Compare commits

...

95 Commits

Author SHA1 Message Date
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
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
smueller
08af99ed52 Implement premium features and GDPR compliance in bot and WebUI
- Added new models for premium tier configurations and assignments, enabling feature limits for free and premium users.
- Introduced GDPR deletion logging and commands for user data management.
- Enhanced logging configuration with retention days for audit logs.
- Updated bot commands and job processing to include new premium and GDPR functionalities.
- Improved localization with new keys for premium features and GDPR commands in both English and German.
- Added UI components for managing premium settings and logging retention in the WebUI.
2026-07-22 16:59:23 +02:00
smueller
4852f16f79 Enhance bot and WebUI functionality with new features and environment updates
- Added support for new environment variables in `.env.example` for public site and legal operator information.
- Integrated core commands into the bot's command structure for improved functionality.
- Implemented a new `publishBotStatus` function to update bot status in Redis, enhancing monitoring capabilities.
- Updated the landing page to include features, invite links, and support server access, improving user experience.
- Enhanced localization with new keys for core commands and landing page elements in both English and German.
- Improved error handling and logging for bot presence updates and status publishing.
2026-07-22 16:47:09 +02:00
smueller
0b4ac0da29 Update Redis configuration to support BullMQ requirements
- Changed `maxRetriesPerRequest` from 3 to null to comply with BullMQ's shared ioredis client requirements.
- Ensured proper initialization of Redis connection for improved performance and compatibility.
2026-07-22 16:15:59 +02:00
smueller
a5bf56f5c9 Update Dockerfile to include new environment variables for bot configuration
- Added `BOT_TOKEN` and `OWNER_USER_IDS` placeholders to the environment configuration for improved bot management.
- Ensured compatibility with the latest features in the Owner Panel and job processing enhancements.
2026-07-22 16:11:30 +02:00
smueller
8a8aefb4fb Implement Owner Panel features and maintenance mode handling
- Introduced the Owner Panel with new routes and UI components for managing guilds, user blacklists, feature flags, and bot presence.
- Added maintenance mode functionality to prevent non-owner users from executing commands during maintenance periods.
- Enhanced localization with new keys for Owner Panel features in both English and German.
- Updated job processing to include a presence refresh job, ensuring the bot's status is regularly updated.
- Improved environment configuration to support owner user IDs for access control.
2026-07-22 16:10:30 +02:00
smueller
bcbfa07697 Implement Batch B features and enhancements for dashboard modules
- Completed integration of shared Zod schemas for various dashboard modules, including Tickets, Giveaways, and more, with new tests added.
- Established WebUI-BullMQ infrastructure for managing jobs related to giveaways, schedules, and guild backups.
- Expanded bot functionality with new jobs for giveaways and suggestions, enabling staff actions through the dashboard.
- Updated module configurations, API routes, and UI components for improved management of tickets, giveaways, and guild backups.
- Enhanced localization with new i18n keys for Batch B modules and verified successful builds across all packages.
2026-07-22 15:57:18 +02:00
smueller
39cca96fed Enhance dashboard overview with activity summary and sidebar commands
- Integrated activity summary into the Guild Overview page, displaying message count, voice minutes, and active user count for the last 7 days.
- Updated sidebar to include a new "Commands" link for easier navigation.
- Added corresponding localization keys for activity metrics in both English and German JSON files.
2026-07-22 15:54:03 +02:00
smueller
e0b4077552 Remove deprecated ModulePlaceholderPage component for dashboard modules. This file was previously used as a catch-all for remaining modules but is no longer needed due to the introduction of dedicated route folders for Batch A modules. 2026-07-22 15:47:34 +02:00
smueller
38979b3c8b Refactor scheduler module and enhance scheduled message handling
- Updated `SchedulerValidationError` to include error codes for better error management.
- Renamed functions for clarity: `listSchedules` to `listScheduledMessagesDashboard` and `createSchedule` to `createScheduledMessageDashboard`.
- Improved validation logic for cron expressions and scheduling times.
- Enhanced the `deleteSchedule` function to ensure proper handling of existing scheduled messages.
- Adjusted database query order and limits for better performance in fetching scheduled messages.
2026-07-22 15:42:57 +02:00
smueller
47b6cfabbc Add suggestion status update job and queue integration
- Introduced a new `SuggestionStatusUpdateJob` type and corresponding worker to handle suggestion status updates in the bot's job processing.
- Added `suggestionsQueue` and `suggestionsQueueEvents` for managing suggestion-related jobs in both the bot and WebUI.
- Implemented `runSuggestionStatusUpdateJob` to facilitate staff actions on suggestions via the WebUI, ensuring consistent behavior with existing commands.
- Enhanced error handling for suggestion actions to improve user experience and reliability.
2026-07-22 15:39:31 +02:00
smueller
0ac69ee840 Refactor giveaway job processing and update environment documentation
- Enhanced the handling of the `giveawayCreate` job in the bot's job processing for improved consistency.
- Clarified the usage of `BOT_TOKEN` in the `.env.example` file for both the bot and WebUI.
- Integrated the `bullmq` dependency into the WebUI package to bolster job management capabilities.
2026-07-22 15:34:20 +02:00
smueller
387fbf11da Refactor giveaway job processing and enhance environment documentation
- Improved handling of the `giveawayCreate` job in the bot's job processing for better consistency.
- Updated `.env.example` to clarify the usage of `BOT_TOKEN` for both the bot and WebUI.
- Added `bullmq` dependency to the WebUI package for enhanced job management capabilities.
2026-07-22 15:31:03 +02:00
smueller
429f974bfc Refactor giveaway job processing and update environment documentation
- Improved the `giveawayCreate` job handling in the bot's job processing, ensuring consistent functionality with existing commands.
- Enhanced the `.env.example` file to provide clearer instructions on the usage of `BOT_TOKEN` for both the bot and WebUI.
- Added the `bullmq` dependency to the WebUI package for better job management capabilities.
2026-07-22 15:22:56 +02:00
smueller
677142672f Enhance giveaway job handling and update environment configuration
- Added support for a new `giveawayCreate` job in the bot's job processing, allowing for giveaways to be created via the WebUI.
- Introduced `runGiveawayCreateJob` function to handle the creation of giveaways, ensuring consistent behavior with existing commands.
- Updated `.env.example` to clarify the usage of `BOT_TOKEN` for both the bot and WebUI, enhancing documentation for environment variables.
- Added `bullmq` dependency in the WebUI package for job management capabilities.
2026-07-22 15:19:04 +02:00
smueller
2ef6b23136 Implement Phase 7 features and enhance dashboard schemas
- Updated PHASE-TRACKING.md to reflect the completion of Batch A features, including the implementation of dashboard pages and API routes for various modules.
- Added new Prisma models and migration for owner features, including CommandOverride and GlobalUserBlacklist.
- Expanded dashboard schemas in dashboard.ts to include Ticket, Giveaway, Self Role, Tag, and Starboard configurations, along with their respective validation tests in dashboard.test.ts.
- Enhanced localization files with new i18n keys for the added modules and features.
- Verified successful builds and tests across multiple packages, ensuring stability and functionality of new features.
2026-07-22 15:10:30 +02:00
smueller
d079cfde79 Enhance ESLint configuration and update component destructuring for improved code quality
- Added a rule to allow unused variables prefixed with an underscore in ESLint configuration.
- Updated destructuring in leveling-form, logging-form, moderation-form, and welcome-form components to prefix unused variables with an underscore, improving clarity and adherence to ESLint rules.
- Expanded German and English localization files with new moderation, auto-moderation, logging, welcome, verification, leveling, economy, and fun module settings for better user experience.
2026-07-22 14:56:03 +02:00
smueller
1de28fa494 Enhance environment configuration and expand Prisma schema for bot management
- Added OWNER_USER_IDS to .env.example for specifying global bot owners.
- Introduced new models in the Prisma schema: CommandOverride, OwnerTeamMember, GlobalUserBlacklist, GuildBlacklist, FeatureFlag, BotPresenceConfig, ChangelogEntry, and OwnerAuditLog to support advanced bot management features.
- Updated env.ts to preprocess OWNER_USER_IDS for better handling of global bot owner IDs.
- Modified ModulePlaceholderPage to ensure proper routing for remaining dashboard modules.
2026-07-22 14:51:56 +02:00
smueller
946283dfba Refactor session management and enhance OAuth2 callback handling
- Updated session management functions to include cookie handling for session creation and destruction.
- Introduced applySessionCookie and clearSessionCookie functions for better cookie management in responses.
- Enhanced error logging in the OAuth2 callback to improve debugging.
- Revised PHASE-TRACKING.md to reflect changes in session handling and OAuth2 integration.
2026-07-22 14:36:14 +02:00
smueller
1de6eac078 Update .env.example and PHASE-TRACKING.md for WebUI configuration changes
- Changed WEBUI_URL in .env.example from localhost to the accessible host IP address (http://10.111.0.65:3000) for proper deployment.
- Updated PHASE-TRACKING.md to reflect the new OAuth2-Redirect URLs and clarify the WebUI integration process.
2026-07-22 14:26:06 +02:00
smueller
b7ad501704 Enhance environment variable handling and Dockerfile configuration
- Introduced a utility function to preprocess environment variables, converting empty strings, undefined, or null values to undefined for better validation in EnvSchema.
- Updated Dockerfile to set PORT and HOSTNAME environment variables for improved container configuration.
- Revised PHASE-TRACKING.md to include automated deployment verification steps, ensuring a smoother deployment process.
2026-07-22 14:22:57 +02:00
smueller
59b3ccb0ca Enhance Dockerfile and Redis configuration for improved build-time handling
- Added environment variables in Dockerfile for build-time placeholders, ensuring proper static analysis during the build process.
- Updated Redis initialization in redis.ts to include lazy connection, preventing network access during module import and improving error handling with a console log for connection errors.
2026-07-22 14:15:52 +02:00
smueller
6a155597ce Remove console error suppression in toApiErrorResponse for improved error logging 2026-07-22 14:06:12 +02:00
smueller
4fcbe38b56 Update .env.example, tsconfig.json, and PHASE-TRACKING.md for WebUI integration and feature completion
- Added WEBUI_URL and SESSION_SECRET to .env.example for improved configuration.
- Updated TypeScript target in apps/webui/tsconfig.json to ES2022 for modern features.
- Revised PHASE-TRACKING.md to reflect the completion of Phase 6, detailing implemented features and pending manual tests for the WebUI, including Discord OAuth2 integration and session management.
2026-07-22 14:04:35 +02:00
smueller
84476e6277 Refactor layout and home page for improved localization and session handling
- Updated RootLayout to include locale support and integrated ThemeProvider and LocaleProvider for better internationalization.
- Replaced static home page content with a redirect based on user session status, enhancing user experience by directing to the appropriate dashboard or login page.
- Switched font from Geist to Inter for improved typography consistency.
2026-07-22 14:01:09 +02:00
smueller
04e04eb11d Update fun module configuration and enhance CSS styles
- Added 'enabled' property to FunConfigSchema with a default value of true for better configuration management.
- Refactored getFunConfig to return a complete default configuration when no data is found.
- Improved globals.css by expanding CSS variables for light and dark themes, enhancing styling consistency across the application.
2026-07-22 13:56:33 +02:00
smueller
7bc1684566 Enhance Prisma schema and documentation for dashboard features
- Added new models for DashboardAccessRule and DashboardAuditLog to the Prisma schema, improving access control and logging capabilities.
- Updated GuildSettings model to include timezone support.
- Revised PHASE-TRACKING.md to reflect the completion of Phase 5 and outline upcoming Phase 6 features, including dashboard layout and settings framework.
- Exported new phase 6 features in shared index for better modularity.
2026-07-22 13:50:14 +02:00
smueller
6f3da6bb15 Add BOT_CLIENT_SECRET to .env.example for enhanced configuration options 2026-07-22 13:37:47 +02:00
smueller
12066befa8 Add stats, feeds, scheduler, and guild backup features to the bot
- Introduced new models in the Prisma schema for statistics, social feeds, scheduled messages, and guild backups, enhancing the bot's functionality.
- Implemented command modules for managing stats channels, feeds, scheduling messages, and creating/restoring backups, improving user engagement and server management.
- Updated job handling to include dedicated workers for stats updates, feed polling, scheduling, and backup restoration, enhancing automation.
- Enhanced command localization for new features in both German and English.
- Documented the new features and their setup processes in PHASE-TRACKING.md for clarity on implementation steps.
2026-07-22 13:36:39 +02:00
smueller
52b10c9536 Add new features for giveaways, tickets, self-roles, tags, starboard, suggestions, and birthdays
- Integrated new command modules for managing giveaways, tickets, self-roles, tags, starboard, suggestions, and birthdays, enhancing the bot's capabilities.
- Implemented job handling for giveaway endings, ticket management, and birthday role expirations, improving automation and user experience.
- Updated command definitions and interaction handling to support new features, ensuring smooth user interactions.
- Enhanced the job processing system with dedicated workers for managing various tasks, including auto-closing tickets and running birthday checks.
- Documented the new features and their setup processes in PHASE-TRACKING.md for clarity on implementation steps.
2026-07-22 13:21:28 +02:00
smueller
1ceb8c3574 Refactor tag editing command to improve option handling and embed structure
- Simplified the retrieval of command options for editing tags, enhancing readability and maintainability.
- Updated embed handling to use an object structure instead of null, ensuring consistency in the updateTag function.
- Improved overall clarity by using descriptive variable names for command options.
2026-07-22 13:09:06 +02:00
smueller
8ec9b94879 Refactor reaction handling and tag response types for improved type safety
- Updated the event handler for message reaction removal to enhance type safety by specifying user types.
- Changed the return type of the buildTagReply function from MessageReplyOptions to InteractionReplyOptions for better compatibility with interaction responses.
- Adjusted embed handling in tag creation and update functions to use undefined instead of null for consistency.
2026-07-22 13:08:58 +02:00
smueller
a583db7a15 Add giveaway, ticket, self-role, starboard, and suggestion features to the bot
- Introduced new models in the Prisma schema for giveaways, tickets, self-role panels, starboard configurations, and suggestions, enhancing the bot's functionality.
- Implemented job handling for self-role expiration and ticket management, improving user experience.
- Updated command localization to support new features in both German and English.
- Enhanced the job processing system to include dedicated workers for managing tickets and self-role expirations.
- Documented the new features and their setup processes in PHASE-TRACKING.md for clarity on implementation steps.
2026-07-22 13:08:37 +02:00
smueller
f2f856628d Refactor command registration to support guild-specific commands
- Updated the command registration process to allow for instant synchronization of guild commands, while maintaining global command registration.
- Introduced optional BOT_GUILD_ID environment variable for guild-specific command handling.
- Enhanced logging to differentiate between guild and global command registrations, improving traceability.
2026-07-22 12:57:25 +02:00
smueller
58623bd1d3 Enhance bot command handling with new leveling, economy, and utility features
- Integrated leveling and economy commands, including XP management and shop interactions.
- Added context menu support for utility commands, improving user interaction options.
- Updated command registration to include new commands and context menus, enhancing overall functionality.
- Improved logging to track the registration of commands and context menus.
- Refactored interaction handling to accommodate new command types and ensure smooth execution.
2026-07-22 12:56:00 +02:00
smueller
fa5910ec43 Implement leveling and economy features with associated models and commands
- Added new models for leveling and economy functionalities in the Prisma schema, including LevelingConfig, MemberLevel, LevelReward, EconomyConfig, MemberEconomy, ShopItem, and InventoryItem.
- Introduced commands for managing XP, economy transactions, and shop interactions, enhancing user engagement.
- Updated job handling to include reminders and poll closing functionalities.
- Enhanced localization support for new commands and features in both German and English.
- Improved the bot's job processing capabilities with a dedicated reminder worker for scheduled tasks.
2026-07-22 12:49:17 +02:00
smueller
2518119257 Enhance bot functionality with AutoMod, Logging, Welcome, and Verification features
- Implemented AutoMod capabilities including spam filtering, anti-raid, and anti-nuke actions.
- Added Logging module to track moderation actions and events across the bot.
- Introduced Welcome module for customizable welcome messages and autoroles.
- Developed Verification system with captcha and role assignment features.
- Updated Prisma schema to include new models for AutoMod, Logging, Welcome, and Verification.
- Enhanced command localization for new features in both German and English.
- Improved health server to handle captcha verification requests.
- Added new environment variable for public base URL to support captcha links.
2026-07-22 12:28:42 +02:00
smueller
a44f4d6641 Update README and moderation commands for localization and migration support
- Revised README to include database migration steps and a new section for database management.
- Enhanced moderation command responses to utilize localized messages for unknown commands.
- Refactored moderation commands to use centralized command data definitions, improving maintainability and consistency.
- Added localization support for command descriptions and options in both German and English.
- Versioned Prisma migrations for better tracking of database changes.
2026-07-22 12:06:16 +02:00
smueller
405b70bdfc Refactor moderation actions to include channel and source metadata
- Updated moderation action functions to include `channelId` and `source` in the metadata for better tracking of actions.
- Introduced an `auditFrom` function to streamline the addition of audit information across various commands.
- Removed `prisma/migrations` from .gitignore to ensure migration files are tracked.
- Enhanced the `CreateCaseInput` type to accommodate new metadata structure.
2026-07-22 11:28:43 +02:00
smueller
878478e4fc Implement confirmation flow for destructive moderation actions
- Added confirmation prompts for destructive commands such as `/ban`, `/purge`, and `/case delete` to enhance user safety.
- Integrated locale support for confirmation messages, ensuring clarity in both German and English.
- Refactored command logic to utilize the new confirmation system, improving user experience and reducing accidental actions.
2026-07-22 11:26:49 +02:00
smueller
a17d161c06 Enhance moderation commands with locale support and permission checks
- Updated moderation commands to utilize locale for user-facing messages, improving internationalization.
- Refactored permission checks to include locale as a parameter, ensuring accurate responses based on the server's language settings.
- Added new translation keys for bot permissions and command restrictions in both German and English.
2026-07-22 11:23:10 +02:00
smueller
39a47074ae Add documentation requirements and asset respect guidelines to nexumi.mdc
- Mandate progress documentation in `docs/PHASE-TRACKING.md` for relevant changes.
- Emphasize the importance of respecting and documenting existing project assets, such as the bot logo.
2026-07-22 11:21:08 +02:00
544 changed files with 66261 additions and 401 deletions

View File

@@ -16,6 +16,8 @@ alwaysApply: true
- Keine Stub-Implementierungen, keine `TODO`-Platzhalter, keine auskommentierten Halbfertigteile in abgeschlossener Arbeit. Ein Modul ist fertig, wenn Commands, Datenbank, Jobs und (ab Phase 7) die WebUI-Seite funktionieren.
- Das Moderations-Modul aus Phase 1 ist das Referenzmodul. Jedes weitere Modul übernimmt dessen Struktur (Ordnerlayout, Command-Registrierung, Settings-Zugriff, Fehlerbehandlung, Tests) exakt.
- Bei Unsicherheit über Discord-API-Verhalten: discord.js-v14-Dokumentation prüfen statt raten. Keine erfundenen Methoden oder Events.
- Fortschritt verpflichtend dokumentieren: `docs/PHASE-TRACKING.md` bei jeder relevanten Änderung aktualisieren (abgeschlossen/offen/manuelle Tests).
- Vorhandene Projekt-Assets respektieren und dokumentieren (z. B. Bot-Logo unter `docs/logo/`).
## Stack (nicht verhandelbar)

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
node_modules
.git
.turbo
dist
coverage
.env
.env.local
apps/*/dist
apps/*/.next
packages/*/dist
**/*.md
.cursor

View File

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

1
.gitignore vendored
View File

@@ -8,4 +8,3 @@ pnpm-lock.yaml
apps/*/dist
apps/*/.next
packages/*/dist
prisma/migrations

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

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

View File

@@ -1,6 +1,6 @@
# Nexumi
Nexumi is a self-hosted public Discord bot with dashboard architecture, built as a TypeScript monorepo.
Nexumi is a public Discord bot with dashboard architecture, built as a TypeScript monorepo.
## Phase 1 scope
@@ -19,9 +19,11 @@ Nexumi is a self-hosted public Discord bot with dashboard architecture, built as
- `pnpm install`
3. Generate Prisma client:
- `pnpm prisma:generate`
4. Start stack:
4. Apply database migrations:
- `pnpm prisma:migrate`
5. Start stack:
- `docker compose up -d`
5. Start bot locally:
6. Start bot locally:
- `pnpm --filter @nexumi/bot dev`
## Verification commands
@@ -30,6 +32,10 @@ Nexumi is a self-hosted public Discord bot with dashboard architecture, built as
- `pnpm lint`
- `pnpm test`
## Database
See `docs/DATABASE.md` for migrations, backup and restore runbooks.
## Backup and restore note
Phase 1 includes the BullMQ foundation for scheduled jobs. Database backup rotation is configured via `BACKUP_RETENTION_DAYS` and the `backups` volume is already reserved in compose.

View File

@@ -1,6 +1,7 @@
FROM node:22-alpine AS base
WORKDIR /app
RUN corepack enable
RUN apk add --no-cache openssl
FROM base AS deps
COPY package.json pnpm-workspace.yaml turbo.json tsconfig.base.json ./
@@ -9,18 +10,19 @@ COPY packages/shared/package.json packages/shared/package.json
RUN pnpm install --frozen-lockfile=false
FROM deps AS build
COPY . .
COPY apps ./apps
COPY packages ./packages
COPY eslint.config.js .eslintrc.cjs .prettierrc ./
RUN pnpm install --frozen-lockfile=false
RUN pnpm --filter @nexumi/shared build
RUN pnpm --filter @nexumi/bot prisma:generate
RUN pnpm --filter @nexumi/bot build
FROM node:22-alpine AS runner
WORKDIR /app
RUN corepack enable
FROM base AS runner
RUN apk add --no-cache postgresql16-client
COPY --from=build /app/package.json ./package.json
COPY --from=build /app/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/apps/bot/dist ./apps/bot/dist
COPY --from=build /app/apps/bot/prisma ./apps/bot/prisma
COPY --from=build /app/packages/shared/dist ./packages/shared/dist
COPY --from=build /app/apps/bot/package.json ./apps/bot/package.json
CMD ["node", "apps/bot/dist/index.js"]
COPY --from=build /app/apps/bot ./apps/bot
COPY --from=build /app/packages/shared ./packages/shared
CMD ["sh", "-c", "pnpm --filter @nexumi/bot prisma:migrate && node apps/bot/dist/index.js"]

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

View File

@@ -0,0 +1,128 @@
-- CreateTable
CREATE TABLE "Guild" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Guild_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GuildSettings" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"locale" TEXT NOT NULL DEFAULT 'de',
"moderationEnabled" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "GuildSettings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Case" (
"id" TEXT NOT NULL,
"caseNumber" INTEGER NOT NULL,
"guildId" TEXT NOT NULL,
"targetUserId" TEXT NOT NULL,
"moderatorId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"reason" TEXT,
"metadata" JSONB,
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Case_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Warning" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"moderatorId" TEXT NOT NULL,
"reason" TEXT,
"caseId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Warning_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ModNote" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"moderatorId" TEXT NOT NULL,
"note" TEXT NOT NULL,
"caseId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ModNote_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EscalationRule" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"warnCount" INTEGER NOT NULL,
"action" TEXT NOT NULL,
"durationMs" INTEGER,
"reason" TEXT,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "EscalationRule_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "GuildSettings_guildId_key" ON "GuildSettings"("guildId");
-- CreateIndex
CREATE INDEX "Case_guildId_targetUserId_idx" ON "Case"("guildId", "targetUserId");
-- CreateIndex
CREATE UNIQUE INDEX "Case_guildId_caseNumber_key" ON "Case"("guildId", "caseNumber");
-- CreateIndex
CREATE INDEX "Warning_guildId_userId_idx" ON "Warning"("guildId", "userId");
-- CreateIndex
CREATE INDEX "ModNote_guildId_userId_idx" ON "ModNote"("guildId", "userId");
-- CreateIndex
CREATE INDEX "EscalationRule_guildId_enabled_idx" ON "EscalationRule"("guildId", "enabled");
-- CreateIndex
CREATE UNIQUE INDEX "EscalationRule_guildId_warnCount_key" ON "EscalationRule"("guildId", "warnCount");
-- AddForeignKey
ALTER TABLE "GuildSettings" ADD CONSTRAINT "GuildSettings_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Case" ADD CONSTRAINT "Case_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Warning" ADD CONSTRAINT "Warning_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Warning" ADD CONSTRAINT "Warning_caseId_fkey" FOREIGN KEY ("caseId") REFERENCES "Case"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ModNote" ADD CONSTRAINT "ModNote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ModNote" ADD CONSTRAINT "ModNote_caseId_fkey" FOREIGN KEY ("caseId") REFERENCES "Case"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EscalationRule" ADD CONSTRAINT "EscalationRule_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,148 @@
-- CreateTable
CREATE TABLE "AutoModConfig" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"antiRaidEnabled" BOOLEAN NOT NULL DEFAULT false,
"antiRaidJoinThreshold" INTEGER NOT NULL DEFAULT 10,
"antiRaidWindowSeconds" INTEGER NOT NULL DEFAULT 10,
"antiRaidAction" TEXT NOT NULL DEFAULT 'LOCKDOWN',
"antiNukeEnabled" BOOLEAN NOT NULL DEFAULT false,
"antiNukeBanThreshold" INTEGER NOT NULL DEFAULT 5,
"antiNukeChannelThreshold" INTEGER NOT NULL DEFAULT 3,
"antiNukeWindowSeconds" INTEGER NOT NULL DEFAULT 60,
"lockdownActive" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AutoModConfig_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AutoModRule" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"ruleType" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"action" TEXT NOT NULL DEFAULT 'DELETE',
"threshold" JSONB,
"exceptions" JSONB,
"wordList" JSONB,
"durationMs" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AutoModRule_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LoggingConfig" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"ignoreBots" BOOLEAN NOT NULL DEFAULT true,
"ignoredChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"ignoredRoleIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LoggingConfig_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LogChannel" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"eventType" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LogChannel_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WelcomeConfig" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"welcomeEnabled" BOOLEAN NOT NULL DEFAULT false,
"leaveEnabled" BOOLEAN NOT NULL DEFAULT false,
"welcomeChannelId" TEXT,
"leaveChannelId" TEXT,
"welcomeType" TEXT NOT NULL DEFAULT 'TEXT',
"welcomeContent" TEXT,
"welcomeEmbed" JSONB,
"leaveContent" TEXT,
"leaveEmbed" JSONB,
"welcomeDmEnabled" BOOLEAN NOT NULL DEFAULT false,
"welcomeDmContent" TEXT,
"userAutoroleIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"botAutoroleIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"imageTitle" TEXT,
"imageSubtitle" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WelcomeConfig_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VerificationConfig" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"channelId" TEXT,
"verifiedRoleId" TEXT,
"unverifiedRoleId" TEXT,
"mode" TEXT NOT NULL DEFAULT 'BUTTON',
"minAccountAgeDays" INTEGER NOT NULL DEFAULT 0,
"failAction" TEXT NOT NULL DEFAULT 'KICK',
"panelChannelId" TEXT,
"panelMessageId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "VerificationConfig_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "AutoModConfig_guildId_key" ON "AutoModConfig"("guildId");
-- CreateIndex
CREATE INDEX "AutoModRule_guildId_enabled_idx" ON "AutoModRule"("guildId", "enabled");
-- CreateIndex
CREATE UNIQUE INDEX "AutoModRule_guildId_ruleType_key" ON "AutoModRule"("guildId", "ruleType");
-- CreateIndex
CREATE UNIQUE INDEX "LoggingConfig_guildId_key" ON "LoggingConfig"("guildId");
-- CreateIndex
CREATE INDEX "LogChannel_guildId_idx" ON "LogChannel"("guildId");
-- CreateIndex
CREATE UNIQUE INDEX "LogChannel_guildId_eventType_key" ON "LogChannel"("guildId", "eventType");
-- CreateIndex
CREATE UNIQUE INDEX "WelcomeConfig_guildId_key" ON "WelcomeConfig"("guildId");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationConfig_guildId_key" ON "VerificationConfig"("guildId");
-- AddForeignKey
ALTER TABLE "AutoModConfig" ADD CONSTRAINT "AutoModConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AutoModRule" ADD CONSTRAINT "AutoModRule_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoggingConfig" ADD CONSTRAINT "LoggingConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LogChannel" ADD CONSTRAINT "LogChannel_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WelcomeConfig" ADD CONSTRAINT "WelcomeConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VerificationConfig" ADD CONSTRAINT "VerificationConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,180 @@
-- CreateTable
CREATE TABLE "LevelingConfig" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"textXpMin" INTEGER NOT NULL DEFAULT 15,
"textXpMax" INTEGER NOT NULL DEFAULT 25,
"textCooldownSeconds" INTEGER NOT NULL DEFAULT 60,
"voiceXpPerMinute" INTEGER NOT NULL DEFAULT 10,
"noXpChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"noXpRoleIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"roleMultipliers" JSONB,
"channelMultipliers" JSONB,
"levelUpMode" TEXT NOT NULL DEFAULT 'CHANNEL',
"levelUpChannelId" TEXT,
"levelUpMessage" TEXT,
"stackRewards" BOOLEAN NOT NULL DEFAULT true,
"cardAccentColor" TEXT NOT NULL DEFAULT '#6366F1',
"cardBackgroundColor" TEXT NOT NULL DEFAULT '#111827',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LevelingConfig_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "MemberLevel" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"xp" INTEGER NOT NULL DEFAULT 0,
"level" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MemberLevel_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "LevelReward" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"level" INTEGER NOT NULL,
"roleId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LevelReward_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "EconomyConfig" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"currencyName" TEXT NOT NULL DEFAULT 'Coins',
"currencySymbol" TEXT NOT NULL DEFAULT '🪙',
"dailyAmount" INTEGER NOT NULL DEFAULT 200,
"weeklyAmount" INTEGER NOT NULL DEFAULT 1000,
"workMin" INTEGER NOT NULL DEFAULT 50,
"workMax" INTEGER NOT NULL DEFAULT 150,
"workCooldownSeconds" INTEGER NOT NULL DEFAULT 3600,
"startingBalance" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "EconomyConfig_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "MemberEconomy" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"balance" INTEGER NOT NULL DEFAULT 0,
"lastDailyAt" TIMESTAMP(3),
"lastWeeklyAt" TIMESTAMP(3),
"lastWorkAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MemberEconomy_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "ShopItem" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"price" INTEGER NOT NULL,
"roleId" TEXT,
"stock" INTEGER,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ShopItem_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "InventoryItem" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"quantity" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "InventoryItem_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "Poll" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"messageId" TEXT,
"creatorId" TEXT NOT NULL,
"question" TEXT NOT NULL,
"options" JSONB NOT NULL,
"multiSelect" BOOLEAN NOT NULL DEFAULT false,
"anonymous" BOOLEAN NOT NULL DEFAULT false,
"endsAt" TIMESTAMP(3),
"closedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Poll_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "PollVote" (
"id" TEXT NOT NULL,
"pollId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"optionIndex" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PollVote_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "Reminder" (
"id" TEXT NOT NULL,
"guildId" TEXT,
"userId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"content" TEXT NOT NULL,
"remindAt" TIMESTAMP(3) NOT NULL,
"recurringCron" TEXT,
"jobId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Reminder_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "AfkStatus" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"reason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AfkStatus_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "LevelingConfig_guildId_key" ON "LevelingConfig"("guildId");
CREATE UNIQUE INDEX "MemberLevel_guildId_userId_key" ON "MemberLevel"("guildId", "userId");
CREATE INDEX "MemberLevel_guildId_xp_idx" ON "MemberLevel"("guildId", "xp");
CREATE UNIQUE INDEX "LevelReward_guildId_level_key" ON "LevelReward"("guildId", "level");
CREATE INDEX "LevelReward_guildId_idx" ON "LevelReward"("guildId");
CREATE UNIQUE INDEX "EconomyConfig_guildId_key" ON "EconomyConfig"("guildId");
CREATE UNIQUE INDEX "MemberEconomy_guildId_userId_key" ON "MemberEconomy"("guildId", "userId");
CREATE INDEX "MemberEconomy_guildId_balance_idx" ON "MemberEconomy"("guildId", "balance");
CREATE INDEX "ShopItem_guildId_enabled_idx" ON "ShopItem"("guildId", "enabled");
CREATE UNIQUE INDEX "InventoryItem_guildId_userId_itemId_key" ON "InventoryItem"("guildId", "userId", "itemId");
CREATE INDEX "InventoryItem_guildId_userId_idx" ON "InventoryItem"("guildId", "userId");
CREATE INDEX "Poll_guildId_idx" ON "Poll"("guildId");
CREATE UNIQUE INDEX "PollVote_pollId_userId_optionIndex_key" ON "PollVote"("pollId", "userId", "optionIndex");
CREATE INDEX "PollVote_pollId_idx" ON "PollVote"("pollId");
CREATE INDEX "Reminder_userId_idx" ON "Reminder"("userId");
CREATE INDEX "Reminder_remindAt_idx" ON "Reminder"("remindAt");
CREATE UNIQUE INDEX "AfkStatus_guildId_userId_key" ON "AfkStatus"("guildId", "userId");
ALTER TABLE "LevelingConfig" ADD CONSTRAINT "LevelingConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "MemberLevel" ADD CONSTRAINT "MemberLevel_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "LevelReward" ADD CONSTRAINT "LevelReward_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "EconomyConfig" ADD CONSTRAINT "EconomyConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "MemberEconomy" ADD CONSTRAINT "MemberEconomy_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ShopItem" ADD CONSTRAINT "ShopItem_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "InventoryItem" ADD CONSTRAINT "InventoryItem_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "InventoryItem" ADD CONSTRAINT "InventoryItem_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "ShopItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Poll" ADD CONSTRAINT "Poll_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "PollVote" ADD CONSTRAINT "PollVote_pollId_fkey" FOREIGN KEY ("pollId") REFERENCES "Poll"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Reminder" ADD CONSTRAINT "Reminder_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "AfkStatus" ADD CONSTRAINT "AfkStatus_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,246 @@
CREATE TABLE "Giveaway" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"messageId" TEXT,
"prize" TEXT NOT NULL,
"winnerCount" INTEGER NOT NULL DEFAULT 1,
"endsAt" TIMESTAMP(3) NOT NULL,
"requiredRoleId" TEXT,
"requiredLevel" INTEGER,
"requiredMemberDays" INTEGER,
"paused" BOOLEAN NOT NULL DEFAULT false,
"endedAt" TIMESTAMP(3),
"winners" TEXT[] DEFAULT ARRAY[]::TEXT[],
"entrants" TEXT[] DEFAULT ARRAY[]::TEXT[],
"hostId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Giveaway_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "TicketConfig" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"mode" TEXT NOT NULL DEFAULT 'CHANNEL',
"categoryId" TEXT,
"logChannelId" TEXT,
"transcriptDm" BOOLEAN NOT NULL DEFAULT false,
"inactivityHours" INTEGER NOT NULL DEFAULT 72,
"ratingEnabled" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TicketConfig_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "TicketCategory" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"emoji" TEXT,
"supportRoleIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"formFields" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TicketCategory_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "Ticket" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"categoryId" TEXT,
"openerId" TEXT NOT NULL,
"claimedById" TEXT,
"channelId" TEXT,
"threadId" TEXT,
"status" TEXT NOT NULL DEFAULT 'OPEN',
"priority" TEXT NOT NULL DEFAULT 'NORMAL',
"subject" TEXT,
"formAnswers" JSONB,
"transcriptHtml" TEXT,
"rating" INTEGER,
"closedAt" TIMESTAMP(3),
"lastActivityAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Ticket_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "SelfRolePanel" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"messageId" TEXT,
"title" TEXT NOT NULL,
"description" TEXT,
"mode" TEXT NOT NULL DEFAULT 'BUTTONS',
"behavior" TEXT NOT NULL DEFAULT 'NORMAL',
"roles" JSONB NOT NULL,
"expiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SelfRolePanel_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "Tag" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"content" TEXT,
"embed" JSONB,
"responseType" TEXT NOT NULL DEFAULT 'TEXT',
"triggerWord" TEXT,
"allowedRoleIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"allowedChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"createdById" TEXT NOT NULL,
"useCount" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Tag_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "StarboardConfig" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"channelId" TEXT,
"emoji" TEXT NOT NULL DEFAULT E'',
"threshold" INTEGER NOT NULL DEFAULT 3,
"allowSelfStar" BOOLEAN NOT NULL DEFAULT false,
"ignoredChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "StarboardConfig_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "StarboardEntry" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"sourceChannelId" TEXT NOT NULL,
"sourceMessageId" TEXT NOT NULL,
"starboardMessageId" TEXT NOT NULL,
"starCount" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "StarboardEntry_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "SuggestionConfig" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"openChannelId" TEXT,
"approvedChannelId" TEXT,
"deniedChannelId" TEXT,
"createThread" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SuggestionConfig_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "Suggestion" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"content" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'OPEN',
"messageId" TEXT,
"channelId" TEXT,
"threadId" TEXT,
"upvotes" INTEGER NOT NULL DEFAULT 0,
"downvotes" INTEGER NOT NULL DEFAULT 0,
"voterUpIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"voterDownIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"staffReason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Suggestion_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "BirthdayConfig" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"channelId" TEXT,
"roleId" TEXT,
"message" TEXT,
"timezone" TEXT NOT NULL DEFAULT 'Europe/Berlin',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BirthdayConfig_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "Birthday" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"month" INTEGER NOT NULL,
"day" INTEGER NOT NULL,
"year" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Birthday_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "TempVoiceConfig" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"hubChannelId" TEXT,
"categoryId" TEXT,
"defaultName" TEXT NOT NULL DEFAULT '{user}''s Channel',
"defaultLimit" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TempVoiceConfig_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "TempVoiceChannel" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"ownerId" TEXT NOT NULL,
"controlMessageId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TempVoiceChannel_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "Giveaway_guildId_endsAt_idx" ON "Giveaway"("guildId", "endsAt");
CREATE UNIQUE INDEX "TicketConfig_guildId_key" ON "TicketConfig"("guildId");
CREATE UNIQUE INDEX "TicketCategory_guildId_name_key" ON "TicketCategory"("guildId", "name");
CREATE INDEX "TicketCategory_guildId_idx" ON "TicketCategory"("guildId");
CREATE INDEX "Ticket_guildId_status_idx" ON "Ticket"("guildId", "status");
CREATE INDEX "Ticket_channelId_idx" ON "Ticket"("channelId");
CREATE INDEX "SelfRolePanel_guildId_idx" ON "SelfRolePanel"("guildId");
CREATE UNIQUE INDEX "Tag_guildId_name_key" ON "Tag"("guildId", "name");
CREATE INDEX "Tag_guildId_idx" ON "Tag"("guildId");
CREATE UNIQUE INDEX "StarboardConfig_guildId_key" ON "StarboardConfig"("guildId");
CREATE UNIQUE INDEX "StarboardEntry_guildId_sourceMessageId_key" ON "StarboardEntry"("guildId", "sourceMessageId");
CREATE INDEX "StarboardEntry_guildId_idx" ON "StarboardEntry"("guildId");
CREATE UNIQUE INDEX "SuggestionConfig_guildId_key" ON "SuggestionConfig"("guildId");
CREATE INDEX "Suggestion_guildId_status_idx" ON "Suggestion"("guildId", "status");
CREATE UNIQUE INDEX "BirthdayConfig_guildId_key" ON "BirthdayConfig"("guildId");
CREATE UNIQUE INDEX "Birthday_guildId_userId_key" ON "Birthday"("guildId", "userId");
CREATE INDEX "Birthday_guildId_month_day_idx" ON "Birthday"("guildId", "month", "day");
CREATE UNIQUE INDEX "TempVoiceConfig_guildId_key" ON "TempVoiceConfig"("guildId");
CREATE UNIQUE INDEX "TempVoiceChannel_channelId_key" ON "TempVoiceChannel"("channelId");
CREATE INDEX "TempVoiceChannel_guildId_ownerId_idx" ON "TempVoiceChannel"("guildId", "ownerId");
ALTER TABLE "Giveaway" ADD CONSTRAINT "Giveaway_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "TicketConfig" ADD CONSTRAINT "TicketConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "TicketCategory" ADD CONSTRAINT "TicketCategory_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "TicketCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "SelfRolePanel" ADD CONSTRAINT "SelfRolePanel_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Tag" ADD CONSTRAINT "Tag_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "StarboardConfig" ADD CONSTRAINT "StarboardConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "StarboardEntry" ADD CONSTRAINT "StarboardEntry_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "SuggestionConfig" ADD CONSTRAINT "SuggestionConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Suggestion" ADD CONSTRAINT "Suggestion_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "BirthdayConfig" ADD CONSTRAINT "BirthdayConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Birthday" ADD CONSTRAINT "Birthday_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "TempVoiceConfig" ADD CONSTRAINT "TempVoiceConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "TempVoiceChannel" ADD CONSTRAINT "TempVoiceChannel_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,116 @@
CREATE TABLE "StatsConfig" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"membersChannelId" TEXT,
"onlineChannelId" TEXT,
"boostsChannelId" TEXT,
"membersTemplate" TEXT NOT NULL DEFAULT 'Members: {count}',
"onlineTemplate" TEXT NOT NULL DEFAULT 'Online: {count}',
"boostsTemplate" TEXT NOT NULL DEFAULT 'Boosts: {count}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "StatsConfig_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "InviteStat" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"inviterId" TEXT NOT NULL,
"code" TEXT,
"joins" INTEGER NOT NULL DEFAULT 0,
"leaves" INTEGER NOT NULL DEFAULT 0,
"fakes" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "InviteStat_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "InviteJoin" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"inviterId" TEXT,
"code" TEXT,
"isFake" BOOLEAN NOT NULL DEFAULT false,
"leftAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "InviteJoin_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "ActivityStat" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"channelId" TEXT,
"messageCount" INTEGER NOT NULL DEFAULT 0,
"voiceMinutes" INTEGER NOT NULL DEFAULT 0,
"day" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ActivityStat_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "SocialFeed" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"sourceId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"rolePingId" TEXT,
"template" TEXT,
"lastItemId" TEXT,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SocialFeed_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "ScheduledMessage" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"creatorId" TEXT NOT NULL,
"content" TEXT,
"embed" JSONB,
"rolePingId" TEXT,
"cron" TEXT,
"runAt" TIMESTAMP(3),
"jobId" TEXT,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ScheduledMessage_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "GuildBackup" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"createdById" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GuildBackup_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "StatsConfig_guildId_key" ON "StatsConfig"("guildId");
CREATE UNIQUE INDEX "InviteStat_guildId_inviterId_key" ON "InviteStat"("guildId", "inviterId");
CREATE INDEX "InviteStat_guildId_joins_idx" ON "InviteStat"("guildId", "joins");
CREATE UNIQUE INDEX "InviteJoin_guildId_userId_key" ON "InviteJoin"("guildId", "userId");
CREATE INDEX "InviteJoin_guildId_inviterId_idx" ON "InviteJoin"("guildId", "inviterId");
CREATE UNIQUE INDEX "ActivityStat_guildId_userId_channelId_day_key" ON "ActivityStat"("guildId", "userId", "channelId", "day");
CREATE INDEX "ActivityStat_guildId_day_idx" ON "ActivityStat"("guildId", "day");
CREATE INDEX "SocialFeed_guildId_type_idx" ON "SocialFeed"("guildId", "type");
CREATE INDEX "SocialFeed_enabled_idx" ON "SocialFeed"("enabled");
CREATE INDEX "ScheduledMessage_guildId_enabled_idx" ON "ScheduledMessage"("guildId", "enabled");
CREATE INDEX "GuildBackup_guildId_createdAt_idx" ON "GuildBackup"("guildId", "createdAt");
ALTER TABLE "StatsConfig" ADD CONSTRAINT "StatsConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "InviteStat" ADD CONSTRAINT "InviteStat_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "InviteJoin" ADD CONSTRAINT "InviteJoin_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ActivityStat" ADD CONSTRAINT "ActivityStat_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "SocialFeed" ADD CONSTRAINT "SocialFeed_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ScheduledMessage" ADD CONSTRAINT "ScheduledMessage_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "GuildBackup" ADD CONSTRAINT "GuildBackup_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,46 @@
-- AlterTable
ALTER TABLE "GuildSettings" ADD COLUMN "timezone" TEXT NOT NULL DEFAULT 'Europe/Berlin';
-- CreateTable
CREATE TABLE "DashboardAccessRule" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"modules" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DashboardAccessRule_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DashboardAuditLog" (
"id" TEXT NOT NULL,
"guildId" TEXT,
"actorUserId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"path" TEXT,
"before" JSONB,
"after" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "DashboardAuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "DashboardAccessRule_guildId_idx" ON "DashboardAccessRule"("guildId");
-- CreateIndex
CREATE UNIQUE INDEX "DashboardAccessRule_guildId_roleId_key" ON "DashboardAccessRule"("guildId", "roleId");
-- CreateIndex
CREATE INDEX "DashboardAuditLog_guildId_createdAt_idx" ON "DashboardAuditLog"("guildId", "createdAt");
-- CreateIndex
CREATE INDEX "DashboardAuditLog_actorUserId_createdAt_idx" ON "DashboardAuditLog"("actorUserId", "createdAt");
-- AddForeignKey
ALTER TABLE "DashboardAccessRule" ADD CONSTRAINT "DashboardAccessRule_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DashboardAuditLog" ADD CONSTRAINT "DashboardAuditLog_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,131 @@
-- CreateTable
CREATE TABLE "CommandOverride" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"commandName" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"allowedRoleIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"deniedRoleIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"allowedChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"deniedChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"cooldownSeconds" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CommandOverride_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OwnerTeamMember" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" TEXT NOT NULL,
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OwnerTeamMember_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GlobalUserBlacklist" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"reason" TEXT,
"note" TEXT,
"createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GlobalUserBlacklist_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GuildBlacklist" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"reason" TEXT,
"createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GuildBlacklist_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FeatureFlag" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"rolloutPercent" INTEGER NOT NULL DEFAULT 0,
"whitelistGuildIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FeatureFlag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BotPresenceConfig" (
"id" TEXT NOT NULL DEFAULT 'singleton',
"status" TEXT NOT NULL DEFAULT 'online',
"activityType" TEXT NOT NULL DEFAULT 'Playing',
"activityText" TEXT NOT NULL DEFAULT 'nexumi.de',
"rotatingMessages" JSONB,
"maintenanceMode" BOOLEAN NOT NULL DEFAULT false,
"maintenanceMessage" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BotPresenceConfig_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ChangelogEntry" (
"id" TEXT NOT NULL,
"version" TEXT NOT NULL,
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ChangelogEntry_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OwnerAuditLog" (
"id" TEXT NOT NULL,
"actorUserId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"targetType" TEXT,
"targetId" TEXT,
"before" JSONB,
"after" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OwnerAuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "CommandOverride_guildId_idx" ON "CommandOverride"("guildId");
-- CreateIndex
CREATE UNIQUE INDEX "CommandOverride_guildId_commandName_key" ON "CommandOverride"("guildId", "commandName");
-- CreateIndex
CREATE UNIQUE INDEX "OwnerTeamMember_userId_key" ON "OwnerTeamMember"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "GlobalUserBlacklist_userId_key" ON "GlobalUserBlacklist"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "GuildBlacklist_guildId_key" ON "GuildBlacklist"("guildId");
-- CreateIndex
CREATE UNIQUE INDEX "FeatureFlag_key_key" ON "FeatureFlag"("key");
-- CreateIndex
CREATE INDEX "OwnerAuditLog_createdAt_idx" ON "OwnerAuditLog"("createdAt");
-- AddForeignKey
ALTER TABLE "CommandOverride" ADD CONSTRAINT "CommandOverride_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,91 @@
-- AlterTable
ALTER TABLE "GuildSettings" ADD COLUMN "snipeEnabled" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "LoggingConfig" ADD COLUMN "retentionDays" INTEGER NOT NULL DEFAULT 90;
-- CreateTable
CREATE TABLE "PremiumTierConfig" (
"tier" TEXT NOT NULL,
"maxTags" INTEGER NOT NULL,
"maxFeeds" INTEGER NOT NULL,
"maxBackups" INTEGER NOT NULL,
"features" JSONB NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PremiumTierConfig_pkey" PRIMARY KEY ("tier")
);
-- CreateTable
CREATE TABLE "GuildPremiumAssignment" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"tier" TEXT NOT NULL,
"note" TEXT,
"assignedById" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GuildPremiumAssignment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserPremiumAssignment" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tier" TEXT NOT NULL,
"note" TEXT,
"assignedById" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UserPremiumAssignment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GdprDeletionLog" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"guildId" TEXT,
"summary" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GdprDeletionLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "GuildPremiumAssignment_guildId_key" ON "GuildPremiumAssignment"("guildId");
-- CreateIndex
CREATE INDEX "GuildPremiumAssignment_tier_idx" ON "GuildPremiumAssignment"("tier");
-- CreateIndex
CREATE UNIQUE INDEX "UserPremiumAssignment_userId_key" ON "UserPremiumAssignment"("userId");
-- CreateIndex
CREATE INDEX "UserPremiumAssignment_tier_idx" ON "UserPremiumAssignment"("tier");
-- CreateIndex
CREATE INDEX "GdprDeletionLog_userId_createdAt_idx" ON "GdprDeletionLog"("userId", "createdAt");
-- Seed default tier configs
INSERT INTO "PremiumTierConfig" ("tier", "maxTags", "maxFeeds", "maxBackups", "features", "updatedAt")
VALUES
(
'FREE',
15,
3,
2,
'{"tags":true,"feeds":true,"guildbackup":true}'::jsonb,
CURRENT_TIMESTAMP
),
(
'PREMIUM',
100,
25,
25,
'{"tags":true,"feeds":true,"guildbackup":true}'::jsonb,
CURRENT_TIMESTAMP
);

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

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@@ -14,16 +14,93 @@ model Guild {
settings GuildSettings?
cases Case[]
escalationRules EscalationRule[]
autoModConfig AutoModConfig?
autoModRules AutoModRule[]
loggingConfig LoggingConfig?
logChannels LogChannel[]
welcomeConfig WelcomeConfig?
verificationConfig VerificationConfig?
verificationRecords VerificationRecord[]
levelingConfig LevelingConfig?
memberLevels MemberLevel[]
levelRewards LevelReward[]
economyConfig EconomyConfig?
memberEconomies MemberEconomy[]
shopItems ShopItem[]
inventoryItems InventoryItem[]
polls Poll[]
reminders Reminder[]
afkStatuses AfkStatus[]
giveaways Giveaway[]
ticketConfig TicketConfig?
ticketCategories TicketCategory[]
tickets Ticket[]
selfRolePanels SelfRolePanel[]
tags Tag[]
starboardConfig StarboardConfig?
starboardEntries StarboardEntry[]
suggestionConfig SuggestionConfig?
suggestions Suggestion[]
birthdays Birthday[]
birthdayConfig BirthdayConfig?
tempVoiceConfig TempVoiceConfig?
tempVoiceChannels TempVoiceChannel[]
statsConfig StatsConfig?
inviteStats InviteStat[]
inviteJoins InviteJoin[]
activityStats ActivityStat[]
socialFeeds SocialFeed[]
scheduledMessages ScheduledMessage[]
guildBackups GuildBackup[]
componentMessageBindings ComponentMessageBinding[]
dashboardAccessRules DashboardAccessRule[]
dashboardAuditLogs DashboardAuditLog[]
commandOverrides CommandOverride[]
}
model GuildSettings {
id String @id @default(cuid())
guildId String @unique
locale String @default("de")
timezone String @default("Europe/Berlin")
moderationEnabled Boolean @default(true)
/// Snipe/editsnipe storage and commands (SPEC: default off for privacy).
snipeEnabled Boolean @default(false)
/// Global command channel whitelist (empty = all channels unless denied).
commandAllowedChannelIds String[] @default([])
/// Global command channel blacklist.
commandDeniedChannelIds String[] @default([])
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
}
model DashboardAccessRule {
id String @id @default(cuid())
guildId String
roleId String
modules Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@unique([guildId, roleId])
@@index([guildId])
}
model DashboardAuditLog {
id String @id @default(cuid())
guildId String?
actorUserId String
action String
path String?
before Json?
after Json?
createdAt DateTime @default(now())
guild Guild? @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@index([guildId, createdAt])
@@index([actorUserId, createdAt])
}
model User {
id String @id
createdAt DateTime @default(now())
@@ -53,6 +130,7 @@ model Case {
model Warning {
id String @id @default(cuid())
warningNumber Int
guildId String
userId String
moderatorId String
@@ -63,6 +141,7 @@ model Warning {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
case Case? @relation(fields: [caseId], references: [id], onDelete: SetNull)
@@unique([guildId, warningNumber])
@@index([guildId, userId])
}
@@ -96,3 +175,831 @@ model EscalationRule {
@@unique([guildId, warnCount])
@@index([guildId, enabled])
}
model AutoModConfig {
id String @id @default(cuid())
guildId String @unique
enabled Boolean @default(true)
antiRaidEnabled Boolean @default(false)
antiRaidJoinThreshold Int @default(10)
antiRaidWindowSeconds Int @default(10)
antiRaidAction String @default("LOCKDOWN")
antiNukeEnabled Boolean @default(false)
antiNukeBanThreshold Int @default(5)
antiNukeChannelThreshold Int @default(3)
antiNukeWindowSeconds Int @default(60)
lockdownActive Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
}
model AutoModRule {
id String @id @default(cuid())
guildId String
ruleType String
enabled Boolean @default(true)
action String @default("DELETE")
threshold Json?
exceptions Json?
wordList Json?
durationMs Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@unique([guildId, ruleType])
@@index([guildId, enabled])
}
model LoggingConfig {
id String @id @default(cuid())
guildId String @unique
enabled Boolean @default(true)
ignoreBots Boolean @default(true)
ignoredChannelIds String[] @default([])
ignoredRoleIds String[] @default([])
/// Days to keep Case / dashboard audit rows; 0 = keep forever.
retentionDays Int @default(90)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
}
model LogChannel {
id String @id @default(cuid())
guildId String
eventType String
channelId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@unique([guildId, eventType])
@@index([guildId])
}
model WelcomeConfig {
id String @id @default(cuid())
guildId String @unique
welcomeEnabled Boolean @default(false)
leaveEnabled Boolean @default(false)
welcomeChannelId String?
leaveChannelId String?
welcomeType String @default("TEXT")
welcomeContent String?
welcomeEmbed Json?
welcomeComponents Json?
leaveType String @default("TEXT")
leaveContent String?
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)
welcomeDmContent String?
userAutoroleIds String[] @default([])
botAutoroleIds String[] @default([])
imageTitle String?
imageSubtitle String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
}
model VerificationConfig {
id String @id @default(cuid())
guildId String @unique
enabled Boolean @default(false)
channelId String?
verifiedRoleId String?
unverifiedRoleId String?
mode String @default("BUTTON")
/// MATH | RECAPTCHA_V2 | RECAPTCHA_V3 | HCAPTCHA | TURNSTILE
captchaProvider String @default("MATH")
minAccountAgeDays Int @default(0)
failAction String @default("KICK")
altDetectionEnabled Boolean @default(false)
altBlockOnMatch Boolean @default(true)
altIpWindowDays Int @default(30)
altInviteWindowHours Int @default(48)
altMaxAccountsPerInvite Int @default(3)
panelChannelId String?
panelMessageId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
}
/// One row per successful (or blocked-as-alt) verification attempt fingerprint.
model VerificationRecord {
id String @id @default(cuid())
guildId String
userId String
inviteCode String?
inviterId String?
ipHash String?
userAgentHash String?
captchaProvider String?
flaggedAsAlt Boolean @default(false)
matchedUserId String?
createdAt DateTime @default(now())
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@unique([guildId, userId])
@@index([guildId, ipHash, createdAt])
@@index([guildId, inviteCode, createdAt])
}
model LevelingConfig {
id String @id @default(cuid())
guildId String @unique
enabled Boolean @default(true)
textXpMin Int @default(15)
textXpMax Int @default(25)
textCooldownSeconds Int @default(60)
voiceXpPerMinute Int @default(10)
noXpChannelIds String[] @default([])
noXpRoleIds String[] @default([])
roleMultipliers Json?
channelMultipliers Json?
levelUpMode String @default("CHANNEL")
levelUpChannelId String?
levelUpMessage String?
stackRewards Boolean @default(true)
cardAccentColor String @default("#6366F1")
cardBackgroundColor String @default("#111827")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
}
model MemberLevel {
id String @id @default(cuid())
guildId String
userId String
xp Int @default(0)
level Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@unique([guildId, userId])
@@index([guildId, xp])
}
model LevelReward {
id String @id @default(cuid())
guildId String
level Int
roleId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@unique([guildId, level])
@@index([guildId])
}
model EconomyConfig {
id String @id @default(cuid())
guildId String @unique
enabled Boolean @default(true)
currencyName String @default("Coins")
currencySymbol String @default("🪙")
dailyAmount Int @default(200)
weeklyAmount Int @default(1000)
workMin Int @default(50)
workMax Int @default(150)
workCooldownSeconds Int @default(3600)
startingBalance Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
}
model MemberEconomy {
id String @id @default(cuid())
guildId String
userId String
balance Int @default(0)
lastDailyAt DateTime?
lastWeeklyAt DateTime?
lastWorkAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@unique([guildId, userId])
@@index([guildId, balance])
}
model ShopItem {
id String @id @default(cuid())
guildId String
name String
description String?
price Int
roleId String?
stock Int?
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
inventory InventoryItem[]
@@index([guildId, enabled])
}
model InventoryItem {
id String @id @default(cuid())
guildId String
userId String
itemId String
quantity Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
item ShopItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
@@unique([guildId, userId, itemId])
@@index([guildId, userId])
}
model Poll {
id String @id @default(cuid())
guildId String
channelId String
messageId String?
creatorId String
question String
options Json
multiSelect Boolean @default(false)
anonymous Boolean @default(false)
endsAt DateTime?
closedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
votes PollVote[]
@@index([guildId])
}
model PollVote {
id String @id @default(cuid())
pollId String
userId String
optionIndex Int
createdAt DateTime @default(now())
poll Poll @relation(fields: [pollId], references: [id], onDelete: Cascade)
@@unique([pollId, userId, optionIndex])
@@index([pollId])
}
model Reminder {
id String @id @default(cuid())
guildId String?
userId String
channelId String
content String
remindAt DateTime
recurringCron String?
jobId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild? @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([remindAt])
}
model AfkStatus {
id String @id @default(cuid())
guildId String
userId String
reason String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@unique([guildId, userId])
}
model Giveaway {
id String @id @default(cuid())
guildId String
channelId String
messageId String?
prize String
winnerCount Int @default(1)
endsAt DateTime
requiredRoleId String?
requiredLevel Int?
requiredMemberDays Int?
paused Boolean @default(false)
endedAt DateTime?
winners String[] @default([])
entrants String[] @default([])
hostId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@index([guildId, endsAt])
}
model TicketConfig {
id String @id @default(cuid())
guildId String @unique
enabled Boolean @default(true)
mode String @default("CHANNEL")
categoryId String?
logChannelId String?
panelChannelId String?
panelMessageId String?
transcriptDm Boolean @default(false)
inactivityHours Int @default(72)
ratingEnabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
}
model TicketCategory {
id String @id @default(cuid())
guildId String
name String
description String?
emoji String?
supportRoleIds String[] @default([])
formFields Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
tickets Ticket[]
@@unique([guildId, name])
@@index([guildId])
}
model Ticket {
id String @id @default(cuid())
guildId String
categoryId String?
openerId String
claimedById String?
channelId String?
threadId String?
status String @default("OPEN")
priority String @default("NORMAL")
subject String?
formAnswers Json?
transcriptHtml String?
rating Int?
closedAt DateTime?
lastActivityAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
category TicketCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
@@index([guildId, status])
@@index([channelId])
}
model SelfRolePanel {
id String @id @default(cuid())
guildId String
channelId String
messageId String?
title String
description String?
mode String @default("BUTTONS")
behavior String @default("NORMAL")
roles Json
expiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@index([guildId])
}
model Tag {
id String @id @default(cuid())
guildId String
name String
content String?
embed Json?
components Json?
responseType String @default("TEXT")
triggerWord String?
cooldownSeconds Int @default(15)
allowedRoleIds String[] @default([])
allowedChannelIds String[] @default([])
createdById String
useCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@unique([guildId, name])
@@index([guildId])
}
model StarboardConfig {
id String @id @default(cuid())
guildId String @unique
enabled Boolean @default(false)
channelId String?
emoji String @default("⭐")
threshold Int @default(3)
allowSelfStar Boolean @default(false)
ignoredChannelIds String[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
}
model StarboardEntry {
id String @id @default(cuid())
guildId String
sourceChannelId String
sourceMessageId String
starboardMessageId String
starCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@unique([guildId, sourceMessageId])
@@index([guildId])
}
model SuggestionConfig {
id String @id @default(cuid())
guildId String @unique
enabled Boolean @default(true)
openChannelId String?
approvedChannelId String?
deniedChannelId String?
createThread Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
}
model Suggestion {
id String @id @default(cuid())
guildId String
authorId String
content String
status String @default("OPEN")
messageId String?
channelId String?
threadId String?
upvotes Int @default(0)
downvotes Int @default(0)
voterUpIds String[] @default([])
voterDownIds String[] @default([])
staffReason String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@index([guildId, status])
}
model BirthdayConfig {
id String @id @default(cuid())
guildId String @unique
enabled Boolean @default(true)
channelId String?
roleId String?
message String?
timezone String @default("Europe/Berlin")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
}
model Birthday {
id String @id @default(cuid())
guildId String
userId String
month Int
day Int
year Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@unique([guildId, userId])
@@index([guildId, month, day])
}
model TempVoiceConfig {
id String @id @default(cuid())
guildId String @unique
enabled Boolean @default(false)
hubChannelId String?
categoryId String?
defaultName String @default("{user}'s Channel")
defaultLimit Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
}
model TempVoiceChannel {
id String @id @default(cuid())
guildId String
channelId String @unique
ownerId String
controlMessageId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@index([guildId, ownerId])
}
model StatsConfig {
id String @id @default(cuid())
guildId String @unique
enabled Boolean @default(false)
membersChannelId String?
onlineChannelId String?
boostsChannelId String?
membersTemplate String @default("Members: {count}")
onlineTemplate String @default("Online: {count}")
boostsTemplate String @default("Boosts: {count}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
}
model InviteStat {
id String @id @default(cuid())
guildId String
inviterId String
code String?
joins Int @default(0)
leaves Int @default(0)
fakes Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@unique([guildId, inviterId])
@@index([guildId, joins])
}
model InviteJoin {
id String @id @default(cuid())
guildId String
userId String
inviterId String?
code String?
isFake Boolean @default(false)
leftAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@unique([guildId, userId])
@@index([guildId, inviterId])
}
model ActivityStat {
id String @id @default(cuid())
guildId String
userId String
channelId String?
messageCount Int @default(0)
voiceMinutes Int @default(0)
day DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@unique([guildId, userId, channelId, day])
@@index([guildId, day])
}
model SocialFeed {
id String @id @default(cuid())
guildId String
type String
sourceId String
channelId String
rolePingId String?
template String?
lastItemId String?
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@index([guildId, type])
@@index([enabled])
}
model ScheduledMessage {
id String @id @default(cuid())
guildId String
channelId String
creatorId String
content String?
embed Json?
components Json?
rolePingId String?
cron String?
runAt DateTime?
jobId String?
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@index([guildId, enabled])
}
model ComponentMessageBinding {
id String @id @default(cuid())
guildId String
channelId String
messageId String?
payload Json
createdById String
createdAt DateTime @default(now())
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@index([guildId, createdAt])
@@index([messageId])
}
model GuildBackup {
id String @id @default(cuid())
guildId String
name String
createdById String
payload Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@index([guildId, createdAt])
}
model CommandOverride {
id String @id @default(cuid())
guildId String
commandName String
enabled Boolean @default(true)
allowedRoleIds String[] @default([])
deniedRoleIds String[] @default([])
allowedChannelIds String[] @default([])
deniedChannelIds String[] @default([])
cooldownSeconds Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@unique([guildId, commandName])
@@index([guildId])
}
model OwnerTeamMember {
id String @id @default(cuid())
userId String @unique
role String
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model GlobalUserBlacklist {
id String @id @default(cuid())
userId String @unique
reason String?
note String?
createdById String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model GuildBlacklist {
id String @id @default(cuid())
guildId String @unique
reason String?
createdById String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model FeatureFlag {
id String @id @default(cuid())
key String @unique
enabled Boolean @default(false)
rolloutPercent Int @default(0)
whitelistGuildIds String[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model BotPresenceConfig {
id String @id @default("singleton")
status String @default("online")
activityType String @default("Watching")
activityText String @default("nexumi.de")
rotatingMessages Json?
maintenanceMode Boolean @default(false)
maintenanceMessage String?
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 {
id String @id @default(cuid())
version String
title String
body String
publishedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model OwnerAuditLog {
id String @id @default(cuid())
actorUserId String
action String
targetType String?
targetId String?
before Json?
after Json?
createdAt DateTime @default(now())
@@index([createdAt])
}
/// Per-tier limits and feature gates (FREE / PREMIUM). Seeded on first use.
model PremiumTierConfig {
tier String @id
maxTags Int
maxFeeds Int
maxBackups Int
/// JSON object of feature key → boolean (tags, feeds, guildbackup, …).
features Json
updatedAt DateTime @updatedAt
}
model GuildPremiumAssignment {
id String @id @default(cuid())
guildId String @unique
tier String
note String?
assignedById String
expiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tier])
}
model UserPremiumAssignment {
id String @id @default(cuid())
userId String @unique
tier String
note String?
assignedById String
expiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tier])
}
model GdprDeletionLog {
id String @id @default(cuid())
userId String
guildId String?
summary Json?
createdAt DateTime @default(now())
@@index([userId, createdAt])
}

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

@@ -1,24 +1,295 @@
import { REST, Routes, type ChatInputCommandInteraction } from 'discord.js';
import {
REST,
Routes,
type ChatInputCommandInteraction,
type MessageContextMenuCommandInteraction
} from 'discord.js';
import { t, tf, type Locale } from '@nexumi/shared';
import { env } from './env.js';
import { logger } from './logger.js';
import { getGuildLocale } from './i18n.js';
import { evaluateCommandGate, applyCommandCooldown, type CommandGateReason } from './command-gates.js';
import {
isGuildBlacklisted,
isModuleEnabled,
isUserBlacklisted,
moduleForCommand
} from './module-gates.js';
import { isMaintenanceMode } from './presence.js';
import { recordCommandMetric } from './metrics.js';
import { captureException } from './sentry.js';
import { moderationCommands } from './modules/moderation/commands.js';
import { automodCommands } from './modules/automod/commands.js';
import { welcomeCommands } from './modules/welcome/commands.js';
import { verificationCommands } from './modules/verification/commands.js';
import { levelingCommands } from './modules/leveling/commands.js';
import { economyCommands } from './modules/economy/commands.js';
import { utilityCommands, utilityContextMenus } from './modules/utility/index.js';
import { funCommands } from './modules/fun/index.js';
import { giveawayCommands } from './modules/giveaways/index.js';
import { ticketsCommands } from './modules/tickets/index.js';
import { selfrolesCommands } from './modules/selfroles/index.js';
import { tagsCommands } from './modules/tags/index.js';
import { starboardCommands } from './modules/starboard/index.js';
import { suggestionsCommands } from './modules/suggestions/index.js';
import { birthdayCommands } from './modules/birthdays/index.js';
import { tempVoiceCommands } from './modules/tempvoice/index.js';
import { statsCommands } from './modules/stats/index.js';
import { feedsCommands } from './modules/feeds/index.js';
import { scheduleCommands } from './modules/scheduler/index.js';
import { guildBackupCommands } from './modules/guildbackup/index.js';
import { coreCommands } from './modules/core/index.js';
import { gdprCommands } from './modules/gdpr/index.js';
import type { BotContext, SlashCommand } from './types.js';
const commands: SlashCommand[] = [...moderationCommands];
const commands: SlashCommand[] = [
...coreCommands,
...gdprCommands,
...moderationCommands,
...automodCommands,
...welcomeCommands,
...verificationCommands,
...levelingCommands,
...economyCommands,
...utilityCommands,
...funCommands,
...giveawayCommands,
...ticketsCommands,
...selfrolesCommands,
...tagsCommands,
...starboardCommands,
...suggestionsCommands,
...birthdayCommands,
...tempVoiceCommands,
...statsCommands,
...feedsCommands,
...scheduleCommands,
...guildBackupCommands
];
const map = new Map(commands.map((c) => [c.data.name, c]));
const contextMenuMap = new Map(utilityContextMenus.map((c) => [c.data.name, c]));
function buildCommandBody(): object[] {
const seen = new Set<string>();
const body: object[] = [];
for (const command of [...commands, ...utilityContextMenus]) {
const json = command.data.toJSON() as { name: string; type?: number };
const key = `${json.type ?? 1}:${json.name}`;
if (seen.has(key)) {
logger.warn({ name: json.name, type: json.type }, 'Skipping duplicate command definition');
continue;
}
seen.add(key);
body.push(json);
}
return body;
}
/**
* Guild-scoped leftovers + global commands both appear in Discord's picker.
* Always clear the unused scope so each command shows once.
*/
async function clearGuildCommandLeftovers(rest: REST, clientId: string): Promise<number> {
const guilds = (await rest.get(Routes.userGuilds())) as Array<{ id: string }>;
let cleared = 0;
for (const guild of guilds) {
const existing = (await rest.get(
Routes.applicationGuildCommands(clientId, guild.id)
)) as unknown[];
if (existing.length === 0) {
continue;
}
await rest.put(Routes.applicationGuildCommands(clientId, guild.id), { body: [] });
cleared += 1;
}
return cleared;
}
export async function registerCommands() {
const rest = new REST({ version: '10' }).setToken(env.BOT_TOKEN);
await rest.put(Routes.applicationCommands(env.BOT_CLIENT_ID), {
body: commands.map((c) => c.data.toJSON())
const body = buildCommandBody();
// Guild commands sync instantly; global can take up to ~1h.
if (env.BOT_GUILD_ID) {
await rest.put(Routes.applicationGuildCommands(env.BOT_CLIENT_ID, env.BOT_GUILD_ID), {
body
});
logger.info({ count: commands.length }, 'Registered application commands');
// Remove global copies so the guild does not show every command twice.
await rest.put(Routes.applicationCommands(env.BOT_CLIENT_ID), { body: [] });
logger.info(
{
scope: 'guild',
guildId: env.BOT_GUILD_ID,
count: body.length,
clearedGlobal: true
},
'Registered guild application commands and cleared global commands'
);
return;
}
await rest.put(Routes.applicationCommands(env.BOT_CLIENT_ID), { body });
// Wipe leftover guild-scoped registrations from earlier BOT_GUILD_ID / shard runs.
let clearedGuilds = 0;
try {
clearedGuilds = await clearGuildCommandLeftovers(rest, env.BOT_CLIENT_ID);
} catch (error) {
logger.warn({ error }, 'Failed to clear leftover guild application commands');
}
logger.info(
{
scope: 'global',
count: body.length,
clearedGuilds
},
'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) {
const started = Date.now();
let ok = true;
const locale = await getGuildLocale(context.prisma, interaction.guildId);
try {
if (await isUserBlacklisted(context.prisma, interaction.user.id)) {
await interaction.reply({
content: t(locale, 'generic.userBlacklisted'),
ephemeral: true
});
return;
}
if (interaction.guildId && (await isGuildBlacklisted(context.prisma, interaction.guildId))) {
await interaction.reply({
content: t(locale, 'generic.guildBlacklisted'),
ephemeral: true
});
return;
}
const maintenance = await isMaintenanceMode(context);
const ownerIds = env.OWNER_USER_IDS ?? [];
if (maintenance.enabled && !ownerIds.includes(interaction.user.id)) {
await interaction.reply({
content: maintenance.message?.trim() || t(locale, 'generic.maintenance'),
ephemeral: true
});
return;
}
const command = map.get(interaction.commandName);
if (!command) {
await interaction.reply({ content: 'Unknown command.', ephemeral: true });
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
return;
}
const moduleId = moduleForCommand(interaction.commandName);
if (moduleId && interaction.guildId) {
const enabled = await isModuleEnabled(
context.prisma,
context.redis,
interaction.guildId,
moduleId
);
if (!enabled) {
// 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 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(
interaction: MessageContextMenuCommandInteraction,
context: BotContext
) {
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) {
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
return;
}
await command.execute(interaction, context);

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

@@ -3,10 +3,14 @@ import { z } from 'zod';
config();
const emptyToUndefined = (value: unknown) =>
value === '' || value === undefined || value === null ? undefined : value;
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
BOT_TOKEN: z.string().min(1),
BOT_CLIENT_ID: z.string().min(1),
BOT_GUILD_ID: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url(),
LOG_LEVEL: z.string().default('info'),
@@ -14,8 +18,38 @@ const EnvSchema = z.object({
BACKUP_RETENTION_DAYS: z.coerce.number().int().positive().default(14),
BACKUP_CRON: z.string().default('0 3 * * *'),
BACKUP_DIR: z.string().default('/backups'),
METRICS_TOKEN: z.string().optional(),
HEALTH_PORT: z.coerce.number().int().positive().default(8080)
METRICS_TOKEN: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
SENTRY_DSN: z.preprocess(emptyToUndefined, z.string().url().optional()),
HEALTH_PORT: z.coerce.number().int().positive().default(8080),
PUBLIC_BASE_URL: z.string().url().default('http://localhost:8080'),
TWITCH_CLIENT_ID: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
TWITCH_CLIENT_SECRET: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
OWNER_USER_IDS: z.preprocess(
emptyToUndefined,
z
.string()
.optional()
.transform((value) =>
value
? value
.split(',')
.map((id) => id.trim())
.filter((id) => id.length > 0)
: []
)
),
WEBUI_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 function buildBotInviteUrl(clientId = env.BOT_CLIENT_ID): string {
const url = new URL('https://discord.com/api/oauth2/authorize');
url.searchParams.set('client_id', clientId);
url.searchParams.set('permissions', '4786708802825463');
url.searchParams.set('scope', 'bot applications.commands');
return url.toString();
}

21
apps/bot/src/guild.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { PrismaClient } from '@prisma/client';
export async function ensureGuild(prisma: PrismaClient, guildId: string): Promise<void> {
await prisma.guild.upsert({
where: { id: guildId },
update: {},
create: { id: guildId }
});
}
export async function ensureGuildSettings(
prisma: PrismaClient,
guildId: string
): Promise<void> {
await ensureGuild(prisma, guildId);
await prisma.guildSettings.upsert({
where: { guildId },
update: {},
create: { guildId }
});
}

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

@@ -1,29 +1,196 @@
import { createServer } from 'node:http';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { env } from './env.js';
import { redis } from './redis.js';
import {
deleteCaptchaChallenge,
getCaptchaChallenge,
hashCaptchaAnswer
} from './modules/verification/service.js';
import {
automodQueue,
backupQueue,
birthdayQueue,
feedsQueue,
presenceQueue,
retentionQueue,
ticketQueue,
verificationQueue
} from './queues.js';
import { collectMetricsSnapshot, formatPrometheus } from './metrics.js';
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
req.on('error', reject);
});
}
function parseFormBody(body: string): Record<string, string> {
return Object.fromEntries(new URLSearchParams(body));
}
function renderCaptchaPage(token: string, question: string, error?: string): string {
const errorBlock = error ? `<p style="color:#ef4444">${error}</p>` : '';
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Nexumi Verification</title>
<style>
body { font-family: Inter, sans-serif; background:#111827; color:#f9fafb; display:flex; justify-content:center; align-items:center; min-height:100vh; margin:0; }
form { background:#1f2937; padding:2rem; border-radius:12px; width:min(420px, 90vw); }
input, button { width:100%; padding:0.75rem; margin-top:0.75rem; border-radius:8px; border:1px solid #374151; background:#111827; color:#f9fafb; }
button { background:#6366f1; border:none; cursor:pointer; font-weight:600; }
</style>
</head>
<body>
<form method="POST" action="/verify/captcha">
<h1>Nexumi Verification</h1>
<p>Solve: <strong>${question} = ?</strong></p>
${errorBlock}
<input type="hidden" name="token" value="${token}" />
<input type="number" name="answer" required autofocus />
<button type="submit">Verify</button>
</form>
</body>
</html>`;
}
async function handleCaptchaGet(url: URL, res: ServerResponse): Promise<void> {
const token = url.searchParams.get('token');
if (!token) {
res.statusCode = 400;
res.end('Missing token');
return;
}
const challenge = await getCaptchaChallenge(redis, token);
if (!challenge) {
res.statusCode = 404;
res.end('Captcha expired');
return;
}
// External providers (reCAPTCHA / hCaptcha / Turnstile) are served by the WebUI.
if (challenge.provider !== 'MATH' && env.WEBUI_URL) {
const target = `${env.WEBUI_URL.replace(/\/$/, '')}/verify/captcha?token=${encodeURIComponent(token)}`;
res.statusCode = 302;
res.setHeader('Location', target);
res.end();
return;
}
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(renderCaptchaPage(token, challenge.question ?? '?'));
}
async function handleCaptchaPost(req: IncomingMessage, res: ServerResponse): Promise<void> {
const body = await readBody(req);
const form = parseFormBody(body);
const token = form.token;
const answer = form.answer;
if (!token || !answer) {
res.statusCode = 400;
res.end('Missing fields');
return;
}
const challenge = await getCaptchaChallenge(redis, token);
if (!challenge) {
res.statusCode = 404;
res.end('Captcha expired');
return;
}
if (challenge.provider !== 'MATH') {
if (env.WEBUI_URL) {
const target = `${env.WEBUI_URL.replace(/\/$/, '')}/verify/captcha?token=${encodeURIComponent(token)}`;
res.statusCode = 302;
res.setHeader('Location', target);
res.end();
return;
}
res.statusCode = 400;
res.end('Use the WebUI captcha URL for this provider');
return;
}
if (!challenge.answerHash || hashCaptchaAnswer(answer) !== challenge.answerHash) {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(renderCaptchaPage(token, challenge.question ?? '?', 'Wrong answer. Try again.'));
return;
}
await deleteCaptchaChallenge(redis, token);
await verificationQueue.add(
'verificationComplete',
{
guildId: challenge.guildId,
userId: challenge.userId,
captchaProvider: challenge.provider
},
{ removeOnComplete: 1000, removeOnFail: 500 }
);
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end('<html><body style="font-family:sans-serif;background:#111827;color:#f9fafb;display:flex;justify-content:center;align-items:center;min-height:100vh"><div><h1>Verified</h1><p>You can return to Discord.</p></div></body></html>');
}
async function approximateQueueWaiting(): Promise<number> {
const counts = await Promise.all([
automodQueue.getWaitingCount(),
backupQueue.getWaitingCount(),
birthdayQueue.getWaitingCount(),
feedsQueue.getWaitingCount(),
presenceQueue.getWaitingCount(),
retentionQueue.getWaitingCount(),
ticketQueue.getWaitingCount()
]);
return counts.reduce((sum, n) => sum + n, 0);
}
export function startHealthServer(): void {
const server = createServer((req, res) => {
const server = createServer(async (req, res) => {
if (!req.url) {
res.statusCode = 400;
res.end('Bad Request');
return;
}
if (req.url === '/health') {
const url = new URL(req.url, `http://127.0.0.1:${env.HEALTH_PORT}`);
if (url.pathname === '/health') {
res.statusCode = 200;
res.end('ok');
return;
}
if (req.url === '/metrics') {
if (url.pathname === '/metrics') {
const auth = req.headers.authorization;
if (!env.METRICS_TOKEN || auth !== `Bearer ${env.METRICS_TOKEN}`) {
res.statusCode = 401;
res.end('Unauthorized');
return;
}
try {
const snapshot = await collectMetricsSnapshot(redis);
snapshot.queueWaiting = await approximateQueueWaiting();
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;
}
if (url.pathname === '/verify/captcha' && req.method === 'GET') {
await handleCaptchaGet(url, res);
return;
}
if (url.pathname === '/verify/captcha' && req.method === 'POST') {
await handleCaptchaPost(req, res);
return;
}
res.statusCode = 404;
res.end('Not Found');
});

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

18
apps/bot/src/i18n.ts Normal file
View File

@@ -0,0 +1,18 @@
import { LocaleSchema, type Locale } from '@nexumi/shared';
import type { PrismaClient } from '@prisma/client';
import { env } from './env.js';
export async function getGuildLocale(
prisma: PrismaClient,
guildId: string | null
): Promise<Locale> {
if (!guildId) {
return env.DEFAULT_LOCALE;
}
const settings = await prisma.guildSettings.findUnique({
where: { guildId },
select: { locale: true }
});
const parsed = LocaleSchema.safeParse(settings?.locale);
return parsed.success ? parsed.data : env.DEFAULT_LOCALE;
}

View File

@@ -10,14 +10,35 @@ import { env } from './env.js';
import { logger } from './logger.js';
import { prisma } from './db.js';
import { redis } from './redis.js';
import { registerCommands, routeCommand } from './commands.js';
import { registerCommands, routeCommand, routeContextMenu } from './commands.js';
import { ensureRecurringJobs, startWorkers } from './jobs.js';
import type { BotContext } from './types.js';
import { startHealthServer } from './health.js';
import { getGuildLocale } from './i18n.js';
import { ephemeral } from './interaction-reply.js';
import { t } from '@nexumi/shared';
import { handleAutoModMessage } from './modules/automod/actions.js';
import { registerLoggingEvents } from './modules/logging/events.js';
import { registerWelcomeEvents } from './modules/welcome/events.js';
import { registerVerificationEvents } from './modules/verification/events.js';
import { registerLevelingEvents } from './modules/leveling/events.js';
import { registerUtilityEvents } from './modules/utility/index.js';
import { registerTicketEvents } from './modules/tickets/index.js';
import { registerSelfRoleEvents } from './modules/selfroles/index.js';
import { registerTagEvents } from './modules/tags/index.js';
import { registerStarboardEvents } from './modules/starboard/index.js';
import { registerTempVoiceEvents } from './modules/tempvoice/index.js';
import { registerStatsEvents } from './modules/stats/index.js';
import { routeComponentInteraction } from './interaction-registry.js';
import { isPrimaryShard, runOncePerCluster } from './shard-lifecycle.js';
import { isGuildBlacklisted, isUserBlacklisted } from './module-gates.js';
import { publishShardMetrics, recordEventMetric } from './metrics.js';
import { captureException, initSentry } from './sentry.js';
const isShard = process.argv.includes('--shard');
if (!isShard) {
initSentry('bot-manager');
startHealthServer();
const manager = new ShardingManager(new URL('./index.js', import.meta.url).pathname, {
token: env.BOT_TOKEN,
@@ -25,37 +46,195 @@ if (!isShard) {
shardArgs: ['--shard']
});
manager.on('shardCreate', (shard) => logger.info({ shardId: shard.id }, 'Shard spawned'));
// Register slash commands once from the manager (not once per shard).
try {
await runOncePerCluster(
'nexumi:lock:register-commands',
300,
async () => {
await registerCommands();
},
'Command registration'
);
} catch (error) {
logger.error({ error }, 'Failed to register application commands from manager');
captureException(error, { phase: 'registerCommands' });
}
await manager.spawn();
} else {
initSentry('bot-shard');
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.GuildMessageReactions,
GatewayIntentBits.GuildModeration,
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.GuildEmojisAndStickers,
GatewayIntentBits.GuildInvites,
GatewayIntentBits.MessageContent
],
partials: [Partials.Channel]
partials: [
Partials.Channel,
Partials.Message,
Partials.GuildMember,
Partials.Reaction
]
});
const context: BotContext = { client, prisma, redis };
startWorkers(context);
await ensureRecurringJobs();
registerLoggingEvents(context);
registerWelcomeEvents(context);
registerVerificationEvents(context);
registerLevelingEvents(context);
registerUtilityEvents(context);
registerTicketEvents(context);
registerSelfRoleEvents(client, context);
registerTagEvents(client, context);
registerStarboardEvents(context);
registerTempVoiceEvents(context);
registerStatsEvents(context);
client.on(Events.ClientReady, async () => {
logger.info({ tag: client.user?.tag }, 'Bot ready');
await registerCommands();
logger.info({ tag: client.user?.tag, shards: client.shard?.ids }, 'Bot ready');
if (isPrimaryShard(client)) {
try {
await runOncePerCluster(
'nexumi:lock:ensure-recurring-jobs',
120,
async () => {
await ensureRecurringJobs();
},
'Recurring job registration'
);
} catch (error) {
logger.error({ error }, 'Failed to ensure recurring jobs');
captureException(error, { phase: 'ensureRecurringJobs' });
}
try {
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 {
const { applyBotPresence, publishBotStatus } = await import('./presence.js');
await applyBotPresence(context);
await publishBotStatus(context);
} catch (error) {
logger.warn({ error }, 'Failed to apply initial bot presence/status');
}
try {
await publishShardMetrics(context.redis, client);
} catch (error) {
logger.warn({ error }, 'Failed to publish initial shard metrics');
}
});
client.on(Events.GuildCreate, async (guild) => {
try {
if (await isGuildBlacklisted(context.prisma, guild.id)) {
logger.info({ guildId: guild.id }, 'Leaving blacklisted guild');
await guild.leave();
}
} catch (error) {
logger.error({ error, guildId: guild.id }, 'GuildCreate blacklist check failed');
captureException(error, { guildId: guild.id });
}
});
setInterval(() => {
publishShardMetrics(context.redis, client).catch(() => undefined);
}, 30_000);
client.on(Events.MessageCreate, async (message) => {
try {
await recordEventMetric(context.redis);
if (message.author.bot) return;
if (await isUserBlacklisted(context.prisma, message.author.id)) return;
if (message.guildId && (await isGuildBlacklisted(context.prisma, message.guildId))) return;
await handleAutoModMessage(context, message);
} catch (error) {
logger.error({ error, messageId: message.id }, 'AutoMod message handler failed');
captureException(error, { messageId: message.id });
}
});
client.on(Events.InteractionCreate, async (interaction: Interaction) => {
if (!interaction.isChatInputCommand()) return;
try {
if (
interaction.user &&
(await isUserBlacklisted(context.prisma, interaction.user.id)) &&
!interaction.isChatInputCommand() &&
!interaction.isMessageContextMenuCommand()
) {
if (interaction.isRepliable() && !interaction.replied && !interaction.deferred) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
await interaction.reply({
content: t(locale, 'generic.userBlacklisted'),
ephemeral: true
});
}
return;
}
if (interaction.isChatInputCommand()) {
await routeCommand(interaction, context);
return;
}
if (interaction.isMessageContextMenuCommand()) {
await routeContextMenu(interaction, context);
return;
}
if (
interaction.isButton() ||
interaction.isStringSelectMenu() ||
interaction.isUserSelectMenu() ||
interaction.isRoleSelectMenu() ||
interaction.isChannelSelectMenu() ||
interaction.isMentionableSelectMenu() ||
interaction.isModalSubmit()
) {
await routeComponentInteraction(interaction, context);
}
} catch (error) {
logger.error({ error }, 'Command execution 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 message = t(locale, 'generic.error');
if (interaction.isRepliable()) {
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ content: 'An error occurred.', ephemeral: true });
await interaction.followUp({ content: message, ...ephemeral() });
} else {
await interaction.reply({ content: 'An error occurred.', 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

@@ -1,4 +1,4 @@
import { Queue, Worker } from 'bullmq';
import { Worker } from 'bullmq';
import { PermissionFlagsBits } from 'discord.js';
import { mkdir, readdir, rm, stat } from 'node:fs/promises';
import { basename, join } from 'node:path';
@@ -7,11 +7,71 @@ import { redis } from './redis.js';
import { logger } from './logger.js';
import type { BotContext } from './types.js';
import { env } from './env.js';
import { refreshPhishingDomains } from './modules/automod/filters.js';
import { activateLockdown, deactivateLockdown } from './modules/automod/actions.js';
import {
completeVerification,
runVerificationPanelSyncJob
} from './modules/verification/handlers.js';
import { closePoll } from './modules/utility/poll.js';
import { sendReminder } from './modules/utility/reminders.js';
import {
expireSelfRole,
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 { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js';
import { pollAllFeeds } from './modules/feeds/index.js';
import { sendScheduledMessage } from './modules/scheduler/index.js';
import { sendDashboardMessage, type DashboardMessageSendJob } from './modules/components-v2/send.js';
import { runGuildBackupRestoreJob } from './modules/guildbackup/index.js';
import { runSuggestionStatusUpdateJob } from './modules/suggestions/index.js';
import { runPresenceRefreshJob } from './presence.js';
import {
automodQueue,
automodQueueName,
backupQueue,
backupQueueName,
birthdayQueue,
birthdayQueueName,
feedsQueue,
feedsQueueName,
giveawayQueueName,
guildBackupQueueName,
messagesQueueName,
moderationQueueName,
presenceQueue,
presenceQueueName,
reminderQueueName,
retentionQueue,
retentionQueueName,
scheduleQueueName,
selfrolesQueueName,
suggestionsQueueName,
ticketQueue,
ticketQueueName,
verificationQueueName
} from './queues.js';
export const moderationQueueName = 'moderation';
export const moderationQueue = new Queue(moderationQueueName, { connection: redis });
export const backupQueueName = 'backups';
export const backupQueue = new Queue(backupQueueName, { connection: redis });
export {
automodQueue,
backupQueue,
moderationQueue,
moderationQueueName,
reminderQueue,
verificationQueue
} from './queues.js';
type TempBanJob = {
guildId: string;
@@ -19,6 +79,103 @@ type TempBanJob = {
reason: string | null;
};
type VerificationCompleteJob = {
guildId: string;
userId: string;
ipHash?: string | null;
userAgentHash?: string | null;
captchaProvider?: string | null;
};
type VerificationPanelSyncJob = {
guildId: string;
};
type ReminderSendJob = {
reminderId: string;
};
type PollCloseJob = {
pollId: string;
};
type SelfRoleExpireJob = {
guildId: string;
userId: string;
roleId: string;
};
type GiveawayEndJob = {
giveawayId: string;
/** When true, end even if the giveaway is paused (dashboard/slash paths). */
force?: boolean;
};
type GiveawayCreateJob = {
guildId: string;
channelId: string;
hostId: string;
prize: string;
winnerCount: number;
durationMs: number;
requiredRoleId?: string | null;
requiredLevel?: number | null;
requiredMemberDays?: number | null;
};
type GiveawayPauseJob = {
giveawayId: string;
paused: boolean;
};
type GiveawayDeleteJob = {
giveawayId: string;
};
type BirthdayRoleExpireJob = {
guildId: string;
userId: string;
roleId: string;
};
type SuggestionStatusUpdateJob = {
suggestionId: string;
guildId: string;
status: 'OPEN' | 'APPROVED' | 'DENIED' | 'CONSIDERED' | 'IMPLEMENTED';
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[] {
const moderationWorker = new Worker<TempBanJob>(
moderationQueueName,
@@ -27,12 +184,70 @@ export function startWorkers(context: BotContext): Worker[] {
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();
if (!me.permissions.has(PermissionFlagsBits.BanMembers)) {
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 }
);
@@ -56,7 +271,323 @@ export function startWorkers(context: BotContext): Worker[] {
logger.error({ jobId: job?.id, error }, 'Backup job failed');
});
return [moderationWorker, backupWorker];
const automodWorker = new Worker(
automodQueueName,
async (job) => {
if (job.name === 'refreshPhishingList') {
const count = await refreshPhishingDomains(redis);
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 }
);
automodWorker.on('failed', (job, error) => {
logger.error({ jobId: job?.id, error }, 'AutoMod job failed');
});
const verificationWorker = new Worker<VerificationCompleteJob | VerificationPanelSyncJob>(
verificationQueueName,
async (job) => {
if (job.name === 'verificationPanelSync') {
return runVerificationPanelSyncJob(context, job.data as VerificationPanelSyncJob);
}
if (job.name !== 'verificationComplete') {
return;
}
const data = job.data as VerificationCompleteJob;
await completeVerification(context, data.guildId, data.userId, {
ipHash: data.ipHash,
userAgentHash: data.userAgentHash,
captchaProvider: data.captchaProvider
});
},
{ connection: redis }
);
verificationWorker.on('failed', (job, error) => {
logger.error({ jobId: job?.id, error }, 'Verification job failed');
});
const reminderWorker = new Worker<ReminderSendJob | PollCloseJob>(
reminderQueueName,
async (job) => {
if (job.name === 'reminderSend') {
await sendReminder(context, (job.data as ReminderSendJob).reminderId);
return;
}
if (job.name === 'pollClose') {
await closePoll(context, (job.data as PollCloseJob).pollId);
}
},
{ connection: redis }
);
reminderWorker.on('failed', (job, error) => {
logger.error({ jobId: job?.id, error }, 'Reminder job failed');
});
const ticketWorker = new Worker(
ticketQueueName,
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') {
const data = job.data as SelfRoleExpireJob;
await expireSelfRole(context, data.guildId, data.userId, data.roleId);
return;
}
if (job.name === 'ticketAutoClose') {
await closeInactiveTickets(context);
}
},
{ connection: redis }
);
ticketWorker.on('failed', (job, error) => {
logger.error({ jobId: job?.id, error }, 'Ticket queue job failed');
});
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,
async (job) => {
if (job.name === 'giveawayEnd') {
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;
}
if (job.name === 'giveawayCreate') {
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 }
);
giveawayWorker.on('failed', (job, error) => {
logger.error({ jobId: job?.id, error }, 'Giveaway job failed');
});
const birthdayWorker = new Worker(
birthdayQueueName,
async (job) => {
if (job.name === 'birthdayDailyCheck') {
await runBirthdayCheck(context);
return;
}
if (job.name === 'birthdayRoleExpire') {
const data = job.data as BirthdayRoleExpireJob;
await expireBirthdayRole(context, data.guildId, data.userId, data.roleId);
}
},
{ connection: redis }
);
birthdayWorker.on('failed', (job, error) => {
logger.error({ jobId: job?.id, error }, 'Birthday job failed');
});
const statsWorker = startStatsWorker(context);
const feedsWorker = new Worker(
feedsQueueName,
async (job) => {
if (job.name !== 'pollFeeds') {
return;
}
await pollAllFeeds(context);
},
{ connection: redis }
);
feedsWorker.on('failed', (job, error) => {
logger.error({ jobId: job?.id, error }, 'Feeds job failed');
});
const scheduleWorker = new Worker<{ scheduleId: string }>(
scheduleQueueName,
async (job) => {
if (job.name !== 'scheduleSend') {
return;
}
await sendScheduledMessage(context, job.data.scheduleId);
},
{ connection: redis }
);
scheduleWorker.on('failed', (job, error) => {
logger.error({ jobId: job?.id, error }, 'Schedule job failed');
});
const messagesWorker = new Worker<DashboardMessageSendJob>(
messagesQueueName,
async (job) => {
if (job.name !== 'dashboardMessageSend') {
return;
}
return sendDashboardMessage(context, job.data);
},
{ connection: redis }
);
messagesWorker.on('failed', (job, error) => {
logger.error({ jobId: job?.id, error }, 'Dashboard message send job failed');
});
const guildBackupWorker = new Worker<{ backupId: string; guildId: string }>(
guildBackupQueueName,
async (job) => {
if (job.name !== 'restoreGuildBackup') {
return;
}
await runGuildBackupRestoreJob(context, job.data.backupId, job.data.guildId);
},
{ connection: redis }
);
guildBackupWorker.on('failed', (job, error) => {
logger.error({ jobId: job?.id, error }, 'Guild backup restore job failed');
});
const suggestionsWorker = new Worker<SuggestionStatusUpdateJob>(
suggestionsQueueName,
async (job) => {
if (job.name !== 'suggestionStatusUpdate') {
return;
}
return runSuggestionStatusUpdateJob(context, job.data);
},
{ connection: redis }
);
suggestionsWorker.on('failed', (job, error) => {
logger.error({ jobId: job?.id, error }, 'Suggestion status update job failed');
});
const presenceWorker = new Worker(
presenceQueueName,
async (job) => {
if (job.name !== 'presenceRefresh') {
return;
}
await runPresenceRefreshJob(context);
},
{ connection: redis }
);
presenceWorker.on('failed', (job, error) => {
logger.error({ jobId: job?.id, error }, 'Presence refresh job failed');
});
const retentionWorker = new Worker(
retentionQueueName,
async (job) => {
if (job.name !== 'purgeExpiredLogs') {
return;
}
await runLogRetentionJob(context);
},
{ connection: redis }
);
retentionWorker.on('failed', (job, error) => {
logger.error({ jobId: job?.id, error }, 'Log retention job failed');
});
return [
moderationWorker,
backupWorker,
automodWorker,
verificationWorker,
reminderWorker,
ticketWorker,
selfrolesWorker,
giveawayWorker,
birthdayWorker,
statsWorker,
feedsWorker,
scheduleWorker,
messagesWorker,
guildBackupWorker,
suggestionsWorker,
presenceWorker,
retentionWorker
];
}
export async function ensureRecurringJobs(): Promise<void> {
@@ -69,6 +600,95 @@ export async function ensureRecurringJobs(): Promise<void> {
removeOnFail: 100
}
);
await automodQueue.add(
'refreshPhishingList',
{},
{
repeat: { pattern: '0 */6 * * *' },
removeOnComplete: 20,
removeOnFail: 20
}
);
await automodQueue.add('refreshPhishingList', {}, { removeOnComplete: 20, removeOnFail: 20 });
await ticketQueue.add(
'ticketAutoClose',
{},
{
repeat: { pattern: '0 * * * *' },
removeOnComplete: 20,
removeOnFail: 20
}
);
await birthdayQueue.add(
'birthdayDailyCheck',
{},
{
repeat: { pattern: '0 * * * *' },
removeOnComplete: 20,
removeOnFail: 20
}
);
await ensureStatsRecurringJobs();
await feedsQueue.add(
'pollFeeds',
{},
{
repeat: { pattern: '*/5 * * * *' },
removeOnComplete: 20,
removeOnFail: 20
}
);
await presenceQueue.add(
'presenceRefresh',
{},
{
repeat: { every: 60_000 },
removeOnComplete: 5,
removeOnFail: 5
}
);
await retentionQueue.add(
'purgeExpiredLogs',
{},
{
repeat: { pattern: '15 4 * * *' },
removeOnComplete: 20,
removeOnFail: 20
}
);
}
async function runLogRetentionJob(context: BotContext): Promise<void> {
const configs = await context.prisma.loggingConfig.findMany({
where: { retentionDays: { gt: 0 } },
select: { guildId: true, retentionDays: true }
});
for (const config of configs) {
const cutoff = new Date(Date.now() - config.retentionDays * 24 * 60 * 60 * 1000);
const [cases, audits] = await Promise.all([
context.prisma.case.deleteMany({
where: { guildId: config.guildId, createdAt: { lt: cutoff } }
}),
context.prisma.dashboardAuditLog.deleteMany({
where: { guildId: config.guildId, createdAt: { lt: cutoff } }
})
]);
if (cases.count > 0 || audits.count > 0) {
logger.info(
{ guildId: config.guildId, cases: cases.count, audits: audits.count, retentionDays: config.retentionDays },
'Log retention purge completed'
);
}
}
}
async function runPgDumpBackup(): Promise<void> {

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

@@ -0,0 +1,342 @@
import { ChannelType, PermissionFlagsBits, type Guild, type GuildMember, type Message, type TextChannel } from 'discord.js';
import type { BotContext } from '../../types.js';
import { createCase, createWarning, notifyModerationTargetDm } from '../moderation/service.js';
import { getGuildLocale } from '../../i18n.js';
import {
getAutoModConfig,
getEnabledAutoModRules,
isExcepted,
parseExceptions,
parseThreshold,
parseWordList
} from './service.js';
import { evaluateRule, loadPhishingDomains, type FilterResult } from './filters.js';
import type { AutoModRuleType } from '@nexumi/shared';
import { logger } from '../../logger.js';
async function applyAutoModAction(
context: BotContext,
message: Message,
ruleType: AutoModRuleType,
action: string,
reason: string,
durationMs?: number | null
): Promise<void> {
const guild = message.guild;
if (!guild || !message.member) {
return;
}
const me = guild.members.me;
if (!me) {
return;
}
if (me.permissions.has(PermissionFlagsBits.ManageMessages) && message.deletable) {
await message.delete().catch((error) => {
logger.warn({ error, messageId: message.id }, 'AutoMod failed to delete message');
});
}
const moderatorId = context.client.user?.id ?? guild.client.user?.id ?? '0';
const baseReason = `AutoMod (${ruleType}): ${reason}`;
if (action === 'WARN') {
const locale = await getGuildLocale(context.prisma, guild.id);
const modCase = await createCase(context, {
guildId: guild.id,
targetUserId: message.author.id,
moderatorId,
action: 'WARN_ADD',
reason: baseReason,
channelId: message.channel.id,
source: 'automod',
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;
}
if (action === 'TIMEOUT' && durationMs && durationMs > 0) {
if (me.permissions.has(PermissionFlagsBits.ModerateMembers)) {
await message.member.timeout(durationMs, baseReason);
await createCase(context, {
guildId: guild.id,
targetUserId: message.author.id,
moderatorId,
action: 'TIMEOUT',
reason: baseReason,
channelId: message.channel.id,
source: 'automod',
metadata: { ruleType, automod: true, durationMs }
});
}
return;
}
if (action === 'KICK') {
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 createCase(context, {
guildId: guild.id,
targetUserId: message.author.id,
moderatorId,
action: 'KICK',
reason: baseReason,
channelId: message.channel.id,
source: 'automod',
metadata: { ruleType, automod: true }
});
}
return;
}
if (action === 'BAN') {
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 createCase(context, {
guildId: guild.id,
targetUserId: message.author.id,
moderatorId,
action: 'BAN',
reason: baseReason,
channelId: message.channel.id,
source: 'automod',
metadata: { ruleType, automod: true }
});
}
}
}
export async function handleAutoModMessage(
context: BotContext,
message: Message
): Promise<FilterResult | null> {
if (!message.guild || message.author.bot || !message.content) {
return null;
}
const config = await getAutoModConfig(context.prisma, message.guild.id);
if (!config.enabled) {
return null;
}
const member = message.member ?? (await message.guild.members.fetch(message.author.id));
const roleIds = member.roles.cache.map((role) => role.id);
const rules = await getEnabledAutoModRules(context.prisma, message.guild.id);
const phishingDomains = await loadPhishingDomains(context.redis);
const filterCtx = {
content: message.content,
authorId: message.author.id,
channelId: message.channel.id,
roleIds,
redis: context.redis,
phishingDomains
};
for (const rule of rules) {
const exceptions = parseExceptions(rule.exceptions);
if (isExcepted(exceptions, message.channel.id, roleIds)) {
continue;
}
const threshold = parseThreshold(rule.threshold);
const wordList = parseWordList(rule.wordList);
const result = await evaluateRule(
rule.ruleType as AutoModRuleType,
filterCtx,
threshold,
wordList
);
if (result) {
await applyAutoModAction(
context,
message,
result.ruleType,
rule.action,
result.reason,
rule.durationMs
);
return result;
}
}
return null;
}
export async function activateLockdown(guild: Guild): Promise<void> {
for (const channel of guild.channels.cache.values()) {
if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) {
continue;
}
const everyone = guild.roles.everyone;
await channel.permissionOverwrites.edit(everyone, {
SendMessages: false
});
}
}
export async function deactivateLockdown(guild: Guild): Promise<void> {
for (const channel of guild.channels.cache.values()) {
if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) {
continue;
}
const everyone = guild.roles.everyone;
await channel.permissionOverwrites.edit(everyone, {
SendMessages: null
});
}
}
export async function handleAntiRaidJoin(
context: BotContext,
member: GuildMember
): Promise<void> {
const config = await getAutoModConfig(context.prisma, member.guild.id);
if (!config.antiRaidEnabled) {
return;
}
// While VERIFY raid-response is active, keep gating new joins with the unverified role.
if (config.lockdownActive && config.antiRaidAction === 'VERIFY') {
await applyRaidVerifyRole(context, member);
return;
}
if (config.lockdownActive) {
return;
}
const key = `automod:raid:${member.guild.id}`;
const count = await context.redis.incr(key);
if (count === 1) {
await context.redis.expire(key, config.antiRaidWindowSeconds);
}
if (count < config.antiRaidJoinThreshold) {
return;
}
await context.prisma.autoModConfig.update({
where: { guildId: member.guild.id },
data: { lockdownActive: true }
});
if (config.antiRaidAction === 'LOCKDOWN') {
await activateLockdown(member.guild);
} else if (config.antiRaidAction === 'VERIFY') {
await applyRaidVerifyRole(context, member);
}
const alertChannel = member.guild.systemChannel ?? member.guild.publicUpdatesChannel;
if (alertChannel && alertChannel.isTextBased()) {
await (alertChannel as TextChannel).send(
`Anti-raid triggered: ${count} joins in ${config.antiRaidWindowSeconds}s (${config.antiRaidAction}).`
);
}
}
async function applyRaidVerifyRole(context: BotContext, member: GuildMember): Promise<void> {
const verification = await context.prisma.verificationConfig.findUnique({
where: { guildId: member.guild.id }
});
const roleId = verification?.unverifiedRoleId;
if (!roleId) {
logger.warn(
{ guildId: member.guild.id },
'Anti-raid VERIFY triggered but no unverified role is configured'
);
return;
}
const me = member.guild.members.me;
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
return;
}
const role = member.guild.roles.cache.get(roleId);
if (!role || role.position >= me.roles.highest.position) {
return;
}
if (!member.roles.cache.has(roleId)) {
await member.roles.add(roleId).catch((error) => {
logger.warn({ error, memberId: member.id }, 'Failed to apply unverified role during raid verify');
});
}
}
export async function handleAntiNukeAction(
context: BotContext,
guildId: string,
executorId: string,
actionType: 'ban' | 'channel_delete'
): Promise<void> {
const config = await getAutoModConfig(context.prisma, guildId);
if (!config.antiNukeEnabled) {
return;
}
const botId = context.client.user?.id;
if (!botId || executorId === botId) {
return;
}
const key = `automod:nuke:${guildId}:${executorId}:${actionType}`;
const count = await context.redis.incr(key);
if (count === 1) {
await context.redis.expire(key, config.antiNukeWindowSeconds);
}
const threshold =
actionType === 'ban' ? config.antiNukeBanThreshold : config.antiNukeChannelThreshold;
if (count < threshold) {
return;
}
const guild = await context.client.guilds.fetch(guildId);
const executor = await guild.members.fetch(executorId).catch(() => null);
if (!executor) {
return;
}
const me = guild.members.me;
if (!me?.permissions.has(PermissionFlagsBits.Administrator)) {
return;
}
await executor.roles.set([], 'Anti-nuke: mass destructive action detected');
await createCase(context, {
guildId,
targetUserId: executorId,
moderatorId: botId,
action: 'ANTI_NUKE',
reason: `Anti-nuke triggered (${actionType}: ${count} in ${config.antiNukeWindowSeconds}s)`,
source: 'automod',
metadata: { actionType, count, antiNuke: true }
});
}

View File

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

View File

@@ -0,0 +1,57 @@
import { PermissionFlagsBits } from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import { automodStatusCommandData } from './command-definitions.js';
import { getAutoModConfig, getEnabledAutoModRules } from './service.js';
const automodCommand: SlashCommand = {
data: automodStatusCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
return;
}
const sub = interaction.options.getSubcommand();
if (sub !== 'status') {
return;
}
const guildId = interaction.guildId!;
const config = await getAutoModConfig(context.prisma, guildId);
const rules = await getEnabledAutoModRules(context.prisma, guildId);
const lines = rules.map((rule) =>
tf(locale, 'automod.status.ruleLine', {
type: rule.ruleType,
action: rule.action
})
);
const body = [
tf(locale, 'automod.status.header', {
enabled: config.enabled ? t(locale, 'automod.status.yes') : t(locale, 'automod.status.no'),
antiRaid: config.antiRaidEnabled
? t(locale, 'automod.status.yes')
: t(locale, 'automod.status.no'),
antiNuke: config.antiNukeEnabled
? t(locale, 'automod.status.yes')
: t(locale, 'automod.status.no'),
lockdown: config.lockdownActive
? t(locale, 'automod.status.yes')
: t(locale, 'automod.status.no')
}),
lines.length > 0 ? lines.join('\n') : t(locale, 'automod.status.noRules')
].join('\n\n');
await interaction.reply({ content: body, ephemeral: true });
}
};
export const automodCommands: SlashCommand[] = [automodCommand];

View File

@@ -0,0 +1,231 @@
import type { Redis } from 'ioredis';
import {
capsRatio,
contentMatchesBlockedWord,
countEmojis,
extractUrls,
hasZalgo,
isDiscordInvite,
normalizeHost,
type AutoModRuleType,
type AutoModThreshold,
type AutoModWordList
} from '@nexumi/shared';
const PHISHING_CACHE_KEY = 'automod:phishing-domains';
export type FilterContext = {
content: string;
authorId: string;
channelId: string;
roleIds: string[];
redis: Redis;
phishingDomains: Set<string>;
};
export type FilterResult = {
matched: boolean;
ruleType: AutoModRuleType;
reason: string;
};
export async function loadPhishingDomains(redis: Redis): Promise<Set<string>> {
const cached = await redis.smembers(PHISHING_CACHE_KEY);
if (cached.length > 0) {
return new Set(cached);
}
return new Set<string>();
}
export async function refreshPhishingDomains(redis: Redis): Promise<number> {
const response = await fetch(
'https://raw.githubusercontent.com/Discord-AntiScam/scam-links/main/list.txt'
);
if (!response.ok) {
throw new Error(`Failed to fetch phishing list: ${response.status}`);
}
const text = await response.text();
const domains = text
.split('\n')
.map((line) => line.trim().toLowerCase())
.filter((line) => line.length > 0 && !line.startsWith('#'));
const pipeline = redis.pipeline();
pipeline.del(PHISHING_CACHE_KEY);
if (domains.length > 0) {
pipeline.sadd(PHISHING_CACHE_KEY, ...domains);
}
pipeline.expire(PHISHING_CACHE_KEY, 60 * 60 * 24);
await pipeline.exec();
return domains.length;
}
async function checkSpam(
ctx: FilterContext,
threshold: AutoModThreshold
): Promise<FilterResult | null> {
const maxMessages = threshold.maxMessages ?? 5;
const windowSeconds = threshold.windowSeconds ?? 5;
const key = `automod:spam:${ctx.authorId}:${ctx.channelId}`;
const count = await ctx.redis.incr(key);
if (count === 1) {
await ctx.redis.expire(key, windowSeconds);
}
if (count > maxMessages) {
return { matched: true, ruleType: 'SPAM', reason: 'Message rate exceeded' };
}
return null;
}
function checkMassMention(content: string, threshold: AutoModThreshold): FilterResult | null {
const maxMentions = threshold.maxMentions ?? 5;
const mentions = (content.match(/<@[!&]?\d+>/g) ?? []).length;
if (mentions >= maxMentions) {
return { matched: true, ruleType: 'MASS_MENTION', reason: `${mentions} mentions` };
}
return null;
}
function checkCaps(content: string, threshold: AutoModThreshold): FilterResult | null {
const minLength = threshold.minLength ?? 10;
const capsPercent = threshold.capsPercent ?? 70;
if (content.length < minLength) {
return null;
}
if (capsRatio(content) >= capsPercent) {
return { matched: true, ruleType: 'CAPS', reason: 'Excessive caps' };
}
return null;
}
function checkInviteLink(content: string): FilterResult | null {
if (isDiscordInvite(content)) {
return { matched: true, ruleType: 'INVITE_LINK', reason: 'Discord invite link' };
}
return null;
}
function checkExternalLink(content: string, threshold: AutoModThreshold): FilterResult | null {
const whitelist = (threshold.linkWhitelist ?? []).map((h) => h.toLowerCase());
const blacklist = (threshold.linkBlacklist ?? []).map((h) => h.toLowerCase());
const urls = extractUrls(content);
for (const url of urls) {
const host = normalizeHost(url);
if (!host) {
continue;
}
if (blacklist.some((entry) => host.includes(entry))) {
return { matched: true, ruleType: 'EXTERNAL_LINK', reason: `Blocked host: ${host}` };
}
if (whitelist.length > 0 && !whitelist.some((entry) => host.includes(entry))) {
return { matched: true, ruleType: 'EXTERNAL_LINK', reason: `Non-whitelisted host: ${host}` };
}
}
return null;
}
function checkWordFilter(content: string, wordList: AutoModWordList): FilterResult | null {
for (const word of wordList.words) {
if (contentMatchesBlockedWord(content, word)) {
return { matched: true, ruleType: 'WORD_FILTER', reason: 'Blocked word' };
}
}
for (const pattern of wordList.regexPatterns) {
try {
const regex = new RegExp(pattern, 'iu');
if (regex.test(content)) {
return { matched: true, ruleType: 'WORD_FILTER', reason: 'Blocked pattern' };
}
} catch {
continue;
}
}
return null;
}
async function checkDuplicate(
ctx: FilterContext,
threshold: AutoModThreshold
): Promise<FilterResult | null> {
const duplicateCount = threshold.duplicateCount ?? 3;
const windowSeconds = threshold.windowSeconds ?? 30;
const normalized = ctx.content.trim().toLowerCase();
if (normalized.length < 3) {
return null;
}
const key = `automod:dup:${ctx.authorId}:${ctx.channelId}`;
const count = await ctx.redis.incr(`${key}:${normalized}`);
if (count === 1) {
await ctx.redis.expire(`${key}:${normalized}`, windowSeconds);
}
if (count >= duplicateCount) {
return { matched: true, ruleType: 'DUPLICATE', reason: 'Duplicate message' };
}
return null;
}
function checkEmojiSpam(content: string, threshold: AutoModThreshold): FilterResult | null {
const maxEmojis = threshold.maxEmojis ?? 10;
const emojiCount = countEmojis(content);
if (emojiCount >= maxEmojis) {
return { matched: true, ruleType: 'EMOJI_SPAM', reason: `${emojiCount} emojis` };
}
return null;
}
function checkZalgo(content: string): FilterResult | null {
if (hasZalgo(content)) {
return { matched: true, ruleType: 'ZALGO', reason: 'Zalgo text detected' };
}
return null;
}
function checkPhishing(content: string, domains: Set<string>): FilterResult | null {
if (domains.size === 0) {
return null;
}
for (const url of extractUrls(content)) {
const host = normalizeHost(url);
if (!host) {
continue;
}
for (const domain of domains) {
if (host === domain || host.endsWith(`.${domain}`)) {
return { matched: true, ruleType: 'PHISHING', reason: `Phishing domain: ${host}` };
}
}
}
return null;
}
export async function evaluateRule(
ruleType: AutoModRuleType,
ctx: FilterContext,
threshold: AutoModThreshold,
wordList: AutoModWordList
): Promise<FilterResult | null> {
switch (ruleType) {
case 'SPAM':
return checkSpam(ctx, threshold);
case 'MASS_MENTION':
return checkMassMention(ctx.content, threshold);
case 'CAPS':
return checkCaps(ctx.content, threshold);
case 'INVITE_LINK':
return checkInviteLink(ctx.content);
case 'EXTERNAL_LINK':
return checkExternalLink(ctx.content, threshold);
case 'WORD_FILTER':
return checkWordFilter(ctx.content, wordList);
case 'DUPLICATE':
return checkDuplicate(ctx, threshold);
case 'EMOJI_SPAM':
return checkEmojiSpam(ctx.content, threshold);
case 'ZALGO':
return checkZalgo(ctx.content);
case 'PHISHING':
return checkPhishing(ctx.content, ctx.phishingDomains);
default:
return null;
}
}

View File

@@ -0,0 +1,110 @@
import type { Prisma, PrismaClient } from '@prisma/client';
import {
DEFAULT_AUTOMOD_RULES,
defaultAutoModWordList,
shouldSeedDefaultBlockedWords,
type AutoModExceptions,
type AutoModThreshold,
type AutoModWordList
} from '@nexumi/shared';
import { ensureGuild } from '../../guild.js';
export async function getAutoModConfig(prisma: PrismaClient, guildId: string) {
await ensureGuild(prisma, guildId);
let config = await prisma.autoModConfig.findUnique({ where: { guildId } });
if (!config) {
config = await prisma.autoModConfig.create({ data: { guildId } });
}
return config;
}
async function seedDefaultWordFilterIfNeeded(prisma: PrismaClient, guildId: string) {
const rule = await prisma.autoModRule.findUnique({
where: { guildId_ruleType: { guildId, ruleType: 'WORD_FILTER' } },
select: { id: true, wordList: true }
});
if (!rule || !shouldSeedDefaultBlockedWords(rule.wordList)) {
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({
data: missing.map((rule) => ({
guildId,
ruleType: rule.ruleType,
action: rule.action,
threshold: rule.threshold ?? undefined,
wordList: rule.wordList ?? undefined,
enabled: ['SPAM', 'MASS_MENTION', 'INVITE_LINK', 'PHISHING'].includes(rule.ruleType)
}))
});
}
await seedDefaultWordFilterIfNeeded(prisma, guildId);
}
export async function getEnabledAutoModRules(prisma: PrismaClient, guildId: string) {
await ensureDefaultAutoModRules(prisma, guildId);
return prisma.autoModRule.findMany({
where: { guildId, enabled: true }
});
}
export function parseExceptions(raw: unknown): AutoModExceptions {
if (!raw || typeof raw !== 'object') {
return { roleIds: [], channelIds: [] };
}
const obj = raw as Record<string, unknown>;
return {
roleIds: Array.isArray(obj.roleIds) ? obj.roleIds.map(String) : [],
channelIds: Array.isArray(obj.channelIds) ? obj.channelIds.map(String) : []
};
}
export function parseThreshold(raw: unknown): AutoModThreshold {
if (!raw || typeof raw !== 'object') {
return {};
}
return raw as AutoModThreshold;
}
export function parseWordList(raw: unknown): AutoModWordList {
if (!raw || typeof raw !== 'object') {
return { words: [], regexPatterns: [] };
}
const obj = raw as Record<string, unknown>;
return {
words: Array.isArray(obj.words) ? obj.words.map(String) : [],
regexPatterns: Array.isArray(obj.regexPatterns) ? obj.regexPatterns.map(String) : [],
...(obj.userCleared === true ? { userCleared: true } : {})
};
}
export function isExcepted(
exceptions: AutoModExceptions,
channelId: string,
roleIds: string[]
): boolean {
if (exceptions.channelIds.includes(channelId)) {
return true;
}
return roleIds.some((roleId) => exceptions.roleIds.includes(roleId));
}

View File

@@ -0,0 +1,45 @@
import { ChannelType } from 'discord.js';
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js';
export const birthdayCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('birthday')
.addSubcommand((s) =>
applyCommandDescription(s.setName('set'), 'birthdays.set.description')
.addIntegerOption((o) =>
applyOptionDescription(o.setName('month').setRequired(true).setMinValue(1).setMaxValue(12), 'birthdays.set.options.month')
)
.addIntegerOption((o) =>
applyOptionDescription(o.setName('day').setRequired(true).setMinValue(1).setMaxValue(31), 'birthdays.set.options.day')
)
.addIntegerOption((o) =>
applyOptionDescription(
o.setName('year').setMinValue(1900).setMaxValue(new Date().getFullYear()),
'birthdays.set.options.year'
)
)
)
.addSubcommand((s) => applyCommandDescription(s.setName('remove'), 'birthdays.remove.description'))
.addSubcommand((s) => applyCommandDescription(s.setName('next'), 'birthdays.next.description'))
.addSubcommand((s) => applyCommandDescription(s.setName('list'), 'birthdays.list.description'))
.addSubcommand((s) =>
applyCommandDescription(s.setName('setup'), 'birthdays.setup.description')
.addChannelOption((o) =>
applyOptionDescription(
o.setName('channel').addChannelTypes(ChannelType.GuildText).setRequired(true),
'birthdays.setup.options.channel'
)
)
.addRoleOption((o) =>
applyOptionDescription(o.setName('role'), 'birthdays.setup.options.role')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('timezone'), 'birthdays.setup.options.timezone')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('message'), 'birthdays.setup.options.message')
)
),
'birthdays.description'
);

View File

@@ -0,0 +1,126 @@
import { PermissionFlagsBits } from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import { birthdayCommandData } from './command-definitions.js';
import {
BirthdaySetSchema,
daysUntilBirthday,
getBirthdayConfig,
isValidTimezone,
listBirthdays,
removeBirthday,
setBirthday,
updateBirthdayConfig
} from './service.js';
const birthdayCommand: SlashCommand = {
data: birthdayCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
const guildId = interaction.guildId!;
const sub = interaction.options.getSubcommand();
if (sub === 'setup') {
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
return;
}
const channel = interaction.options.getChannel('channel', true);
const role = interaction.options.getRole('role');
const timezone = interaction.options.getString('timezone') ?? 'Europe/Berlin';
const message = interaction.options.getString('message');
if (!isValidTimezone(timezone)) {
await interaction.reply({ content: t(locale, 'birthdays.error.invalidTimezone'), ephemeral: true });
return;
}
await updateBirthdayConfig(context.prisma, guildId, {
enabled: true,
channelId: channel.id,
roleId: role?.id ?? null,
message: message ?? null,
timezone
});
await interaction.reply({ content: t(locale, 'birthdays.setup.done'), ephemeral: true });
return;
}
const config = await getBirthdayConfig(context.prisma, guildId);
if (sub === 'set') {
const month = interaction.options.getInteger('month', true);
const day = interaction.options.getInteger('day', true);
const year = interaction.options.getInteger('year') ?? undefined;
const parsed = BirthdaySetSchema.safeParse({ month, day, year });
if (!parsed.success) {
await interaction.reply({ content: t(locale, 'birthdays.error.invalidDate'), ephemeral: true });
return;
}
try {
await setBirthday(context.prisma, guildId, interaction.user.id, parsed.data);
} catch (error) {
if (error instanceof Error && error.message === 'invalid_date') {
await interaction.reply({ content: t(locale, 'birthdays.error.invalidDate'), ephemeral: true });
return;
}
throw error;
}
await interaction.reply({
content: tf(locale, 'birthdays.set.success', { month, day, year: year ?? '—' }),
ephemeral: true
});
return;
}
if (sub === 'remove') {
const removed = await removeBirthday(context.prisma, guildId, interaction.user.id);
if (!removed) {
await interaction.reply({ content: t(locale, 'birthdays.remove.none'), ephemeral: true });
return;
}
await interaction.reply({ content: t(locale, 'birthdays.remove.success'), ephemeral: true });
return;
}
if (sub === 'list' || sub === 'next') {
const entries = await listBirthdays(context.prisma, guildId, config.timezone);
if (entries.length === 0) {
await interaction.reply({ content: t(locale, 'birthdays.list.empty'), ephemeral: true });
return;
}
const slice = sub === 'next' ? entries.slice(0, 10) : entries.slice(0, 25);
const lines = slice.map((entry, index) => {
const days = daysUntilBirthday(entry.month, entry.day, config.timezone);
const dateLabel = `${String(entry.day).padStart(2, '0')}.${String(entry.month).padStart(2, '0')}`;
const yearLabel = entry.year ? ` (${entry.year})` : '';
return tf(locale, 'birthdays.list.line', {
rank: index + 1,
user: `<@${entry.userId}>`,
date: `${dateLabel}${yearLabel}`,
days
});
});
const header = t(locale, sub === 'next' ? 'birthdays.next.header' : 'birthdays.list.header');
await interaction.reply({
content: `${header}\n${lines.join('\n')}`,
ephemeral: true
});
}
}
};
export const birthdayCommands: SlashCommand[] = [birthdayCommand];

View File

@@ -0,0 +1,2 @@
export { birthdayCommands } from './commands.js';
export { runBirthdayCheck, expireBirthdayRole } from './service.js';

View File

@@ -0,0 +1,304 @@
import { PermissionFlagsBits, type Guild, type GuildMember, type TextChannel } from 'discord.js';
import { z } from 'zod';
import { applyTagPlaceholders } from '@nexumi/shared';
import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js';
import { birthdayQueue } from '../../queues.js';
import type { BotContext } from '../../types.js';
export const BirthdaySetSchema = z.object({
month: z.number().int().min(1).max(12),
day: z.number().int().min(1).max(31),
year: z.number().int().min(1900).max(new Date().getFullYear()).optional()
});
export type BirthdaySetInput = z.infer<typeof BirthdaySetSchema>;
const DEFAULT_MESSAGE = 'Happy birthday {user}! 🎂';
const ROLE_EXPIRE_MS = 24 * 60 * 60 * 1000;
function congratsRedisKey(guildId: string, userId: string, dateKey: string): string {
return `birthday:sent:${guildId}:${userId}:${dateKey}`;
}
export function isValidTimezone(timezone: string): boolean {
try {
Intl.DateTimeFormat(undefined, { timeZone: timezone });
return true;
} catch {
return false;
}
}
export function getDatePartsInTimezone(
timezone: string,
date = new Date()
): { month: number; day: number; year: number } {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
year: 'numeric',
month: 'numeric',
day: 'numeric'
}).formatToParts(date);
return {
year: Number(parts.find((p) => p.type === 'year')!.value),
month: Number(parts.find((p) => p.type === 'month')!.value),
day: Number(parts.find((p) => p.type === 'day')!.value)
};
}
export function isValidCalendarDate(month: number, day: number, year?: number): boolean {
const probeYear = year ?? 2000;
const date = new Date(probeYear, month - 1, day);
return date.getFullYear() === probeYear && date.getMonth() === month - 1 && date.getDate() === day;
}
export function computeAge(year: number, month: number, day: number, timezone: string): number {
const today = getDatePartsInTimezone(timezone);
let age = today.year - year;
if (today.month < month || (today.month === month && today.day < day)) {
age -= 1;
}
return age;
}
export function daysUntilBirthday(month: number, day: number, timezone: string): number {
const today = getDatePartsInTimezone(timezone);
let year = today.year;
let candidate = new Date(Date.UTC(year, month - 1, day));
const todayUtc = new Date(Date.UTC(today.year, today.month - 1, today.day));
if (candidate.getTime() < todayUtc.getTime()) {
year += 1;
candidate = new Date(Date.UTC(year, month - 1, day));
}
return Math.round((candidate.getTime() - todayUtc.getTime()) / (24 * 60 * 60 * 1000));
}
export async function getBirthdayConfig(prisma: BotContext['prisma'], guildId: string) {
await ensureGuild(prisma, guildId);
let config = await prisma.birthdayConfig.findUnique({ where: { guildId } });
if (!config) {
config = await prisma.birthdayConfig.create({ data: { guildId } });
}
return config;
}
export async function updateBirthdayConfig(
prisma: BotContext['prisma'],
guildId: string,
data: {
enabled?: boolean;
channelId?: string | null;
roleId?: string | null;
message?: string | null;
timezone?: string;
}
) {
await ensureGuild(prisma, guildId);
return prisma.birthdayConfig.upsert({
where: { guildId },
update: data,
create: { guildId, ...data }
});
}
export async function setBirthday(
prisma: BotContext['prisma'],
guildId: string,
userId: string,
input: BirthdaySetInput
) {
if (!isValidCalendarDate(input.month, input.day, input.year)) {
throw new Error('invalid_date');
}
return prisma.birthday.upsert({
where: { guildId_userId: { guildId, userId } },
update: {
month: input.month,
day: input.day,
year: input.year ?? null
},
create: {
guildId,
userId,
month: input.month,
day: input.day,
year: input.year ?? null
}
});
}
export async function removeBirthday(prisma: BotContext['prisma'], guildId: string, userId: string) {
const existing = await prisma.birthday.findUnique({
where: { guildId_userId: { guildId, userId } }
});
if (!existing) {
return null;
}
await prisma.birthday.delete({ where: { guildId_userId: { guildId, userId } } });
return existing;
}
export async function listBirthdays(prisma: BotContext['prisma'], guildId: string, timezone: string) {
const birthdays = await prisma.birthday.findMany({ where: { guildId } });
return birthdays.sort((a, b) => {
const diff = daysUntilBirthday(a.month, a.day, timezone) - daysUntilBirthday(b.month, b.day, timezone);
if (diff !== 0) {
return diff;
}
return a.userId.localeCompare(b.userId);
});
}
export async function scheduleBirthdayRoleExpire(
guildId: string,
userId: string,
roleId: string
): Promise<void> {
await birthdayQueue.add(
'birthdayRoleExpire',
{ guildId, userId, roleId },
{
delay: ROLE_EXPIRE_MS,
jobId: `birthday-role-${guildId}-${userId}-${Date.now()}`,
removeOnComplete: 1000,
removeOnFail: 500
}
);
}
function renderBirthdayMessage(
template: string | null | undefined,
member: GuildMember,
age?: number
): string {
const vars: Record<string, string> = {
user: `<@${member.id}>`,
'user.name': member.displayName,
'user.tag': member.user.tag
};
if (age !== undefined) {
vars.age = String(age);
}
return applyTagPlaceholders(template?.trim() || DEFAULT_MESSAGE, vars);
}
async function sendBirthdayCongrats(
context: BotContext,
guild: Guild,
config: Awaited<ReturnType<typeof getBirthdayConfig>>,
birthday: { userId: string; month: number; day: number; year: number | null },
dateKey: string
): Promise<void> {
if (!config.channelId) {
return;
}
const dedupeKey = congratsRedisKey(guild.id, birthday.userId, dateKey);
const alreadySent = await context.redis.get(dedupeKey);
if (alreadySent) {
return;
}
const channel = await guild.channels.fetch(config.channelId).catch(() => null);
if (!channel?.isTextBased() || channel.isDMBased()) {
logger.warn({ guildId: guild.id, channelId: config.channelId }, 'Birthday channel invalid');
return;
}
const member = await guild.members.fetch(birthday.userId).catch(() => null);
if (!member) {
return;
}
const age =
birthday.year !== null
? computeAge(birthday.year, birthday.month, birthday.day, config.timezone)
: undefined;
const content = renderBirthdayMessage(config.message, member, age);
await (channel as TextChannel).send({ content });
if (config.roleId) {
const me = guild.members.me;
const role = guild.roles.cache.get(config.roleId);
if (
me?.permissions.has(PermissionFlagsBits.ManageRoles) &&
role &&
role.position < me.roles.highest.position &&
!member.roles.cache.has(role.id)
) {
await member.roles.add(role).catch((error) => {
logger.warn({ error, guildId: guild.id, userId: birthday.userId }, 'Failed to assign birthday role');
});
await scheduleBirthdayRoleExpire(guild.id, birthday.userId, role.id);
}
}
await context.redis.set(dedupeKey, '1', 'EX', 60 * 60 * 26);
logger.info({ guildId: guild.id, userId: birthday.userId }, 'Birthday congratulations sent');
}
export async function expireBirthdayRole(
context: BotContext,
guildId: string,
userId: string,
roleId: string
): Promise<void> {
const guild = await context.client.guilds.fetch(guildId).catch(() => null);
if (!guild) {
return;
}
const member = await guild.members.fetch(userId).catch(() => null);
if (!member) {
return;
}
const me = guild.members.me;
const role = guild.roles.cache.get(roleId);
if (
!me?.permissions.has(PermissionFlagsBits.ManageRoles) ||
!role ||
role.position >= me.roles.highest.position
) {
return;
}
if (member.roles.cache.has(roleId)) {
await member.roles.remove(roleId).catch((error) => {
logger.warn({ error, guildId, userId, roleId }, 'Failed to remove birthday role');
});
}
}
export async function runBirthdayCheck(context: BotContext): Promise<void> {
const configs = await context.prisma.birthdayConfig.findMany({
where: { enabled: true, channelId: { not: null } }
});
for (const config of configs) {
try {
const guild = await context.client.guilds.fetch(config.guildId).catch(() => null);
if (!guild) {
continue;
}
const { month, day, year } = getDatePartsInTimezone(config.timezone);
const dateKey = `${year}-${month}-${day}`;
const birthdays = await context.prisma.birthday.findMany({
where: { guildId: config.guildId, month, day }
});
await Promise.all(
birthdays.map((birthday) => sendBirthdayCongrats(context, guild, config, birthday, dateKey))
);
} catch (error) {
logger.error({ error, guildId: config.guildId }, 'Birthday check failed for guild');
}
}
}

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

@@ -0,0 +1,29 @@
import { SlashCommandBuilder } from 'discord.js';
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
export const helpCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('help'),
'core.help.description'
).addStringOption((option) =>
applyOptionDescription(option.setName('module'), 'core.help.options.module')
);
export const infoCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('info'),
'core.info.description'
);
export const aboutCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('about'),
'core.about.description'
);
export const inviteCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('invite'),
'core.invite.description'
);
export const supportCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('support'),
'core.support.description'
);

View File

@@ -0,0 +1,170 @@
import { EmbedBuilder } from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { buildBotInviteUrl, env } from '../../env.js';
import {
helpCommandData,
infoCommandData,
aboutCommandData,
inviteCommandData,
supportCommandData
} from './command-definitions.js';
import { HELP_MODULES } from './help-catalog.js';
import { buildAboutReply } from './about.js';
const NEXUMI_COLOR = 0x6366f1;
const PACKAGE_VERSION = '0.1.0';
function formatUptime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
return `${h}h ${m}m ${s}s`;
}
function webuiBase(): string {
return env.WEBUI_URL ?? env.PUBLIC_BASE_URL;
}
const helpCommand: SlashCommand = {
data: helpCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const filter = interaction.options.getString('module')?.trim().toLowerCase() ?? null;
const modules = filter
? HELP_MODULES.filter((module) => module.id === filter)
: HELP_MODULES;
if (modules.length === 0) {
await interaction.reply({
content: tf(locale, 'core.help.unknownModule', {
modules: HELP_MODULES.map((module) => module.id).join(', ')
}),
ephemeral: true
});
return;
}
const lines = modules.map((module) =>
tf(locale, 'core.help.moduleLine', {
module: t(locale, `core.module.${module.id}`),
commands: module.commands.map((name) => `\`/${name}\``).join(', ')
})
);
// Discord embed description limit is 4096; split across fields if needed.
const embed = new EmbedBuilder()
.setTitle(t(locale, 'core.help.title'))
.setDescription(t(locale, 'core.help.subtitle'))
.setColor(NEXUMI_COLOR)
.setFooter({
text: tf(locale, 'core.help.footer', { webui: webuiBase() })
});
const chunkSize = 8;
for (let i = 0; i < lines.length; i += chunkSize) {
const chunk = lines.slice(i, i + chunkSize);
embed.addFields({
name: '\u200b',
value: chunk.join('\n')
});
}
await interaction.reply({ embeds: [embed], ephemeral: true });
}
};
const infoCommand: SlashCommand = {
data: infoCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const shardId = context.client.shard?.ids[0] ?? 0;
const inviteUrl = buildBotInviteUrl();
const base = webuiBase();
const linkLines = [
`[${t(locale, 'core.info.link.dashboard')}](${base})`,
`[${t(locale, 'core.info.link.invite')}](${inviteUrl})`,
`[${t(locale, 'core.info.link.status')}](${base.replace(/\/$/, '')}/status)`
];
if (env.SUPPORT_SERVER_URL) {
linkLines.push(`[${t(locale, 'core.info.link.support')}](${env.SUPPORT_SERVER_URL})`);
}
const embed = new EmbedBuilder()
.setTitle(t(locale, 'core.info.title'))
.setColor(NEXUMI_COLOR)
.addFields(
{ name: t(locale, 'core.info.version'), value: PACKAGE_VERSION, inline: true },
{
name: t(locale, 'core.info.uptime'),
value: formatUptime(process.uptime()),
inline: true
},
{ name: t(locale, 'core.info.shard'), value: String(shardId), inline: true },
{
name: t(locale, 'core.info.guilds'),
value: String(context.client.guilds.cache.size),
inline: true
},
{
name: t(locale, 'core.info.latency'),
value: `${context.client.ws.ping} ms`,
inline: true
},
{ name: t(locale, 'core.info.links'), value: linkLines.join('\n') }
)
.setFooter({ text: 'Nexumi' });
await interaction.reply({ embeds: [embed], ephemeral: true });
}
};
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 = {
data: inviteCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const inviteUrl = buildBotInviteUrl();
const embed = new EmbedBuilder()
.setTitle(t(locale, 'core.invite.title'))
.setDescription(`${t(locale, 'core.invite.body')}\n${inviteUrl}`)
.setColor(NEXUMI_COLOR);
await interaction.reply({ embeds: [embed], ephemeral: true });
}
};
const supportCommand: SlashCommand = {
data: supportCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!env.SUPPORT_SERVER_URL) {
await interaction.reply({
content: t(locale, 'core.support.missing'),
ephemeral: true
});
return;
}
const embed = new EmbedBuilder()
.setTitle(t(locale, 'core.support.title'))
.setDescription(`${t(locale, 'core.support.body')}\n${env.SUPPORT_SERVER_URL}`)
.setColor(NEXUMI_COLOR);
await interaction.reply({ embeds: [embed], ephemeral: true });
}
};
export const coreCommands: SlashCommand[] = [
helpCommand,
infoCommand,
aboutCommand,
inviteCommand,
supportCommand
];

View File

@@ -0,0 +1,93 @@
/** Module id → top-level slash command names for `/help`. */
export const HELP_MODULES: ReadonlyArray<{ id: string; commands: readonly string[] }> = [
{ id: 'core', commands: ['help', 'info', 'about', 'invite', 'support', 'privacy', 'gdpr'] },
{
id: 'moderation',
commands: [
'ban',
'unban',
'kick',
'timeout',
'untimeout',
'warn',
'purge',
'slowmode',
'lock',
'unlock',
'nick',
'case',
'modnote'
]
},
{ id: 'automod', commands: ['automod'] },
{ id: 'welcome', commands: ['welcome'] },
{ id: 'verification', commands: ['verify'] },
{ id: 'leveling', commands: ['rank', 'leaderboard', 'xp'] },
{
id: 'economy',
commands: [
'balance',
'daily',
'weekly',
'work',
'pay',
'gamble',
'slots',
'blackjack',
'coinflip',
'shop',
'inventory',
'eco'
]
},
{
id: 'utility',
commands: [
'userinfo',
'serverinfo',
'roleinfo',
'channelinfo',
'avatar',
'banner',
'poll',
'remindme',
'reminders',
'afk',
'emoji',
'sticker',
'timestamp',
'snipe',
'editsnipe',
'embed'
]
},
{
id: 'fun',
commands: [
'8ball',
'dice',
'flip',
'rps',
'choose',
'trivia',
'tictactoe',
'connect4',
'hangman',
'meme',
'cat',
'dog'
]
},
{ id: 'giveaways', commands: ['giveaway'] },
{ id: 'tickets', commands: ['ticket'] },
{ id: 'selfroles', commands: ['selfroles'] },
{ id: 'tags', commands: ['tag'] },
{ id: 'starboard', commands: ['starboard'] },
{ id: 'suggestions', commands: ['suggest', 'suggestion'] },
{ id: 'birthdays', commands: ['birthday'] },
{ id: 'tempvoice', commands: ['voice'] },
{ id: 'stats', commands: ['stats', 'invites'] },
{ id: 'feeds', commands: ['feeds'] },
{ id: 'scheduler', commands: ['schedule', 'announce'] },
{ id: 'guildbackup', commands: ['backup'] }
];

View File

@@ -0,0 +1,2 @@
export { coreCommands } from './commands.js';
export { HELP_MODULES } from './help-catalog.js';

View File

@@ -0,0 +1,50 @@
import type { ButtonInteraction } from 'discord.js';
import { t } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import {
BLACKJACK_BUTTON_PREFIX,
hitBlackjack,
parseBlackjackButtonCustomId,
standBlackjack
} from './games.js';
import { EconomyError } from './service.js';
export function isBlackjackButton(customId: string): boolean {
return customId.startsWith(BLACKJACK_BUTTON_PREFIX);
}
export async function handleBlackjackButton(
interaction: ButtonInteraction,
context: BotContext
): Promise<void> {
const parsed = parseBlackjackButtonCustomId(interaction.customId);
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!parsed || !interaction.guildId) {
await interaction.reply({ content: t(locale, 'economy.blackjack.expired'), ephemeral: true });
return;
}
if (interaction.user.id !== parsed.userId) {
await interaction.reply({ content: t(locale, 'economy.blackjack.notOwner'), ephemeral: true });
return;
}
try {
if (parsed.action === 'hit') {
const result = await hitBlackjack(context, parsed.guildId, parsed.userId, locale);
await interaction.update({ embeds: [result.embed], components: result.components });
return;
}
const result = await standBlackjack(context, parsed.guildId, parsed.userId, locale);
await interaction.update({ embeds: [result.embed], components: [] });
} catch (error) {
if (error instanceof EconomyError && error.code === 'not_found') {
await interaction.reply({ content: t(locale, 'economy.blackjack.expired'), ephemeral: true });
return;
}
throw error;
}
}

View File

@@ -0,0 +1,121 @@
import {
SlashCommandBuilder,
type SlashCommandIntegerOption,
type SlashCommandStringOption,
type SlashCommandUserOption
} from 'discord.js';
import {
applyCommandDescription,
applyOptionDescription,
localizedChoice,
type CommandLocaleKey
} from '@nexumi/shared';
function amountOpt(o: SlashCommandIntegerOption, key: CommandLocaleKey = 'economy.common.options.amount') {
return applyOptionDescription(o.setName('amount').setRequired(true).setMinValue(1), key);
}
function userOpt(o: SlashCommandUserOption, key: CommandLocaleKey = 'economy.common.options.user') {
return applyOptionDescription(o.setName('user').setRequired(true), key);
}
function optionalUserOpt(o: SlashCommandUserOption) {
return applyOptionDescription(o.setName('user'), 'economy.common.options.userOptional');
}
function choiceOpt(o: SlashCommandStringOption) {
return applyOptionDescription(o.setName('choice').setRequired(true), 'economy.coinflip.options.choice').addChoices(
localizedChoice('economy.choice.heads', 'heads'),
localizedChoice('economy.choice.tails', 'tails')
);
}
export const balanceCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('balance'),
'economy.balance.description'
).addUserOption((o) => optionalUserOpt(o));
export const dailyCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('daily'),
'economy.daily.description'
);
export const weeklyCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('weekly'),
'economy.weekly.description'
);
export const workCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('work'),
'economy.work.description'
);
export const payCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('pay'),
'economy.pay.description'
)
.addUserOption((o) => userOpt(o))
.addIntegerOption((o) => amountOpt(o));
export const gambleCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('gamble'),
'economy.gamble.description'
).addIntegerOption((o) => amountOpt(o));
export const slotsCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('slots'),
'economy.slots.description'
).addIntegerOption((o) => amountOpt(o));
export const blackjackCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('blackjack'),
'economy.blackjack.description'
).addIntegerOption((o) => amountOpt(o));
export const coinflipCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('coinflip'),
'economy.coinflip.description'
)
.addIntegerOption((o) => amountOpt(o))
.addStringOption((o) => choiceOpt(o));
export const shopCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('shop'),
'economy.shop.description'
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('view'), 'economy.shop.view.description')
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('buy'), 'economy.shop.buy.description').addStringOption((o) =>
applyOptionDescription(o.setName('item_id').setRequired(true), 'economy.shop.options.item_id')
)
);
export const inventoryCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('inventory'),
'economy.inventory.description'
);
export const ecoCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('eco'),
'economy.eco.description'
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('leaderboard'), 'economy.eco.leaderboard.description')
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('give'), 'economy.eco.give.description')
.addUserOption((o) => userOpt(o, 'economy.common.options.target'))
.addIntegerOption((o) => amountOpt(o))
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('remove'), 'economy.eco.remove.description')
.addUserOption((o) => userOpt(o, 'economy.common.options.target'))
.addIntegerOption((o) => amountOpt(o))
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('reset'), 'economy.eco.reset.description').addUserOption((o) =>
userOpt(o, 'economy.common.options.target')
)
);

View File

@@ -0,0 +1,497 @@
import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js';
import { formatCurrency, randomInt, t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import {
balanceCommandData,
blackjackCommandData,
coinflipCommandData,
dailyCommandData,
ecoCommandData,
gambleCommandData,
inventoryCommandData,
payCommandData,
shopCommandData,
slotsCommandData,
weeklyCommandData,
workCommandData
} from './command-definitions.js';
import {
adminGiveBalance,
adminRemoveBalance,
adminResetEconomy,
assertEconomyEnabled,
buyShopItem,
claimDaily,
claimWeekly,
claimWork,
EconomyError,
formatCooldownSeconds,
getBalance,
getDailyCooldownRemaining,
getEconomyConfig,
getInventory,
getLeaderboard,
getWeeklyCooldownRemaining,
getWorkCooldownRemaining,
listShopItems,
transferBalance
} from './service.js';
import { playCoinflipBet, playGamble, playSlots, startBlackjack, type CoinflipChoice } from './games.js';
async function ensureGuildCommand(
interaction: ChatInputCommandInteraction,
locale: 'de' | 'en'
): Promise<string | null> {
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return null;
}
return interaction.guildId!;
}
async function handleEconomyError(
interaction: ChatInputCommandInteraction,
locale: 'de' | 'en',
error: unknown,
extras?: Record<string, string | number>
): Promise<void> {
if (!(error instanceof EconomyError)) {
throw error;
}
const key = `economy.error.${error.code}` as const;
const content =
error.code === 'cooldown' && extras?.seconds !== undefined
? tf(locale, 'economy.error.cooldown', { seconds: extras.seconds })
: t(locale, key);
await interaction.reply({ content, ephemeral: true });
}
function formatMoney(locale: 'de' | 'en', amount: number, symbol: string, name: string): string {
return formatCurrency(amount, symbol, name);
}
const balanceCommand: SlashCommand = {
data: balanceCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuildCommand(interaction, locale);
if (!guildId) return;
try {
const config = await assertEconomyEnabled(context.prisma, guildId);
const target = interaction.options.getUser('user') ?? interaction.user;
const balance = await getBalance(context.prisma, guildId, target.id);
await interaction.reply({
content: tf(locale, 'economy.balance.result', {
user: `<@${target.id}>`,
amount: formatMoney(locale, balance, config.currencySymbol, config.currencyName)
}),
ephemeral: true
});
} catch (error) {
await handleEconomyError(interaction, locale, error);
}
}
};
const dailyCommand: SlashCommand = {
data: dailyCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuildCommand(interaction, locale);
if (!guildId) return;
try {
const config = await getEconomyConfig(context.prisma, guildId);
const result = await claimDaily(context.prisma, guildId, interaction.user.id);
await interaction.reply({
content: tf(locale, 'economy.daily.success', {
amount: formatMoney(locale, result.amount, config.currencySymbol, config.currencyName),
balance: formatMoney(locale, result.balance, config.currencySymbol, config.currencyName)
}),
ephemeral: true
});
} catch (error) {
if (error instanceof EconomyError && error.code === 'cooldown') {
const seconds = formatCooldownSeconds(
await getDailyCooldownRemaining(context.prisma, guildId, interaction.user.id)
);
await handleEconomyError(interaction, locale, error, { seconds });
return;
}
await handleEconomyError(interaction, locale, error);
}
}
};
const weeklyCommand: SlashCommand = {
data: weeklyCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuildCommand(interaction, locale);
if (!guildId) return;
try {
const config = await getEconomyConfig(context.prisma, guildId);
const result = await claimWeekly(context.prisma, guildId, interaction.user.id);
await interaction.reply({
content: tf(locale, 'economy.weekly.success', {
amount: formatMoney(locale, result.amount, config.currencySymbol, config.currencyName),
balance: formatMoney(locale, result.balance, config.currencySymbol, config.currencyName)
}),
ephemeral: true
});
} catch (error) {
if (error instanceof EconomyError && error.code === 'cooldown') {
const seconds = formatCooldownSeconds(
await getWeeklyCooldownRemaining(context.prisma, guildId, interaction.user.id)
);
await handleEconomyError(interaction, locale, error, { seconds });
return;
}
await handleEconomyError(interaction, locale, error);
}
}
};
const workCommand: SlashCommand = {
data: workCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuildCommand(interaction, locale);
if (!guildId) return;
try {
const config = await getEconomyConfig(context.prisma, guildId);
const earned = randomInt(config.workMin, config.workMax);
const result = await claimWork(context.prisma, guildId, interaction.user.id, earned);
await interaction.reply({
content: tf(locale, 'economy.work.success', {
amount: formatMoney(locale, result.amount, config.currencySymbol, config.currencyName),
balance: formatMoney(locale, result.balance, config.currencySymbol, config.currencyName)
}),
ephemeral: true
});
} catch (error) {
if (error instanceof EconomyError && error.code === 'cooldown') {
const seconds = formatCooldownSeconds(
await getWorkCooldownRemaining(context.prisma, guildId, interaction.user.id)
);
await handleEconomyError(interaction, locale, error, { seconds });
return;
}
await handleEconomyError(interaction, locale, error);
}
}
};
const payCommand: SlashCommand = {
data: payCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuildCommand(interaction, locale);
if (!guildId) return;
const target = interaction.options.getUser('user', true);
const amount = interaction.options.getInteger('amount', true);
try {
await assertEconomyEnabled(context.prisma, guildId);
const config = await getEconomyConfig(context.prisma, guildId);
const { sender } = await transferBalance(
context.prisma,
guildId,
interaction.user.id,
target.id,
amount
);
await interaction.reply({
content: tf(locale, 'economy.pay.success', {
user: `<@${target.id}>`,
amount: formatMoney(locale, amount, config.currencySymbol, config.currencyName),
balance: formatMoney(locale, sender.balance, config.currencySymbol, config.currencyName)
}),
ephemeral: true
});
} catch (error) {
await handleEconomyError(interaction, locale, error);
}
}
};
const gambleCommand: SlashCommand = {
data: gambleCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuildCommand(interaction, locale);
if (!guildId) return;
const amount = interaction.options.getInteger('amount', true);
try {
const config = await getEconomyConfig(context.prisma, guildId);
const result = await playGamble(context, guildId, interaction.user.id, amount);
await interaction.reply({
content: tf(locale, result.won ? 'economy.gamble.win' : 'economy.gamble.lose', {
amount: formatMoney(locale, amount, config.currencySymbol, config.currencyName),
balance: formatMoney(locale, result.balance, config.currencySymbol, config.currencyName)
}),
ephemeral: true
});
} catch (error) {
await handleEconomyError(interaction, locale, error);
}
}
};
const slotsCommand: SlashCommand = {
data: slotsCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuildCommand(interaction, locale);
if (!guildId) return;
const amount = interaction.options.getInteger('amount', true);
try {
const config = await getEconomyConfig(context.prisma, guildId);
const result = await playSlots(context, guildId, interaction.user.id, amount);
const key = result.payout > 0 ? 'economy.slots.win' : 'economy.slots.lose';
await interaction.reply({
content: tf(locale, key, {
reels: result.reels.join(' | '),
payout: formatMoney(locale, result.payout, config.currencySymbol, config.currencyName),
balance: formatMoney(locale, result.balance, config.currencySymbol, config.currencyName)
}),
ephemeral: true
});
} catch (error) {
await handleEconomyError(interaction, locale, error);
}
}
};
const blackjackCommand: SlashCommand = {
data: blackjackCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuildCommand(interaction, locale);
if (!guildId) return;
const amount = interaction.options.getInteger('amount', true);
try {
const { embed, components } = await startBlackjack(
context,
guildId,
interaction.user.id,
amount,
locale
);
await interaction.reply({ embeds: [embed], components });
} catch (error) {
await handleEconomyError(interaction, locale, error);
}
}
};
const coinflipCommand: SlashCommand = {
data: coinflipCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuildCommand(interaction, locale);
if (!guildId) return;
const amount = interaction.options.getInteger('amount', true);
const choice = interaction.options.getString('choice', true) as CoinflipChoice;
try {
const config = await getEconomyConfig(context.prisma, guildId);
const result = await playCoinflipBet(context, guildId, interaction.user.id, amount, choice);
const choiceLabel = t(locale, `economy.choice.${choice}`);
const resultLabel = t(locale, `economy.choice.${result.result}`);
await interaction.reply({
content: tf(locale, result.won ? 'economy.coinflip.win' : 'economy.coinflip.lose', {
choice: choiceLabel,
result: resultLabel,
amount: formatMoney(locale, amount, config.currencySymbol, config.currencyName),
balance: formatMoney(locale, result.balance, config.currencySymbol, config.currencyName)
}),
ephemeral: true
});
} catch (error) {
await handleEconomyError(interaction, locale, error);
}
}
};
const shopCommand: SlashCommand = {
data: shopCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuildCommand(interaction, locale);
if (!guildId) return;
const sub = interaction.options.getSubcommand();
try {
const config = await getEconomyConfig(context.prisma, guildId);
if (sub === 'view') {
const items = await listShopItems(context.prisma, guildId);
const content =
items.length === 0
? t(locale, 'economy.shop.empty')
: items
.map((item) =>
tf(locale, 'economy.shop.itemLine', {
id: item.id,
name: item.name,
price: formatMoney(locale, item.price, config.currencySymbol, config.currencyName),
stock: item.stock === null ? '∞' : String(item.stock),
description: item.description ?? t(locale, 'economy.shop.noDescription')
})
)
.join('\n');
await interaction.reply({ content, ephemeral: true });
return;
}
const itemId = interaction.options.getString('item_id', true);
const purchase = await buyShopItem(context, guildId, interaction.user.id, itemId);
await interaction.reply({
content: tf(locale, 'economy.shop.purchased', {
name: purchase.item.name,
balance: formatMoney(locale, purchase.balance, config.currencySymbol, config.currencyName)
}),
ephemeral: true
});
} catch (error) {
await handleEconomyError(interaction, locale, error);
}
}
};
const inventoryCommand: SlashCommand = {
data: inventoryCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuildCommand(interaction, locale);
if (!guildId) return;
try {
const entries = await getInventory(context.prisma, guildId, interaction.user.id);
const content =
entries.length === 0
? t(locale, 'economy.inventory.empty')
: entries
.map((entry) =>
tf(locale, 'economy.inventory.itemLine', {
name: entry.item.name,
quantity: entry.quantity
})
)
.join('\n');
await interaction.reply({ content, ephemeral: true });
} catch (error) {
await handleEconomyError(interaction, locale, error);
}
}
};
const ecoCommand: SlashCommand = {
data: ecoCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuildCommand(interaction, locale);
if (!guildId) return;
const sub = interaction.options.getSubcommand();
if (sub === 'leaderboard') {
try {
const config = await getEconomyConfig(context.prisma, guildId);
const rows = await getLeaderboard(context.prisma, guildId);
const content =
rows.length === 0
? t(locale, 'economy.leaderboard.empty')
: rows
.map((row, index) =>
tf(locale, 'economy.leaderboard.line', {
rank: index + 1,
user: `<@${row.userId}>`,
amount: formatMoney(locale, row.balance, config.currencySymbol, config.currencyName)
})
)
.join('\n');
await interaction.reply({ content, ephemeral: true });
} catch (error) {
await handleEconomyError(interaction, locale, error);
}
return;
}
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
return;
}
const target = interaction.options.getUser('user', true);
try {
const config = await getEconomyConfig(context.prisma, guildId);
if (sub === 'give') {
const amount = interaction.options.getInteger('amount', true);
const updated = await adminGiveBalance(context.prisma, guildId, target.id, amount);
await interaction.reply({
content: tf(locale, 'economy.admin.give', {
user: `<@${target.id}>`,
amount: formatMoney(locale, amount, config.currencySymbol, config.currencyName),
balance: formatMoney(locale, updated.balance, config.currencySymbol, config.currencyName)
}),
ephemeral: true
});
return;
}
if (sub === 'remove') {
const amount = interaction.options.getInteger('amount', true);
const updated = await adminRemoveBalance(context.prisma, guildId, target.id, amount);
await interaction.reply({
content: tf(locale, 'economy.admin.remove', {
user: `<@${target.id}>`,
amount: formatMoney(locale, amount, config.currencySymbol, config.currencyName),
balance: formatMoney(locale, updated.balance, config.currencySymbol, config.currencyName)
}),
ephemeral: true
});
return;
}
await adminResetEconomy(context.prisma, guildId, target.id);
await interaction.reply({
content: tf(locale, 'economy.admin.reset', {
user: `<@${target.id}>`
}),
ephemeral: true
});
} catch (error) {
await handleEconomyError(interaction, locale, error);
}
}
};
export const economyCommands: SlashCommand[] = [
balanceCommand,
dailyCommand,
weeklyCommand,
workCommand,
payCommand,
gambleCommand,
slotsCommand,
blackjackCommand,
coinflipCommand,
shopCommand,
inventoryCommand,
ecoCommand
];

View File

@@ -0,0 +1,420 @@
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, type APIEmbed } from 'discord.js';
import { formatCurrency, randomInt } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import type { Locale } from '@nexumi/shared';
import { tf } from '@nexumi/shared';
import {
addBalance,
assertEconomyEnabled,
deductBalance,
EconomyError,
getEconomyConfig
} from './service.js';
const BLACKJACK_TTL_SECONDS = 300;
const BLACKJACK_KEY_PREFIX = 'eco:bj:';
export const BLACKJACK_BUTTON_PREFIX = 'eco:bj:';
const RANKS = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K'] as const;
const SUITS = ['H', 'D', 'C', 'S'] as const;
type Card = `${(typeof RANKS)[number]}${(typeof SUITS)[number]}`;
export type BlackjackState = {
userId: string;
guildId: string;
bet: number;
deck: Card[];
playerHand: Card[];
dealerHand: Card[];
finished: boolean;
};
const SLOT_SYMBOLS = ['🍒', '🍋', '🔔', '⭐', '💎', '7⃣'] as const;
function buildDeck(): Card[] {
const deck: Card[] = [];
for (const suit of SUITS) {
for (const rank of RANKS) {
deck.push(`${rank}${suit}`);
}
}
for (let i = deck.length - 1; i > 0; i -= 1) {
const j = randomInt(0, i);
[deck[i], deck[j]] = [deck[j]!, deck[i]!];
}
return deck;
}
function cardValue(card: Card): number {
const rank = card.slice(0, card.length - 1);
if (rank === 'A') {
return 11;
}
if (rank === 'K' || rank === 'Q' || rank === 'J') {
return 10;
}
return Number(rank);
}
export function handValue(hand: Card[]): number {
let total = hand.reduce((sum, card) => sum + cardValue(card), 0);
let aces = hand.filter((card) => card.startsWith('A')).length;
while (total > 21 && aces > 0) {
total -= 10;
aces -= 1;
}
return total;
}
function isBlackjack(hand: Card[]): boolean {
return hand.length === 2 && handValue(hand) === 21;
}
function formatHand(hand: Card[], hideFirst = false): string {
if (hideFirst && hand.length > 0) {
return `🂠 ${hand.slice(1).join(' ')}`;
}
return hand.join(' ');
}
function blackjackKey(guildId: string, userId: string): string {
return `${BLACKJACK_KEY_PREFIX}${guildId}:${userId}`;
}
async function saveBlackjackState(context: BotContext, state: BlackjackState): Promise<void> {
await context.redis.set(
blackjackKey(state.guildId, state.userId),
JSON.stringify(state),
'EX',
BLACKJACK_TTL_SECONDS
);
}
async function loadBlackjackState(
context: BotContext,
guildId: string,
userId: string
): Promise<BlackjackState | null> {
const raw = await context.redis.get(blackjackKey(guildId, userId));
if (!raw) {
return null;
}
return JSON.parse(raw) as BlackjackState;
}
async function deleteBlackjackState(context: BotContext, guildId: string, userId: string): Promise<void> {
await context.redis.del(blackjackKey(guildId, userId));
}
function drawCard(state: BlackjackState): Card {
const card = state.deck.pop();
if (!card) {
throw new Error('Blackjack deck empty');
}
return card;
}
export function buildBlackjackButtons(locale: Locale, guildId: string, userId: string): ActionRowBuilder<ButtonBuilder> {
return new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(`${BLACKJACK_BUTTON_PREFIX}hit:${guildId}:${userId}`)
.setLabel(tf(locale, 'economy.blackjack.button.hit', {}))
.setStyle(ButtonStyle.Primary),
new ButtonBuilder()
.setCustomId(`${BLACKJACK_BUTTON_PREFIX}stand:${guildId}:${userId}`)
.setLabel(tf(locale, 'economy.blackjack.button.stand', {}))
.setStyle(ButtonStyle.Secondary)
);
}
export function buildBlackjackEmbed(
locale: Locale,
state: BlackjackState,
config: { currencySymbol: string; currencyName: string },
revealDealer: boolean,
outcome?: 'win' | 'lose' | 'push' | 'blackjack'
): APIEmbed {
const playerTotal = handValue(state.playerHand);
const dealerTotal = revealDealer ? handValue(state.dealerHand) : handValue(state.dealerHand.slice(1));
let description = tf(locale, 'economy.blackjack.hand', {
playerHand: formatHand(state.playerHand),
playerTotal: String(playerTotal),
dealerHand: formatHand(state.dealerHand, !revealDealer),
dealerTotal: revealDealer ? String(dealerTotal) : '?',
bet: formatCurrency(state.bet, config.currencySymbol, config.currencyName)
});
if (outcome) {
const outcomeKey = `economy.blackjack.outcome.${outcome}` as const;
description += `\n\n${tf(locale, outcomeKey, {
bet: formatCurrency(state.bet, config.currencySymbol, config.currencyName)
})}`;
}
return {
title: tf(locale, 'economy.blackjack.title', {}),
description,
color: outcome === 'win' || outcome === 'blackjack' ? 0x22c55e : outcome === 'push' ? 0xeab308 : 0x6366f1
};
}
async function finishBlackjack(
context: BotContext,
state: BlackjackState
): Promise<{ state: BlackjackState; outcome: 'win' | 'lose' | 'push' | 'blackjack'; payout: number }> {
const playerTotal = handValue(state.playerHand);
const dealerTotal = handValue(state.dealerHand);
let outcome: 'win' | 'lose' | 'push' | 'blackjack';
let payout = 0;
if (playerTotal > 21) {
outcome = 'lose';
} else if (isBlackjack(state.playerHand) && !isBlackjack(state.dealerHand)) {
outcome = 'blackjack';
payout = Math.floor(state.bet * 2.5);
} else if (dealerTotal > 21 || playerTotal > dealerTotal) {
outcome = 'win';
payout = state.bet * 2;
} else if (playerTotal === dealerTotal) {
outcome = 'push';
payout = state.bet;
} else {
outcome = 'lose';
}
if (payout > 0) {
await addBalance(context.prisma, state.guildId, state.userId, payout);
}
state.finished = true;
await deleteBlackjackState(context, state.guildId, state.userId);
return { state, outcome, payout };
}
export async function startBlackjack(
context: BotContext,
guildId: string,
userId: string,
bet: number,
locale: Locale
): Promise<{ embed: APIEmbed; components: ActionRowBuilder<ButtonBuilder>[] }> {
await assertEconomyEnabled(context.prisma, guildId);
if (!Number.isInteger(bet) || bet <= 0) {
throw new EconomyError('invalid_amount');
}
const existing = await loadBlackjackState(context, guildId, userId);
if (existing && !existing.finished) {
throw new EconomyError('game_in_progress');
}
await deductBalance(context.prisma, guildId, userId, bet);
const state: BlackjackState = {
userId,
guildId,
bet,
deck: buildDeck(),
playerHand: [],
dealerHand: [],
finished: false
};
state.playerHand.push(drawCard(state), drawCard(state));
state.dealerHand.push(drawCard(state), drawCard(state));
const config = await getEconomyConfig(context.prisma, guildId);
if (isBlackjack(state.playerHand) || isBlackjack(state.dealerHand)) {
const { state: finished, outcome } = await finishBlackjack(context, state);
return {
embed: buildBlackjackEmbed(locale, finished, config, true, outcome),
components: []
};
}
await saveBlackjackState(context, state);
return {
embed: buildBlackjackEmbed(locale, state, config, false),
components: [buildBlackjackButtons(locale, guildId, userId)]
};
}
export async function hitBlackjack(
context: BotContext,
guildId: string,
userId: string,
locale: Locale
): Promise<{ embed: APIEmbed; components: ActionRowBuilder<ButtonBuilder>[]; finished: boolean }> {
const state = await loadBlackjackState(context, guildId, userId);
if (!state || state.finished) {
throw new EconomyError('not_found');
}
if (state.userId !== userId) {
throw new EconomyError('not_found');
}
state.playerHand.push(drawCard(state));
const config = await getEconomyConfig(context.prisma, guildId);
if (handValue(state.playerHand) > 21) {
const { state: finished, outcome } = await finishBlackjack(context, state);
return {
embed: buildBlackjackEmbed(locale, finished, config, true, outcome),
components: [],
finished: true
};
}
await saveBlackjackState(context, state);
return {
embed: buildBlackjackEmbed(locale, state, config, false),
components: [buildBlackjackButtons(locale, guildId, userId)],
finished: false
};
}
export async function standBlackjack(
context: BotContext,
guildId: string,
userId: string,
locale: Locale
): Promise<{ embed: APIEmbed; finished: true }> {
const state = await loadBlackjackState(context, guildId, userId);
if (!state || state.finished) {
throw new EconomyError('not_found');
}
if (state.userId !== userId) {
throw new EconomyError('not_found');
}
while (handValue(state.dealerHand) < 17) {
state.dealerHand.push(drawCard(state));
}
const config = await getEconomyConfig(context.prisma, guildId);
const { state: finished, outcome } = await finishBlackjack(context, state);
return {
embed: buildBlackjackEmbed(locale, finished, config, true, outcome),
finished: true
};
}
export async function playGamble(
context: BotContext,
guildId: string,
userId: string,
amount: number
): Promise<{ won: boolean; balance: number }> {
await assertEconomyEnabled(context.prisma, guildId);
if (!Number.isInteger(amount) || amount <= 0) {
throw new EconomyError('invalid_amount');
}
await deductBalance(context.prisma, guildId, userId, amount);
const won = Math.random() < 0.5;
if (won) {
const updated = await addBalance(context.prisma, guildId, userId, amount * 2);
return { won: true, balance: updated.balance };
}
const member = await context.prisma.memberEconomy.findUniqueOrThrow({
where: { guildId_userId: { guildId, userId } }
});
return { won: false, balance: member.balance };
}
export async function playSlots(
context: BotContext,
guildId: string,
userId: string,
amount: number
): Promise<{ reels: string[]; multiplier: number; payout: number; balance: number }> {
await assertEconomyEnabled(context.prisma, guildId);
if (!Number.isInteger(amount) || amount <= 0) {
throw new EconomyError('invalid_amount');
}
await deductBalance(context.prisma, guildId, userId, amount);
const reels = [
SLOT_SYMBOLS[randomInt(0, SLOT_SYMBOLS.length - 1)]!,
SLOT_SYMBOLS[randomInt(0, SLOT_SYMBOLS.length - 1)]!,
SLOT_SYMBOLS[randomInt(0, SLOT_SYMBOLS.length - 1)]!
];
let multiplier = 0;
if (reels[0] === reels[1] && reels[1] === reels[2]) {
multiplier = reels[0] === '7⃣' ? 10 : reels[0] === '💎' ? 5 : 3;
} else if (reels[0] === reels[1] || reels[1] === reels[2] || reels[0] === reels[2]) {
multiplier = 1.5;
}
const payout = Math.floor(amount * multiplier);
let balance: number;
if (payout > 0) {
const updated = await addBalance(context.prisma, guildId, userId, payout);
balance = updated.balance;
} else {
const member = await context.prisma.memberEconomy.findUniqueOrThrow({
where: { guildId_userId: { guildId, userId } }
});
balance = member.balance;
}
return { reels, multiplier, payout, balance };
}
export type CoinflipChoice = 'heads' | 'tails';
export async function playCoinflipBet(
context: BotContext,
guildId: string,
userId: string,
amount: number,
choice: CoinflipChoice
): Promise<{ result: CoinflipChoice; won: boolean; balance: number }> {
await assertEconomyEnabled(context.prisma, guildId);
if (!Number.isInteger(amount) || amount <= 0) {
throw new EconomyError('invalid_amount');
}
await deductBalance(context.prisma, guildId, userId, amount);
const result: CoinflipChoice = Math.random() < 0.5 ? 'heads' : 'tails';
const won = result === choice;
if (won) {
const updated = await addBalance(context.prisma, guildId, userId, amount * 2);
return { result, won: true, balance: updated.balance };
}
const member = await context.prisma.memberEconomy.findUniqueOrThrow({
where: { guildId_userId: { guildId, userId } }
});
return { result, won: false, balance: member.balance };
}
export function parseBlackjackButtonCustomId(customId: string): {
action: 'hit' | 'stand';
guildId: string;
userId: string;
} | null {
if (!customId.startsWith(BLACKJACK_BUTTON_PREFIX)) {
return null;
}
const parts = customId.slice(BLACKJACK_BUTTON_PREFIX.length).split(':');
if (parts.length !== 3) {
return null;
}
const [action, guildId, userId] = parts;
if (action !== 'hit' && action !== 'stand') {
return null;
}
if (!guildId || !userId) {
return null;
}
return { action, guildId, userId };
}

View File

@@ -0,0 +1,2 @@
export { economyCommands } from './commands.js';
export { isBlackjackButton, handleBlackjackButton } from './blackjack-buttons.js';

View File

@@ -0,0 +1,380 @@
import type { EconomyConfig, MemberEconomy, Prisma, PrismaClient, ShopItem } from '@prisma/client';
import { ensureGuild } from '../../guild.js';
import type { BotContext } from '../../types.js';
type DbClient = PrismaClient | Prisma.TransactionClient;
export type EconomyErrorCode =
| 'disabled'
| 'insufficient'
| 'cooldown'
| 'invalid_amount'
| 'self_transfer'
| 'not_found'
| 'out_of_stock'
| 'role_assign_failed'
| 'game_in_progress';
export class EconomyError extends Error {
constructor(public readonly code: EconomyErrorCode) {
super(code);
this.name = 'EconomyError';
}
}
const DAILY_COOLDOWN_MS = 24 * 60 * 60 * 1000;
const WEEKLY_COOLDOWN_MS = 7 * DAILY_COOLDOWN_MS;
export async function getEconomyConfig(
prisma: DbClient,
guildId: string
): Promise<EconomyConfig> {
await ensureGuild(prisma as PrismaClient, guildId);
return prisma.economyConfig.upsert({
where: { guildId },
update: {},
create: { guildId }
});
}
export async function assertEconomyEnabled(prisma: PrismaClient, guildId: string): Promise<EconomyConfig> {
const config = await getEconomyConfig(prisma, guildId);
if (!config.enabled) {
throw new EconomyError('disabled');
}
return config;
}
export async function ensureMemberEconomy(
prisma: DbClient,
guildId: string,
userId: string
): Promise<MemberEconomy> {
const config = await getEconomyConfig(prisma, guildId);
return prisma.memberEconomy.upsert({
where: { guildId_userId: { guildId, userId } },
update: {},
create: {
guildId,
userId,
balance: config.startingBalance
}
});
}
export async function getBalance(
prisma: PrismaClient,
guildId: string,
userId: string
): Promise<number> {
const member = await ensureMemberEconomy(prisma, guildId, userId);
return member.balance;
}
function assertPositiveAmount(amount: number): void {
if (!Number.isInteger(amount) || amount <= 0) {
throw new EconomyError('invalid_amount');
}
}
export async function addBalance(
prisma: DbClient,
guildId: string,
userId: string,
amount: number
): Promise<MemberEconomy> {
await ensureMemberEconomy(prisma, guildId, userId);
return prisma.memberEconomy.update({
where: { guildId_userId: { guildId, userId } },
data: { balance: { increment: amount } }
});
}
export async function deductBalance(
prisma: DbClient,
guildId: string,
userId: string,
amount: number
): Promise<MemberEconomy> {
assertPositiveAmount(amount);
const member = await ensureMemberEconomy(prisma, guildId, userId);
if (member.balance < amount) {
throw new EconomyError('insufficient');
}
return prisma.memberEconomy.update({
where: { guildId_userId: { guildId, userId } },
data: { balance: { decrement: amount } }
});
}
export async function transferBalance(
prisma: PrismaClient,
guildId: string,
fromUserId: string,
toUserId: string,
amount: number
): Promise<{ sender: MemberEconomy; receiver: MemberEconomy }> {
assertPositiveAmount(amount);
if (fromUserId === toUserId) {
throw new EconomyError('self_transfer');
}
return prisma.$transaction(async (tx) => {
const sender = await ensureMemberEconomy(tx, guildId, fromUserId);
if (sender.balance < amount) {
throw new EconomyError('insufficient');
}
await ensureMemberEconomy(tx, guildId, toUserId);
const updatedSender = await tx.memberEconomy.update({
where: { guildId_userId: { guildId, userId: fromUserId } },
data: { balance: { decrement: amount } }
});
const updatedReceiver = await tx.memberEconomy.update({
where: { guildId_userId: { guildId, userId: toUserId } },
data: { balance: { increment: amount } }
});
return { sender: updatedSender, receiver: updatedReceiver };
});
}
function cooldownRemainingMs(lastAt: Date | null, cooldownMs: number): number {
if (!lastAt) {
return 0;
}
const elapsed = Date.now() - lastAt.getTime();
return Math.max(0, cooldownMs - elapsed);
}
export async function claimDaily(
prisma: PrismaClient,
guildId: string,
userId: string
): Promise<{ amount: number; balance: number }> {
const config = await assertEconomyEnabled(prisma, guildId);
const member = await ensureMemberEconomy(prisma, guildId, userId);
const remaining = cooldownRemainingMs(member.lastDailyAt, DAILY_COOLDOWN_MS);
if (remaining > 0) {
throw new EconomyError('cooldown');
}
const updated = await prisma.memberEconomy.update({
where: { guildId_userId: { guildId, userId } },
data: {
balance: { increment: config.dailyAmount },
lastDailyAt: new Date()
}
});
return { amount: config.dailyAmount, balance: updated.balance };
}
export async function claimWeekly(
prisma: PrismaClient,
guildId: string,
userId: string
): Promise<{ amount: number; balance: number }> {
const config = await assertEconomyEnabled(prisma, guildId);
const member = await ensureMemberEconomy(prisma, guildId, userId);
const remaining = cooldownRemainingMs(member.lastWeeklyAt, WEEKLY_COOLDOWN_MS);
if (remaining > 0) {
throw new EconomyError('cooldown');
}
const updated = await prisma.memberEconomy.update({
where: { guildId_userId: { guildId, userId } },
data: {
balance: { increment: config.weeklyAmount },
lastWeeklyAt: new Date()
}
});
return { amount: config.weeklyAmount, balance: updated.balance };
}
export async function claimWork(
prisma: PrismaClient,
guildId: string,
userId: string,
earned: number
): Promise<{ amount: number; balance: number }> {
await assertEconomyEnabled(prisma, guildId);
const config = await getEconomyConfig(prisma, guildId);
const member = await ensureMemberEconomy(prisma, guildId, userId);
const cooldownMs = config.workCooldownSeconds * 1000;
const remaining = cooldownRemainingMs(member.lastWorkAt, cooldownMs);
if (remaining > 0) {
throw new EconomyError('cooldown');
}
const updated = await prisma.memberEconomy.update({
where: { guildId_userId: { guildId, userId } },
data: {
balance: { increment: earned },
lastWorkAt: new Date()
}
});
return { amount: earned, balance: updated.balance };
}
export async function getWorkCooldownRemaining(
prisma: PrismaClient,
guildId: string,
userId: string
): Promise<number> {
const config = await getEconomyConfig(prisma, guildId);
const member = await ensureMemberEconomy(prisma, guildId, userId);
return cooldownRemainingMs(member.lastWorkAt, config.workCooldownSeconds * 1000);
}
export async function getDailyCooldownRemaining(
prisma: PrismaClient,
guildId: string,
userId: string
): Promise<number> {
const member = await ensureMemberEconomy(prisma, guildId, userId);
return cooldownRemainingMs(member.lastDailyAt, DAILY_COOLDOWN_MS);
}
export async function getWeeklyCooldownRemaining(
prisma: PrismaClient,
guildId: string,
userId: string
): Promise<number> {
const member = await ensureMemberEconomy(prisma, guildId, userId);
return cooldownRemainingMs(member.lastWeeklyAt, WEEKLY_COOLDOWN_MS);
}
export async function listShopItems(prisma: PrismaClient, guildId: string): Promise<ShopItem[]> {
await assertEconomyEnabled(prisma, guildId);
return prisma.shopItem.findMany({
where: { guildId, enabled: true },
orderBy: { price: 'asc' }
});
}
export async function buyShopItem(
context: BotContext,
guildId: string,
userId: string,
itemId: string
): Promise<{ item: ShopItem; balance: number }> {
await assertEconomyEnabled(context.prisma, guildId);
return context.prisma.$transaction(async (tx) => {
const item = await tx.shopItem.findFirst({
where: { id: itemId, guildId, enabled: true }
});
if (!item) {
throw new EconomyError('not_found');
}
if (item.stock !== null && item.stock <= 0) {
throw new EconomyError('out_of_stock');
}
const member = await ensureMemberEconomy(tx, guildId, userId);
if (member.balance < item.price) {
throw new EconomyError('insufficient');
}
const updatedMember = await tx.memberEconomy.update({
where: { guildId_userId: { guildId, userId } },
data: { balance: { decrement: item.price } }
});
await tx.inventoryItem.upsert({
where: { guildId_userId_itemId: { guildId, userId, itemId: item.id } },
update: { quantity: { increment: 1 } },
create: { guildId, userId, itemId: item.id, quantity: 1 }
});
if (item.stock !== null) {
await tx.shopItem.update({
where: { id: item.id },
data: { stock: { decrement: 1 } }
});
}
if (item.roleId) {
const guild = context.client.guilds.cache.get(guildId) ?? (await context.client.guilds.fetch(guildId));
const memberObj = await guild.members.fetch(userId);
try {
await memberObj.roles.add(item.roleId, 'Economy shop purchase');
} catch {
throw new EconomyError('role_assign_failed');
}
}
return { item, balance: updatedMember.balance };
});
}
export async function getInventory(
prisma: PrismaClient,
guildId: string,
userId: string
) {
await assertEconomyEnabled(prisma, guildId);
return prisma.inventoryItem.findMany({
where: { guildId, userId },
include: { item: true },
orderBy: { createdAt: 'desc' }
});
}
export async function getLeaderboard(prisma: PrismaClient, guildId: string, limit = 10) {
await assertEconomyEnabled(prisma, guildId);
return prisma.memberEconomy.findMany({
where: { guildId },
orderBy: { balance: 'desc' },
take: limit
});
}
export async function adminGiveBalance(
prisma: PrismaClient,
guildId: string,
userId: string,
amount: number
): Promise<MemberEconomy> {
assertPositiveAmount(amount);
await assertEconomyEnabled(prisma, guildId);
return addBalance(prisma, guildId, userId, amount);
}
export async function adminRemoveBalance(
prisma: PrismaClient,
guildId: string,
userId: string,
amount: number
): Promise<MemberEconomy> {
return deductBalance(prisma, guildId, userId, amount);
}
export async function adminResetEconomy(
prisma: PrismaClient,
guildId: string,
userId: string
): Promise<void> {
await assertEconomyEnabled(prisma, guildId);
const config = await getEconomyConfig(prisma, guildId);
await prisma.$transaction([
prisma.memberEconomy.upsert({
where: { guildId_userId: { guildId, userId } },
update: {
balance: config.startingBalance,
lastDailyAt: null,
lastWeeklyAt: null,
lastWorkAt: null
},
create: {
guildId,
userId,
balance: config.startingBalance
}
}),
prisma.inventoryItem.deleteMany({ where: { guildId, userId } })
]);
}
export function formatCooldownSeconds(ms: number): number {
return Math.ceil(ms / 1000);
}

View File

@@ -0,0 +1,54 @@
import { ChannelType } from 'discord.js';
import {
applyCommandDescription,
applyOptionDescription,
localizedChoice
} from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js';
const feedTypeChoices = [
localizedChoice('feeds.choice.type.twitch', 'TWITCH'),
localizedChoice('feeds.choice.type.youtube', 'YOUTUBE'),
localizedChoice('feeds.choice.type.rss', 'RSS'),
localizedChoice('feeds.choice.type.reddit', 'REDDIT')
] as const;
export const feedsCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('feeds')
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('add'), 'feeds.add.description')
.addStringOption((o) =>
applyOptionDescription(o.setName('type').setRequired(true), 'feeds.add.options.type').addChoices(
...feedTypeChoices
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('source').setRequired(true), 'feeds.add.options.source')
)
.addChannelOption((o) =>
applyOptionDescription(
o.setName('channel').addChannelTypes(ChannelType.GuildText).setRequired(true),
'feeds.add.options.channel'
)
)
.addRoleOption((o) =>
applyOptionDescription(o.setName('role'), 'feeds.add.options.role')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('template'), 'feeds.add.options.template')
)
)
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('remove'), 'feeds.remove.description').addStringOption((o) =>
applyOptionDescription(o.setName('id').setRequired(true), 'feeds.remove.options.id')
)
)
.addSubcommand((sub) => applyCommandDescription(sub.setName('list'), 'feeds.list.description'))
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('test'), 'feeds.test.description').addStringOption((o) =>
applyOptionDescription(o.setName('id').setRequired(true), 'feeds.test.options.id')
)
),
'feeds.description'
);

View File

@@ -0,0 +1,141 @@
import { PermissionFlagsBits } from 'discord.js';
import { SocialFeedTypeSchema, t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import { feedsCommandData } from './command-definitions.js';
import {
addFeed,
FeedAddSchema,
FeedError,
getFeedById,
listFeeds,
removeFeed,
testFeed
} from './service.js';
const feedsCommand: SlashCommand = {
data: feedsCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
const sub = interaction.options.getSubcommand(true);
const guildId = interaction.guildId!;
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
return;
}
try {
if (sub === 'add') {
const input = FeedAddSchema.parse({
type: SocialFeedTypeSchema.parse(interaction.options.getString('type', true)),
source: interaction.options.getString('source', true),
channelId: interaction.options.getChannel('channel', true).id,
rolePingId: interaction.options.getRole('role')?.id,
template: interaction.options.getString('template') ?? undefined
});
const feed = await addFeed(context.prisma, guildId, input);
await interaction.reply({
content: tf(locale, 'feeds.added', { id: feed.id, type: feed.type }),
ephemeral: true
});
return;
}
if (sub === 'remove') {
const feedId = interaction.options.getString('id', true);
const removed = await removeFeed(context.prisma, guildId, feedId);
if (!removed) {
await interaction.reply({ content: t(locale, 'feeds.error.not_found'), ephemeral: true });
return;
}
await interaction.reply({
content: tf(locale, 'feeds.removed', { id: removed.id }),
ephemeral: true
});
return;
}
if (sub === 'list') {
const feeds = await listFeeds(context.prisma, guildId);
if (feeds.length === 0) {
await interaction.reply({ content: t(locale, 'feeds.list.empty'), ephemeral: true });
return;
}
const lines = feeds.map((feed) =>
tf(locale, 'feeds.list.line', {
id: feed.id,
type: feed.type,
source: feed.sourceId,
channel: `<#${feed.channelId}>`
})
);
await interaction.reply({
content: `${t(locale, 'feeds.list.header')}\n${lines.join('\n')}`,
ephemeral: true
});
return;
}
if (sub === 'test') {
const feedId = interaction.options.getString('id', true);
const feed = await getFeedById(context.prisma, guildId, feedId);
if (!feed) {
await interaction.reply({ content: t(locale, 'feeds.error.not_found'), ephemeral: true });
return;
}
const result = await testFeed(context, feed);
if (result.skipped) {
const skipKey =
result.message === 'twitch_credentials_missing'
? 'feeds.test.twitchSkipped'
: 'feeds.test.skipped';
await interaction.reply({
content: t(locale, skipKey),
ephemeral: true
});
return;
}
if (result.message && result.posted === 0) {
await interaction.reply({
content: tf(locale, 'feeds.test.failed', { reason: result.message }),
ephemeral: true
});
return;
}
if (result.posted === 0) {
await interaction.reply({ content: t(locale, 'feeds.test.noItems'), ephemeral: true });
return;
}
await interaction.reply({
content: tf(locale, 'feeds.test.posted', { count: result.posted }),
ephemeral: true
});
}
} catch (error) {
if (error instanceof FeedError) {
await interaction.reply({
content: t(locale, `feeds.error.${error.code}`),
ephemeral: true
});
return;
}
throw error;
}
}
};
export const feedsCommands: SlashCommand[] = [feedsCommand];

View File

@@ -0,0 +1,277 @@
import { env } from '../../env.js';
export type FeedItem = {
id: string;
title: string;
url: string;
};
export type FetchResult =
| { ok: true; items: FeedItem[] }
| { ok: false; skipped?: boolean; message: string };
const USER_AGENT = 'NexumiBot/1.0';
const YOUTUBE_CHANNEL_ID_RE = /^UC[\w-]{10,}$/i;
function decodeXmlEntities(value: string): string {
return value
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&apos;', "'")
.replaceAll('&#39;', "'");
}
function stripCdata(value: string): string {
return value.replace(/^<!\[CDATA\[([\s\S]*?)\]\]>$/i, '$1').trim();
}
function extractTag(block: string, tagNames: string[]): string | null {
for (const tag of tagNames) {
const match = block.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i'));
if (match?.[1]) {
return decodeXmlEntities(stripCdata(match[1].trim()));
}
}
return null;
}
function extractLink(block: string): string | null {
const atomLink = block.match(/<link[^>]*href=["']([^"']+)["'][^>]*\/?>/i);
if (atomLink?.[1]) {
return atomLink[1];
}
const rssLink = extractTag(block, ['link']);
return rssLink;
}
function extractGuid(block: string, link: string | null): string | null {
const guid = extractTag(block, ['guid', 'id']);
if (guid) {
return guid;
}
return link;
}
export function parseRssXml(xml: string): FeedItem[] {
const items: FeedItem[] = [];
const blockRegex = /<(?:item|entry)\b[\s\S]*?<\/(?:item|entry)>/gi;
const blocks = xml.match(blockRegex) ?? [];
for (const block of blocks) {
const title = extractTag(block, ['title']);
const link = extractLink(block);
const id = extractGuid(block, link);
if (!title || !link || !id) {
continue;
}
items.push({ id, title, url: link });
}
return items;
}
async function fetchText(url: string): Promise<string> {
const response = await fetch(url, {
headers: { 'User-Agent': USER_AGENT }
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.text();
}
export async function fetchRssFeed(sourceUrl: string): Promise<FetchResult> {
try {
const xml = await fetchText(sourceUrl);
const items = parseRssXml(xml);
return { ok: true, items };
} catch (error) {
return {
ok: false,
message: error instanceof Error ? error.message : 'fetch_failed'
};
}
}
export async function fetchYouTubeFeed(source: string): Promise<FetchResult> {
const trimmed = source.trim();
const channelIdMatch = trimmed.match(/channel_id=([\w-]+)/i);
const channelId = YOUTUBE_CHANNEL_ID_RE.test(trimmed)
? trimmed
: (channelIdMatch?.[1] ?? null);
if (channelId) {
return fetchRssFeed(`https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`);
}
if (/^https?:\/\//i.test(trimmed)) {
return fetchRssFeed(trimmed);
}
return { ok: false, message: 'invalid_youtube_source' };
}
export async function fetchRedditFeed(subreddit: string): Promise<FetchResult> {
const name = subreddit.replace(/^r\//i, '').trim();
if (!name) {
return { ok: false, message: 'invalid_subreddit' };
}
try {
const url = `https://www.reddit.com/r/${encodeURIComponent(name)}/new.json?limit=5`;
const response = await fetch(url, {
headers: { 'User-Agent': USER_AGENT }
});
if (!response.ok) {
return { ok: false, message: `HTTP ${response.status}` };
}
const data = (await response.json()) as {
data?: { children?: Array<{ data?: { id?: string; title?: string; permalink?: string } }> };
};
const items: FeedItem[] = [];
for (const child of data.data?.children ?? []) {
const post = child.data;
if (!post?.id || !post.title || !post.permalink) {
continue;
}
items.push({
id: post.id,
title: post.title,
url: post.permalink.startsWith('http')
? post.permalink
: `https://www.reddit.com${post.permalink}`
});
}
return { ok: true, items };
} catch (error) {
return {
ok: false,
message: error instanceof Error ? error.message : 'fetch_failed'
};
}
}
let twitchAppToken: { token: string; expiresAt: number } | null = null;
async function getTwitchAppToken(): Promise<string | null> {
const clientId = env.TWITCH_CLIENT_ID;
const clientSecret = env.TWITCH_CLIENT_SECRET;
if (!clientId || !clientSecret) {
return null;
}
if (twitchAppToken && twitchAppToken.expiresAt > Date.now() + 60_000) {
return twitchAppToken.token;
}
const params = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
grant_type: 'client_credentials'
});
const response = await fetch(`https://id.twitch.tv/oauth2/token?${params.toString()}`, {
method: 'POST'
});
if (!response.ok) {
throw new Error(`twitch_token_http_${response.status}`);
}
const body = (await response.json()) as { access_token?: string; expires_in?: number };
if (!body.access_token) {
throw new Error('twitch_token_missing');
}
twitchAppToken = {
token: body.access_token,
expiresAt: Date.now() + (body.expires_in ?? 3600) * 1000
};
return twitchAppToken.token;
}
export async function fetchTwitchFeed(login: string): Promise<FetchResult> {
const userLogin = login.trim().toLowerCase();
if (!userLogin) {
return { ok: false, message: 'invalid_twitch_login' };
}
const clientId = env.TWITCH_CLIENT_ID;
const clientSecret = env.TWITCH_CLIENT_SECRET;
if (!clientId || !clientSecret) {
return {
ok: false,
skipped: true,
message: 'twitch_credentials_missing'
};
}
try {
const token = await getTwitchAppToken();
if (!token) {
return {
ok: false,
skipped: true,
message: 'twitch_credentials_missing'
};
}
const url = `https://api.twitch.tv/helix/streams?user_login=${encodeURIComponent(userLogin)}`;
const response = await fetch(url, {
headers: {
'Client-ID': clientId,
Authorization: `Bearer ${token}`
}
});
if (!response.ok) {
return { ok: false, message: `HTTP ${response.status}` };
}
const body = (await response.json()) as {
data?: Array<{ id?: string; title?: string; user_login?: string }>;
};
const stream = body.data?.[0];
if (!stream?.id || !stream.title) {
return { ok: true, items: [] };
}
const streamLogin = stream.user_login ?? userLogin;
return {
ok: true,
items: [
{
id: stream.id,
title: stream.title,
url: `https://twitch.tv/${streamLogin}`
}
]
};
} catch (error) {
return {
ok: false,
message: error instanceof Error ? error.message : 'fetch_failed'
};
}
}
export async function fetchFeedItems(type: string, sourceId: string): Promise<FetchResult> {
switch (type) {
case 'RSS':
return fetchRssFeed(sourceId);
case 'YOUTUBE':
return fetchYouTubeFeed(sourceId);
case 'REDDIT':
return fetchRedditFeed(sourceId);
case 'TWITCH':
return fetchTwitchFeed(sourceId);
default:
return { ok: false, message: 'unsupported_type' };
}
}

View File

@@ -0,0 +1,2 @@
export { feedsCommands } from './commands.js';
export { pollAllFeeds } from './service.js';

View File

@@ -0,0 +1,239 @@
import { PermissionFlagsBits, type TextChannel } from 'discord.js';
import type { SocialFeed } from '@prisma/client';
import { applyFeedTemplate, SocialFeedTypeSchema } from '@nexumi/shared';
import { z } from 'zod';
import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js';
import { assertPremiumLimit, PremiumLimitError } from '../../premium.js';
import type { BotContext } from '../../types.js';
import { fetchFeedItems, type FeedItem, type FetchResult } from './fetchers.js';
export const FeedAddSchema = z.object({
type: SocialFeedTypeSchema,
source: z.string().min(1).max(512),
channelId: z.string().min(1),
rolePingId: z.string().min(1).optional(),
template: z.string().min(1).max(2000).optional()
});
export type FeedAddInput = z.infer<typeof FeedAddSchema>;
export class FeedError extends Error {
constructor(public readonly code: string) {
super(code);
this.name = 'FeedError';
}
}
const DEFAULT_TEMPLATE = '{title}\n{url}';
export function normalizeFeedSource(type: z.infer<typeof SocialFeedTypeSchema>, source: string): string {
const trimmed = source.trim();
switch (type) {
case 'TWITCH':
return trimmed.replace(/^@/, '').toLowerCase();
case 'REDDIT':
return trimmed.replace(/^r\//i, '').toLowerCase();
case 'YOUTUBE':
case 'RSS':
return trimmed;
default:
return trimmed;
}
}
export async function addFeed(
prisma: BotContext['prisma'],
guildId: string,
input: FeedAddInput
): Promise<SocialFeed> {
await ensureGuild(prisma, guildId);
const sourceId = normalizeFeedSource(input.type, input.source);
const currentCount = await prisma.socialFeed.count({ where: { guildId } });
try {
await assertPremiumLimit(prisma, guildId, 'feeds', currentCount);
} catch (error) {
if (error instanceof PremiumLimitError) {
throw new FeedError(error.code === 'feature_disabled' ? 'premium_feature' : 'premium_limit');
}
throw error;
}
return prisma.socialFeed.create({
data: {
guildId,
type: input.type,
sourceId,
channelId: input.channelId,
rolePingId: input.rolePingId ?? null,
template: input.template ?? null
}
});
}
export async function removeFeed(
prisma: BotContext['prisma'],
guildId: string,
feedId: string
): Promise<SocialFeed | null> {
const existing = await prisma.socialFeed.findFirst({
where: { id: feedId, guildId }
});
if (!existing) {
return null;
}
await prisma.socialFeed.delete({ where: { id: feedId } });
return existing;
}
export async function listFeeds(prisma: BotContext['prisma'], guildId: string): Promise<SocialFeed[]> {
await ensureGuild(prisma, guildId);
return prisma.socialFeed.findMany({
where: { guildId },
orderBy: { createdAt: 'asc' }
});
}
export async function getFeedById(
prisma: BotContext['prisma'],
guildId: string,
feedId: string
): Promise<SocialFeed | null> {
return prisma.socialFeed.findFirst({
where: { id: feedId, guildId }
});
}
function buildFeedMessage(feed: SocialFeed, item: FeedItem): string {
const body = applyFeedTemplate(feed.template?.trim() || DEFAULT_TEMPLATE, {
title: item.title,
url: item.url
});
if (feed.rolePingId) {
return `<@&${feed.rolePingId}>\n${body}`;
}
return body;
}
async function resolveTextChannel(
context: BotContext,
guildId: string,
channelId: string
): Promise<TextChannel | null> {
const guild = await context.client.guilds.fetch(guildId).catch(() => null);
if (!guild) {
return null;
}
const channel = await guild.channels.fetch(channelId).catch(() => null);
if (!channel?.isTextBased() || channel.isDMBased()) {
return null;
}
const me = guild.members.me;
if (!me?.permissionsIn(channel).has(PermissionFlagsBits.SendMessages)) {
return null;
}
return channel as TextChannel;
}
async function postFeedItem(
context: BotContext,
feed: SocialFeed,
item: FeedItem
): Promise<boolean> {
const channel = await resolveTextChannel(context, feed.guildId, feed.channelId);
if (!channel) {
logger.warn({ feedId: feed.id, channelId: feed.channelId }, 'Feed channel invalid');
return false;
}
await channel.send({ content: buildFeedMessage(feed, item) });
return true;
}
function selectNewItems(items: FeedItem[], lastItemId: string | null): FeedItem[] {
if (items.length === 0) {
return [];
}
if (!lastItemId) {
return [items[0]!];
}
const lastIndex = items.findIndex((item) => item.id === lastItemId);
if (lastIndex === -1) {
return [items[0]!];
}
return items.slice(0, lastIndex).reverse();
}
export async function processFeedFetch(
context: BotContext,
feed: SocialFeed,
result: FetchResult,
options: { forceLatest?: boolean } = {}
): Promise<{ posted: number; skipped?: boolean; message?: string }> {
if (!result.ok) {
return {
posted: 0,
skipped: result.skipped,
message: result.message
};
}
const items = result.items;
if (items.length === 0) {
return { posted: 0 };
}
const candidates = options.forceLatest ? [items[0]!] : selectNewItems(items, feed.lastItemId);
let posted = 0;
for (const item of candidates) {
const sent = await postFeedItem(context, feed, item);
if (sent) {
posted += 1;
}
}
const newestId = items[0]!.id;
if (posted > 0 || feed.lastItemId !== newestId) {
await context.prisma.socialFeed.update({
where: { id: feed.id },
data: { lastItemId: newestId }
});
}
return { posted };
}
export async function pollFeed(context: BotContext, feed: SocialFeed): Promise<void> {
const result = await fetchFeedItems(feed.type, feed.sourceId);
await processFeedFetch(context, feed, result);
}
export async function testFeed(
context: BotContext,
feed: SocialFeed
): Promise<{ posted: number; skipped?: boolean; message?: string }> {
const result = await fetchFeedItems(feed.type, feed.sourceId);
return processFeedFetch(context, feed, result, { forceLatest: true });
}
export async function pollAllFeeds(context: BotContext): Promise<void> {
const feeds = await context.prisma.socialFeed.findMany({
where: { enabled: true }
});
for (const feed of feeds) {
try {
await pollFeed(context, feed);
} catch (error) {
logger.error({ error, feedId: feed.id, guildId: feed.guildId }, 'Feed poll failed');
}
}
}

View File

@@ -0,0 +1,234 @@
import type { ButtonInteraction } from 'discord.js';
import { t } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import {
applyConnect4Move,
applyHangmanGuess,
applyTicTacToeMove,
buildConnect4Components,
buildConnect4Embed,
buildHangmanComponents,
buildHangmanEmbed,
buildTicTacToeComponents,
buildTicTacToeEmbed,
currentConnect4PlayerId,
currentTicTacToePlayerId,
deleteConnect4State,
deleteHangmanState,
deleteTicTacToeState,
deleteTriviaSession,
FUN_BUTTON_PREFIX,
loadConnect4State,
loadHangmanState,
loadTicTacToeState,
loadTriviaSession,
parseConnect4Button,
parseHangmanButton,
parseTicTacToeButton,
parseTriviaButton,
saveConnect4State,
saveHangmanState,
saveTicTacToeState
} from './games.js';
export function isFunButton(customId: string): boolean {
return customId.startsWith(FUN_BUTTON_PREFIX);
}
async function replyNotParticipant(interaction: ButtonInteraction, locale: 'de' | 'en'): Promise<void> {
await interaction.reply({ content: t(locale, 'fun.common.notParticipant'), ephemeral: true });
}
async function replyExpired(interaction: ButtonInteraction, locale: 'de' | 'en'): Promise<void> {
await interaction.reply({ content: t(locale, 'fun.common.expired'), ephemeral: true });
}
async function handleTriviaButton(
interaction: ButtonInteraction,
context: BotContext,
locale: 'de' | 'en'
): Promise<void> {
const parsed = parseTriviaButton(interaction.customId);
if (!parsed) {
await replyExpired(interaction, locale);
return;
}
if (interaction.user.id !== parsed.userId) {
await interaction.reply({ content: t(locale, 'fun.trivia.notOwner'), ephemeral: true });
return;
}
const session = await loadTriviaSession(context, parsed.userId);
if (!session) {
await replyExpired(interaction, locale);
return;
}
const correct = parsed.answerIndex === parsed.correctIndex;
await deleteTriviaSession(context, parsed.userId);
await interaction.update({
embeds: interaction.message.embeds,
components: [],
content: correct ? t(locale, 'fun.trivia.correct') : t(locale, 'fun.trivia.incorrect')
});
}
async function handleTicTacToeButton(
interaction: ButtonInteraction,
context: BotContext,
locale: 'de' | 'en'
): Promise<void> {
const parsed = parseTicTacToeButton(interaction.customId);
if (!parsed) {
await replyExpired(interaction, locale);
return;
}
const state = await loadTicTacToeState(context, parsed.gameId);
if (!state || state.status !== 'active') {
await replyExpired(interaction, locale);
return;
}
if (interaction.user.id !== currentTicTacToePlayerId(state)) {
await replyNotParticipant(interaction, locale);
return;
}
const next = applyTicTacToeMove(state, parsed.cell);
if (next.board[parsed.cell] === state.board[parsed.cell]) {
await interaction.reply({ content: t(locale, 'fun.tictactoe.invalidMove'), ephemeral: true });
return;
}
if (next.status !== 'active') {
await deleteTicTacToeState(context, parsed.gameId);
} else {
await saveTicTacToeState(context, next);
}
await interaction.update({
embeds: [buildTicTacToeEmbed(locale, next)],
components: buildTicTacToeComponents(next)
});
}
async function handleConnect4Button(
interaction: ButtonInteraction,
context: BotContext,
locale: 'de' | 'en'
): Promise<void> {
const parsed = parseConnect4Button(interaction.customId);
if (!parsed) {
await replyExpired(interaction, locale);
return;
}
const state = await loadConnect4State(context, parsed.gameId);
if (!state || state.status !== 'active') {
await replyExpired(interaction, locale);
return;
}
if (interaction.user.id !== currentConnect4PlayerId(state)) {
await replyNotParticipant(interaction, locale);
return;
}
const next = applyConnect4Move(state, parsed.col);
const changed = next.board.some((row, rowIndex) =>
row.some((cell, colIndex) => cell !== state.board[rowIndex]![colIndex])
);
if (!changed) {
await interaction.reply({ content: t(locale, 'fun.connect4.invalidMove'), ephemeral: true });
return;
}
if (next.status !== 'active') {
await deleteConnect4State(context, parsed.gameId);
} else {
await saveConnect4State(context, next);
}
await interaction.update({
embeds: [buildConnect4Embed(locale, next)],
components: buildConnect4Components(next)
});
}
async function handleHangmanButton(
interaction: ButtonInteraction,
context: BotContext,
locale: 'de' | 'en'
): Promise<void> {
const parsed = parseHangmanButton(interaction.customId);
if (!parsed) {
await replyExpired(interaction, locale);
return;
}
const state = await loadHangmanState(context, parsed.gameId);
if (!state) {
await replyExpired(interaction, locale);
return;
}
if (interaction.user.id !== state.userId) {
await replyNotParticipant(interaction, locale);
return;
}
if (parsed.page !== undefined) {
const next = { ...state, letterPage: parsed.page };
await saveHangmanState(context, next);
await interaction.update({
embeds: [buildHangmanEmbed(locale, next)],
components: buildHangmanComponents(next)
});
return;
}
if (!parsed.letter || state.status !== 'active') {
await replyExpired(interaction, locale);
return;
}
const next = applyHangmanGuess(state, parsed.letter);
if (next.status !== 'active') {
await deleteHangmanState(context, parsed.gameId);
} else {
await saveHangmanState(context, next);
}
await interaction.update({
embeds: [buildHangmanEmbed(locale, next)],
components: buildHangmanComponents(next)
});
}
export async function handleFunButton(interaction: ButtonInteraction, context: BotContext): Promise<void> {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const parts = interaction.customId.split(':');
if (parts[1] === 'trivia') {
await handleTriviaButton(interaction, context, locale);
return;
}
if (parts[1] === 'ttt') {
await handleTicTacToeButton(interaction, context, locale);
return;
}
if (parts[1] === 'c4') {
await handleConnect4Button(interaction, context, locale);
return;
}
if (parts[1] === 'hm') {
await handleHangmanButton(interaction, context, locale);
return;
}
await replyExpired(interaction, locale);
}

View File

@@ -0,0 +1,112 @@
import {
SlashCommandBuilder,
type SlashCommandIntegerOption,
type SlashCommandStringOption,
type SlashCommandUserOption
} from 'discord.js';
import {
applyCommandDescription,
applyOptionDescription,
localizedChoice,
type CommandLocaleKey
} from '@nexumi/shared';
function questionOpt(o: SlashCommandStringOption) {
return applyOptionDescription(o.setName('question').setRequired(true), 'fun.8ball.options.question');
}
function sidesOpt(o: SlashCommandIntegerOption) {
return applyOptionDescription(
o.setName('sides').setMinValue(2).setMaxValue(100),
'fun.dice.options.sides'
);
}
function countOpt(o: SlashCommandIntegerOption) {
return applyOptionDescription(
o.setName('count').setMinValue(1).setMaxValue(10),
'fun.dice.options.count'
);
}
function rpsChoiceOpt(o: SlashCommandStringOption) {
return applyOptionDescription(o.setName('choice').setRequired(true), 'fun.rps.options.choice').addChoices(
localizedChoice('fun.rps.choice.rock', 'rock'),
localizedChoice('fun.rps.choice.paper', 'paper'),
localizedChoice('fun.rps.choice.scissors', 'scissors')
);
}
function optionsOpt(o: SlashCommandStringOption) {
return applyOptionDescription(o.setName('options').setRequired(true), 'fun.choose.options.options');
}
function opponentOpt(o: SlashCommandUserOption) {
return applyOptionDescription(o.setName('user').setRequired(true), 'fun.common.options.opponent');
}
export const eightBallCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('8ball'),
'fun.8ball.description'
).addStringOption((o) => questionOpt(o));
export const diceCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('dice'),
'fun.dice.description'
)
.addIntegerOption((o) => sidesOpt(o))
.addIntegerOption((o) => countOpt(o));
export const flipCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('flip'),
'fun.flip.description'
);
export const rpsCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('rps'),
'fun.rps.description'
).addStringOption((o) => rpsChoiceOpt(o));
export const chooseCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('choose'),
'fun.choose.description'
).addStringOption((o) => optionsOpt(o));
export const triviaCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('trivia'),
'fun.trivia.description'
);
export const tictactoeCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('tictactoe'),
'fun.tictactoe.description'
).addUserOption((o) => opponentOpt(o));
export const connect4CommandData = applyCommandDescription(
new SlashCommandBuilder().setName('connect4'),
'fun.connect4.description'
).addUserOption((o) => opponentOpt(o));
export const hangmanCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('hangman'),
'fun.hangman.description'
);
export const memeCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('meme'),
'fun.meme.description'
);
export const catCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('cat'),
'fun.cat.description'
);
export const dogCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('dog'),
'fun.dog.description'
);
export type RpsChoice = 'rock' | 'paper' | 'scissors';
export type FunCommandLocaleKey = CommandLocaleKey;

View File

@@ -0,0 +1,420 @@
import { EmbedBuilder, type ChatInputCommandInteraction } from 'discord.js';
import { EIGHT_BALL_ANSWERS, randomInt, TRIVIA_QUESTIONS, t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import type { RpsChoice } from './command-definitions.js';
import {
catCommandData,
chooseCommandData,
connect4CommandData,
diceCommandData,
dogCommandData,
eightBallCommandData,
flipCommandData,
hangmanCommandData,
memeCommandData,
rpsCommandData,
tictactoeCommandData,
triviaCommandData
} from './command-definitions.js';
import {
buildConnect4Components,
buildConnect4Embed,
buildHangmanComponents,
buildHangmanEmbed,
buildTicTacToeComponents,
buildTicTacToeEmbed,
buildTriviaComponents,
buildTriviaEmbed,
createConnect4Board,
createGameId,
pickHangmanWord,
saveConnect4State,
saveHangmanState,
saveTicTacToeState,
saveTriviaSession
} from './games.js';
import {
assertMediaEnabled,
fetchCatImage,
fetchDogImage,
fetchMeme,
FunMediaError
} from './media.js';
const RPS_CHOICES: RpsChoice[] = ['rock', 'paper', 'scissors'];
function rpsWinner(a: RpsChoice, b: RpsChoice): 'player' | 'bot' | 'draw' {
if (a === b) {
return 'draw';
}
if (
(a === 'rock' && b === 'scissors') ||
(a === 'paper' && b === 'rock') ||
(a === 'scissors' && b === 'paper')
) {
return 'player';
}
return 'bot';
}
async function ensureGuild(
interaction: ChatInputCommandInteraction,
locale: 'de' | 'en'
): Promise<string | null> {
if (!interaction.inGuild() || !interaction.guildId) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return null;
}
return interaction.guildId;
}
function parseChooseOptions(raw: string): string[] {
return raw
.split(',')
.map((part) => part.trim())
.filter((part) => part.length > 0);
}
const eightBallCommand: SlashCommand = {
data: eightBallCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuild(interaction, locale);
if (!guildId) return;
const question = interaction.options.getString('question', true);
const answers = EIGHT_BALL_ANSWERS[locale];
const answer = answers[randomInt(0, answers.length - 1)]!;
await interaction.reply({
content: tf(locale, 'fun.8ball.result', { question, answer }),
ephemeral: false
});
}
};
const diceCommand: SlashCommand = {
data: diceCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuild(interaction, locale);
if (!guildId) return;
const sides = interaction.options.getInteger('sides') ?? 6;
const count = interaction.options.getInteger('count') ?? 1;
const rolls = Array.from({ length: count }, () => randomInt(1, sides));
await interaction.reply({
content: tf(locale, 'fun.dice.result', {
rolls: rolls.join(', '),
sides: String(sides)
}),
ephemeral: false
});
}
};
const flipCommand: SlashCommand = {
data: flipCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuild(interaction, locale);
if (!guildId) return;
const result = Math.random() < 0.5 ? 'heads' : 'tails';
const label = t(locale, `economy.choice.${result}`);
await interaction.reply({
content: tf(locale, 'fun.flip.result', { result: label }),
ephemeral: false
});
}
};
const rpsCommand: SlashCommand = {
data: rpsCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuild(interaction, locale);
if (!guildId) return;
const playerChoice = interaction.options.getString('choice', true) as RpsChoice;
const botChoice = RPS_CHOICES[randomInt(0, RPS_CHOICES.length - 1)]!;
const outcome = rpsWinner(playerChoice, botChoice);
const key =
outcome === 'draw'
? 'fun.rps.draw'
: outcome === 'player'
? 'fun.rps.win'
: 'fun.rps.lose';
await interaction.reply({
content: tf(locale, key, {
player: t(locale, `fun.rps.choice.${playerChoice}`),
bot: t(locale, `fun.rps.choice.${botChoice}`)
}),
ephemeral: false
});
}
};
const chooseCommand: SlashCommand = {
data: chooseCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuild(interaction, locale);
if (!guildId) return;
const raw = interaction.options.getString('options', true);
const options = parseChooseOptions(raw);
if (options.length < 2) {
await interaction.reply({ content: t(locale, 'fun.choose.tooFew'), ephemeral: true });
return;
}
const picked = options[randomInt(0, options.length - 1)]!;
await interaction.reply({
content: tf(locale, 'fun.choose.result', { choice: picked }),
ephemeral: false
});
}
};
const triviaCommand: SlashCommand = {
data: triviaCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuild(interaction, locale);
if (!guildId) return;
const questionIndex = randomInt(0, TRIVIA_QUESTIONS.length - 1);
const question = TRIVIA_QUESTIONS[questionIndex]!;
const correctIndex = question.answer;
await saveTriviaSession(context, {
userId: interaction.user.id,
guildId,
questionIndex,
correctIndex
});
await interaction.reply({
embeds: [buildTriviaEmbed(locale, question, interaction.user.id)],
components: buildTriviaComponents(interaction.user.id, correctIndex, locale, question)
});
}
};
const tictactoeCommand: SlashCommand = {
data: tictactoeCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuild(interaction, locale);
if (!guildId || !interaction.channel) return;
const opponent = interaction.options.getUser('user', true);
if (opponent.id === interaction.user.id) {
await interaction.reply({ content: t(locale, 'fun.common.selfChallenge'), ephemeral: true });
return;
}
if (opponent.bot) {
await interaction.reply({ content: t(locale, 'fun.common.botChallenge'), ephemeral: true });
return;
}
const gameId = createGameId();
const state = {
gameId,
guildId,
channelId: interaction.channel.id,
playerX: interaction.user.id,
playerO: opponent.id,
board: Array.from({ length: 9 }, () => null) as Array<'X' | 'O' | null>,
current: 'X' as const,
status: 'active' as const
};
await saveTicTacToeState(context, state);
await interaction.reply({
content: tf(locale, 'fun.tictactoe.challenge', {
challenger: `<@${interaction.user.id}>`,
opponent: `<@${opponent.id}>`
}),
embeds: [buildTicTacToeEmbed(locale, state)],
components: buildTicTacToeComponents(state)
});
}
};
const connect4Command: SlashCommand = {
data: connect4CommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuild(interaction, locale);
if (!guildId || !interaction.channel) return;
const opponent = interaction.options.getUser('user', true);
if (opponent.id === interaction.user.id) {
await interaction.reply({ content: t(locale, 'fun.common.selfChallenge'), ephemeral: true });
return;
}
if (opponent.bot) {
await interaction.reply({ content: t(locale, 'fun.common.botChallenge'), ephemeral: true });
return;
}
const gameId = createGameId();
const state = {
gameId,
guildId,
channelId: interaction.channel.id,
playerRed: interaction.user.id,
playerYellow: opponent.id,
board: createConnect4Board(),
current: 'R' as const,
status: 'active' as const
};
await saveConnect4State(context, state);
await interaction.reply({
content: tf(locale, 'fun.connect4.challenge', {
challenger: `<@${interaction.user.id}>`,
opponent: `<@${opponent.id}>`
}),
embeds: [buildConnect4Embed(locale, state)],
components: buildConnect4Components(state)
});
}
};
const hangmanCommand: SlashCommand = {
data: hangmanCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuild(interaction, locale);
if (!guildId) return;
const gameId = createGameId();
const state = {
gameId,
guildId,
userId: interaction.user.id,
word: pickHangmanWord(locale),
guessed: [] as string[],
wrongGuesses: 0,
maxWrong: 6,
status: 'active' as const,
letterPage: 0
};
await saveHangmanState(context, state);
await interaction.reply({
embeds: [buildHangmanEmbed(locale, state)],
components: buildHangmanComponents(state)
});
}
};
async function handleMediaError(
interaction: ChatInputCommandInteraction,
locale: 'de' | 'en',
error: unknown
): Promise<void> {
if (error instanceof FunMediaError) {
const key = error.code === 'disabled' ? 'fun.media.disabled' : 'fun.media.fetchFailed';
const content = t(locale, key);
if (interaction.deferred || interaction.replied) {
await interaction.editReply({ content });
return;
}
await interaction.reply({ content, ephemeral: true });
return;
}
throw error;
}
const memeCommand: SlashCommand = {
data: memeCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuild(interaction, locale);
if (!guildId) return;
try {
await assertMediaEnabled(context.redis, guildId);
await interaction.deferReply();
const meme = await fetchMeme();
const embed = new EmbedBuilder()
.setTitle(meme.title)
.setImage(meme.url)
.setColor(0x6366f1);
await interaction.editReply({ embeds: [embed] });
} catch (error) {
await handleMediaError(interaction, locale, error);
}
}
};
const catCommand: SlashCommand = {
data: catCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuild(interaction, locale);
if (!guildId) return;
try {
await assertMediaEnabled(context.redis, guildId);
await interaction.deferReply();
const url = await fetchCatImage();
const embed = new EmbedBuilder()
.setTitle(t(locale, 'fun.cat.title'))
.setImage(url)
.setColor(0x6366f1);
await interaction.editReply({ embeds: [embed] });
} catch (error) {
await handleMediaError(interaction, locale, error);
}
}
};
const dogCommand: SlashCommand = {
data: dogCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuild(interaction, locale);
if (!guildId) return;
try {
await assertMediaEnabled(context.redis, guildId);
await interaction.deferReply();
const url = await fetchDogImage();
const embed = new EmbedBuilder()
.setTitle(t(locale, 'fun.dog.title'))
.setImage(url)
.setColor(0x6366f1);
await interaction.editReply({ embeds: [embed] });
} catch (error) {
await handleMediaError(interaction, locale, error);
}
}
};
export const funCommands: SlashCommand[] = [
eightBallCommand,
diceCommand,
flipCommand,
rpsCommand,
chooseCommand,
triviaCommand,
tictactoeCommand,
connect4Command,
hangmanCommand,
memeCommand,
catCommand,
dogCommand
];

View File

@@ -0,0 +1,26 @@
import type { Redis } from 'ioredis';
import { z } from 'zod';
const FUN_CONFIG_PREFIX = 'fun:config:';
export const FunConfigSchema = z.object({
enabled: z.boolean().default(true),
mediaEnabled: z.boolean().default(true)
});
export type FunConfig = z.infer<typeof FunConfigSchema>;
const DEFAULT_FUN_CONFIG: FunConfig = { enabled: true, mediaEnabled: true };
export async function getFunConfig(redis: Redis, guildId: string): Promise<FunConfig> {
const raw = await redis.get(`${FUN_CONFIG_PREFIX}${guildId}`);
if (!raw) {
return { ...DEFAULT_FUN_CONFIG };
}
const parsed = FunConfigSchema.safeParse(JSON.parse(raw));
return parsed.success ? parsed.data : { ...DEFAULT_FUN_CONFIG };
}
export async function setFunConfig(redis: Redis, guildId: string, config: FunConfig): Promise<void> {
await redis.set(`${FUN_CONFIG_PREFIX}${guildId}`, JSON.stringify(config));
}

View File

@@ -0,0 +1,574 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
type APIEmbed
} from 'discord.js';
import { HANGMAN_WORDS, randomInt, TRIVIA_QUESTIONS, type Locale, tf } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
export const FUN_BUTTON_PREFIX = 'fun:';
export const TRIVIA_TTL_SECONDS = 120;
export const GAME_TTL_SECONDS = 3600;
const TRIVIA_KEY_PREFIX = 'fun:trivia:';
const TTT_KEY_PREFIX = 'fun:ttt:';
const C4_KEY_PREFIX = 'fun:c4:';
const HM_KEY_PREFIX = 'fun:hm:';
export type TicTacToeMark = 'X' | 'O';
export type TicTacToeCell = TicTacToeMark | null;
export type TicTacToeState = {
gameId: string;
guildId: string;
channelId: string;
playerX: string;
playerO: string;
board: TicTacToeCell[];
current: TicTacToeMark;
status: 'active' | 'draw' | 'won';
winner?: TicTacToeMark;
};
export type Connect4Mark = 'R' | 'Y';
export type Connect4Cell = Connect4Mark | null;
export type Connect4State = {
gameId: string;
guildId: string;
channelId: string;
playerRed: string;
playerYellow: string;
board: Connect4Cell[][];
current: Connect4Mark;
status: 'active' | 'draw' | 'won';
winner?: Connect4Mark;
};
export type HangmanState = {
gameId: string;
guildId: string;
userId: string;
word: string;
guessed: string[];
wrongGuesses: number;
maxWrong: number;
status: 'active' | 'won' | 'lost';
letterPage: number;
};
export type TriviaSession = {
userId: string;
guildId: string;
questionIndex: number;
correctIndex: number;
};
const TTT_LINES = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6]
] as const;
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
export function createGameId(): string {
return crypto.randomUUID().replace(/-/g, '').slice(0, 12);
}
export function checkTicTacToeWinner(board: TicTacToeCell[]): TicTacToeMark | null {
for (const [a, b, c] of TTT_LINES) {
const mark = board[a];
if (mark && mark === board[b] && mark === board[c]) {
return mark;
}
}
return null;
}
export function currentTicTacToePlayerId(state: TicTacToeState): string {
return state.current === 'X' ? state.playerX : state.playerO;
}
export function applyTicTacToeMove(state: TicTacToeState, cell: number): TicTacToeState {
if (state.status !== 'active' || state.board[cell] !== null) {
return state;
}
const board = [...state.board];
board[cell] = state.current;
const winner = checkTicTacToeWinner(board);
if (winner) {
return { ...state, board, status: 'won', winner };
}
if (board.every((value) => value !== null)) {
return { ...state, board, status: 'draw' };
}
return { ...state, board, current: state.current === 'X' ? 'O' : 'X' };
}
export function createConnect4Board(): Connect4Cell[][] {
return Array.from({ length: 6 }, () => Array.from({ length: 7 }, () => null));
}
function connect4DropRow(board: Connect4Cell[][], col: number): number {
for (let row = board.length - 1; row >= 0; row -= 1) {
if (board[row]![col] === null) {
return row;
}
}
return -1;
}
function connect4ColumnFull(board: Connect4Cell[][], col: number): boolean {
return board[0]![col] !== null;
}
export function applyConnect4Move(state: Connect4State, col: number): Connect4State {
if (state.status !== 'active' || col < 0 || col >= 7 || connect4ColumnFull(state.board, col)) {
return state;
}
const row = connect4DropRow(state.board, col);
if (row < 0) {
return state;
}
const board = state.board.map((line) => [...line]);
board[row]![col] = state.current;
const winner = checkConnect4Winner(board);
if (winner) {
return { ...state, board, status: 'won', winner };
}
if (board.every((line) => line.every((cell) => cell !== null))) {
return { ...state, board, status: 'draw' };
}
return { ...state, board, current: state.current === 'R' ? 'Y' : 'R' };
}
export function checkConnect4Winner(board: Connect4Cell[][]): Connect4Mark | null {
const directions = [
[0, 1],
[1, 0],
[1, 1],
[1, -1]
] as const;
for (let row = 0; row < 6; row += 1) {
for (let col = 0; col < 7; col += 1) {
const start = board[row]![col];
if (!start) {
continue;
}
for (const [dr, dc] of directions) {
let matches = 1;
for (let step = 1; step < 4; step += 1) {
const next = board[row + dr * step]?.[col + dc * step];
if (next !== start) {
break;
}
matches += 1;
}
if (matches >= 4) {
return start;
}
}
}
}
return null;
}
export function currentConnect4PlayerId(state: Connect4State): string {
return state.current === 'R' ? state.playerRed : state.playerYellow;
}
export function pickHangmanWord(locale: Locale): string {
const words = HANGMAN_WORDS[locale];
return words[randomInt(0, words.length - 1)]!;
}
export function renderHangmanWord(word: string, guessed: string[]): string {
return word
.split('')
.map((letter) => (guessed.includes(letter) ? letter : '⬜'))
.join(' ');
}
export function applyHangmanGuess(state: HangmanState, letter: string): HangmanState {
const normalized = letter.toUpperCase();
if (
state.status !== 'active' ||
normalized.length !== 1 ||
!/^[A-Z]$/.test(normalized) ||
state.guessed.includes(normalized)
) {
return state;
}
const guessed = [...state.guessed, normalized];
const inWord = state.word.includes(normalized);
const wrongGuesses = inWord ? state.wrongGuesses : state.wrongGuesses + 1;
const revealed = state.word.split('').every((char) => guessed.includes(char));
if (revealed) {
return { ...state, guessed, wrongGuesses, status: 'won' };
}
if (wrongGuesses >= state.maxWrong) {
return { ...state, guessed, wrongGuesses, status: 'lost' };
}
return { ...state, guessed, wrongGuesses };
}
async function saveJson(redis: BotContext['redis'], key: string, value: unknown, ttl: number): Promise<void> {
await redis.set(key, JSON.stringify(value), 'EX', ttl);
}
async function loadJson<T>(redis: BotContext['redis'], key: string): Promise<T | null> {
const raw = await redis.get(key);
if (!raw) {
return null;
}
return JSON.parse(raw) as T;
}
export async function saveTriviaSession(context: BotContext, session: TriviaSession): Promise<void> {
await saveJson(context.redis, `${TRIVIA_KEY_PREFIX}${session.userId}`, session, TRIVIA_TTL_SECONDS);
}
export async function loadTriviaSession(context: BotContext, userId: string): Promise<TriviaSession | null> {
return loadJson<TriviaSession>(context.redis, `${TRIVIA_KEY_PREFIX}${userId}`);
}
export async function deleteTriviaSession(context: BotContext, userId: string): Promise<void> {
await context.redis.del(`${TRIVIA_KEY_PREFIX}${userId}`);
}
export async function saveTicTacToeState(context: BotContext, state: TicTacToeState): Promise<void> {
await saveJson(context.redis, `${TTT_KEY_PREFIX}${state.gameId}`, state, GAME_TTL_SECONDS);
}
export async function loadTicTacToeState(context: BotContext, gameId: string): Promise<TicTacToeState | null> {
return loadJson<TicTacToeState>(context.redis, `${TTT_KEY_PREFIX}${gameId}`);
}
export async function deleteTicTacToeState(context: BotContext, gameId: string): Promise<void> {
await context.redis.del(`${TTT_KEY_PREFIX}${gameId}`);
}
export async function saveConnect4State(context: BotContext, state: Connect4State): Promise<void> {
await saveJson(context.redis, `${C4_KEY_PREFIX}${state.gameId}`, state, GAME_TTL_SECONDS);
}
export async function loadConnect4State(context: BotContext, gameId: string): Promise<Connect4State | null> {
return loadJson<Connect4State>(context.redis, `${C4_KEY_PREFIX}${gameId}`);
}
export async function deleteConnect4State(context: BotContext, gameId: string): Promise<void> {
await context.redis.del(`${C4_KEY_PREFIX}${gameId}`);
}
export async function saveHangmanState(context: BotContext, state: HangmanState): Promise<void> {
await saveJson(context.redis, `${HM_KEY_PREFIX}${state.gameId}`, state, GAME_TTL_SECONDS);
}
export async function loadHangmanState(context: BotContext, gameId: string): Promise<HangmanState | null> {
return loadJson<HangmanState>(context.redis, `${HM_KEY_PREFIX}${gameId}`);
}
export async function deleteHangmanState(context: BotContext, gameId: string): Promise<void> {
await context.redis.del(`${HM_KEY_PREFIX}${gameId}`);
}
function tttCellLabel(cell: TicTacToeCell): string {
if (cell === 'X') return '❌';
if (cell === 'O') return '⭕';
return '⬜';
}
export function buildTicTacToeEmbed(locale: Locale, state: TicTacToeState): APIEmbed {
const boardLines = [0, 1, 2]
.map((row) => state.board.slice(row * 3, row * 3 + 3).map(tttCellLabel).join(' '))
.join('\n');
let description = tf(locale, 'fun.tictactoe.board', {
x: `<@${state.playerX}>`,
o: `<@${state.playerO}>`,
board: boardLines
});
if (state.status === 'won' && state.winner) {
const winnerId = state.winner === 'X' ? state.playerX : state.playerO;
description += `\n\n${tf(locale, 'fun.tictactoe.won', { user: `<@${winnerId}>` })}`;
} else if (state.status === 'draw') {
description += `\n\n${tf(locale, 'fun.tictactoe.draw', {})}`;
} else {
description += `\n\n${tf(locale, 'fun.tictactoe.turn', {
user: `<@${currentTicTacToePlayerId(state)}>`
})}`;
}
return { title: tf(locale, 'fun.tictactoe.title', {}), description, color: 0x6366f1 };
}
export function buildTicTacToeComponents(state: TicTacToeState): ActionRowBuilder<ButtonBuilder>[] {
if (state.status !== 'active') {
return [];
}
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
for (let row = 0; row < 3; row += 1) {
const builder = new ActionRowBuilder<ButtonBuilder>();
for (let col = 0; col < 3; col += 1) {
const cell = row * 3 + col;
const mark = state.board[cell];
builder.addComponents(
new ButtonBuilder()
.setCustomId(`${FUN_BUTTON_PREFIX}ttt:${state.gameId}:${cell}`)
.setLabel(mark ?? String(cell + 1))
.setStyle(mark ? ButtonStyle.Secondary : ButtonStyle.Primary)
.setDisabled(mark !== null)
);
}
rows.push(builder);
}
return rows;
}
function c4CellLabel(cell: Connect4Cell): string {
if (cell === 'R') return '🔴';
if (cell === 'Y') return '🟡';
return '⚪';
}
export function buildConnect4Embed(locale: Locale, state: Connect4State): APIEmbed {
const boardText = [...state.board]
.reverse()
.map((row) => row.map(c4CellLabel).join(''))
.join('\n');
let description = tf(locale, 'fun.connect4.board', {
red: `<@${state.playerRed}>`,
yellow: `<@${state.playerYellow}>`,
board: boardText
});
if (state.status === 'won' && state.winner) {
const winnerId = state.winner === 'R' ? state.playerRed : state.playerYellow;
description += `\n\n${tf(locale, 'fun.connect4.won', { user: `<@${winnerId}>` })}`;
} else if (state.status === 'draw') {
description += `\n\n${tf(locale, 'fun.connect4.draw', {})}`;
} else {
description += `\n\n${tf(locale, 'fun.connect4.turn', {
user: `<@${currentConnect4PlayerId(state)}>`
})}`;
}
return { title: tf(locale, 'fun.connect4.title', {}), description, color: 0x6366f1 };
}
export function buildConnect4Components(state: Connect4State): ActionRowBuilder<ButtonBuilder>[] {
if (state.status !== 'active') {
return [];
}
const row = new ActionRowBuilder<ButtonBuilder>();
for (let col = 0; col < 7; col += 1) {
row.addComponents(
new ButtonBuilder()
.setCustomId(`${FUN_BUTTON_PREFIX}c4:${state.gameId}:${col}`)
.setLabel(String(col + 1))
.setStyle(ButtonStyle.Primary)
.setDisabled(connect4ColumnFull(state.board, col))
);
}
return [row];
}
export function buildHangmanEmbed(locale: Locale, state: HangmanState): APIEmbed {
let description = tf(locale, 'fun.hangman.progress', {
word: renderHangmanWord(state.word, state.guessed),
wrong: state.wrongGuesses,
max: state.maxWrong,
guessed: state.guessed.length > 0 ? state.guessed.join(', ') : '—'
});
if (state.status === 'won') {
description += `\n\n${tf(locale, 'fun.hangman.won', { word: state.word })}`;
} else if (state.status === 'lost') {
description += `\n\n${tf(locale, 'fun.hangman.lost', { word: state.word })}`;
}
return { title: tf(locale, 'fun.hangman.title', {}), description, color: 0x6366f1 };
}
function hangmanLettersForPage(state: HangmanState): { letters: string[]; page: number; totalPages: number } {
const remaining = ALPHABET.filter((letter) => !state.guessed.includes(letter));
const lettersPerPage = 24;
const totalPages = Math.max(1, Math.ceil(remaining.length / lettersPerPage));
const page = Math.min(state.letterPage, totalPages - 1);
const start = page * lettersPerPage;
return {
letters: remaining.slice(start, start + lettersPerPage),
page,
totalPages
};
}
export function buildHangmanComponents(state: HangmanState): ActionRowBuilder<ButtonBuilder>[] {
if (state.status !== 'active') {
return [];
}
const { letters, page, totalPages } = hangmanLettersForPage(state);
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
for (let i = 0; i < letters.length; i += 5) {
const chunk = letters.slice(i, i + 5);
const row = new ActionRowBuilder<ButtonBuilder>();
for (const letter of chunk) {
row.addComponents(
new ButtonBuilder()
.setCustomId(`${FUN_BUTTON_PREFIX}hm:${state.gameId}:${letter}`)
.setLabel(letter)
.setStyle(ButtonStyle.Primary)
);
}
rows.push(row);
if (rows.length >= 4) {
break;
}
}
const navRow = new ActionRowBuilder<ButtonBuilder>();
if (totalPages > 1) {
navRow.addComponents(
new ButtonBuilder()
.setCustomId(`${FUN_BUTTON_PREFIX}hm:${state.gameId}:page:${Math.max(0, page - 1)}`)
.setLabel('◀')
.setStyle(ButtonStyle.Secondary)
.setDisabled(page <= 0),
new ButtonBuilder()
.setCustomId(`${FUN_BUTTON_PREFIX}hm:${state.gameId}:page:${Math.min(totalPages - 1, page + 1)}`)
.setLabel('▶')
.setStyle(ButtonStyle.Secondary)
.setDisabled(page >= totalPages - 1)
);
}
if (navRow.components.length > 0) {
rows.push(navRow);
}
return rows.slice(0, 5);
}
export function pickTriviaQuestion(): (typeof TRIVIA_QUESTIONS)[number] {
return TRIVIA_QUESTIONS[randomInt(0, TRIVIA_QUESTIONS.length - 1)]!;
}
export function buildTriviaEmbed(
locale: Locale,
question: (typeof TRIVIA_QUESTIONS)[number],
userId: string
): APIEmbed {
const options = question.options[locale]
.map((option, index) => `${String.fromCharCode(65 + index)}. ${option}`)
.join('\n');
return {
title: tf(locale, 'fun.trivia.title', {}),
description: tf(locale, 'fun.trivia.question', {
question: question.q[locale],
options
}),
footer: { text: tf(locale, 'fun.trivia.footer', { user: `<@${userId}>` }) },
color: 0x6366f1
};
}
export function buildTriviaComponents(
userId: string,
correctIndex: number,
locale: Locale,
question: (typeof TRIVIA_QUESTIONS)[number]
): ActionRowBuilder<ButtonBuilder>[] {
const labels = ['A', 'B', 'C', 'D'];
const row = new ActionRowBuilder<ButtonBuilder>();
for (let index = 0; index < question.options[locale].length; index += 1) {
row.addComponents(
new ButtonBuilder()
.setCustomId(`${FUN_BUTTON_PREFIX}trivia:${userId}:${index}:${correctIndex}`)
.setLabel(`${labels[index]!}. ${question.options[locale][index]!.slice(0, 20)}`)
.setStyle(ButtonStyle.Primary)
);
}
return [row];
}
export function parseTriviaButton(customId: string): {
userId: string;
answerIndex: number;
correctIndex: number;
} | null {
const parts = customId.split(':');
if (parts.length !== 5 || parts[0] !== 'fun' || parts[1] !== 'trivia') {
return null;
}
const userId = parts[2];
const answerIndex = Number(parts[3]);
const correctIndex = Number(parts[4]);
if (!userId || Number.isNaN(answerIndex) || Number.isNaN(correctIndex)) {
return null;
}
return { userId, answerIndex, correctIndex };
}
export function parseTicTacToeButton(customId: string): { gameId: string; cell: number } | null {
const parts = customId.split(':');
if (parts.length !== 4 || parts[0] !== 'fun' || parts[1] !== 'ttt') {
return null;
}
const gameId = parts[2];
const cell = Number(parts[3]);
if (!gameId || Number.isNaN(cell) || cell < 0 || cell > 8) {
return null;
}
return { gameId, cell };
}
export function parseConnect4Button(customId: string): { gameId: string; col: number } | null {
const parts = customId.split(':');
if (parts.length !== 4 || parts[0] !== 'fun' || parts[1] !== 'c4') {
return null;
}
const gameId = parts[2];
const col = Number(parts[3]);
if (!gameId || Number.isNaN(col) || col < 0 || col > 6) {
return null;
}
return { gameId, col };
}
export function parseHangmanButton(
customId: string
): { gameId: string; letter?: string; page?: number } | null {
const parts = customId.split(':');
if (parts.length < 4 || parts[0] !== 'fun' || parts[1] !== 'hm') {
return null;
}
const gameId = parts[2];
if (!gameId) {
return null;
}
if (parts[3] === 'page') {
const page = Number(parts[4]);
if (Number.isNaN(page) || page < 0) {
return null;
}
return { gameId, page };
}
const letter = parts[3]?.toUpperCase();
if (!letter || !/^[A-Z]$/.test(letter)) {
return null;
}
return { gameId, letter };
}

View File

@@ -0,0 +1,2 @@
export { funCommands } from './commands.js';
export { isFunButton, handleFunButton } from './buttons.js';

View File

@@ -0,0 +1,65 @@
import type { Redis } from 'ioredis';
import { getFunConfig } from './config.js';
export class FunMediaError extends Error {
constructor(public readonly code: 'disabled' | 'fetch_failed') {
super(code);
this.name = 'FunMediaError';
}
}
type MemeApiResponse = {
url?: string;
title?: string;
};
type CatApiResponse = Array<{ url?: string }>;
type DogApiResponse = {
message?: string;
status?: string;
};
export async function assertMediaEnabled(redis: Redis, guildId: string): Promise<void> {
const config = await getFunConfig(redis, guildId);
if (!config.mediaEnabled) {
throw new FunMediaError('disabled');
}
}
export async function fetchMeme(): Promise<{ url: string; title: string }> {
const response = await fetch('https://meme-api.com/gimme');
if (!response.ok) {
throw new FunMediaError('fetch_failed');
}
const data = (await response.json()) as MemeApiResponse;
if (!data.url) {
throw new FunMediaError('fetch_failed');
}
return { url: data.url, title: data.title ?? 'Meme' };
}
export async function fetchCatImage(): Promise<string> {
const response = await fetch('https://api.thecatapi.com/v1/images/search');
if (!response.ok) {
throw new FunMediaError('fetch_failed');
}
const data = (await response.json()) as CatApiResponse;
const url = data[0]?.url;
if (!url) {
throw new FunMediaError('fetch_failed');
}
return url;
}
export async function fetchDogImage(): Promise<string> {
const response = await fetch('https://dog.ceo/api/breeds/image/random');
if (!response.ok) {
throw new FunMediaError('fetch_failed');
}
const data = (await response.json()) as DogApiResponse;
if (data.status !== 'success' || !data.message) {
throw new FunMediaError('fetch_failed');
}
return data.message;
}

View File

@@ -0,0 +1,22 @@
import {
applyCommandDescription,
applyOptionDescription
} from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js';
export const privacyCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('privacy'),
'gdpr.privacy.description'
);
export const gdprCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('gdpr'),
'gdpr.command.description'
).addSubcommand((sub) =>
applyCommandDescription(sub.setName('delete'), 'gdpr.delete.description').addBooleanOption((option) =>
applyOptionDescription(
option.setName('confirm').setRequired(true),
'gdpr.delete.options.confirm'
)
)
);

View File

@@ -0,0 +1,77 @@
import { EmbedBuilder } from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { env } from '../../env.js';
import { privacyCommandData, gdprCommandData } from './command-definitions.js';
import { deleteUserPersonalData } from './service.js';
const NEXUMI_COLOR = 0x6366f1;
function privacyUrl(): string {
const base = (env.WEBUI_URL ?? env.PUBLIC_BASE_URL).replace(/\/$/, '');
return `${base}/privacy`;
}
const privacyCommand: SlashCommand = {
data: privacyCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const embed = new EmbedBuilder()
.setTitle(t(locale, 'gdpr.privacy.title'))
.setColor(NEXUMI_COLOR)
.setDescription(
[
t(locale, 'gdpr.privacy.body'),
'',
tf(locale, 'gdpr.privacy.link', { url: privacyUrl() }),
t(locale, 'gdpr.privacy.deleteHint')
].join('\n')
);
await interaction.reply({ embeds: [embed], ephemeral: true });
}
};
const gdprCommand: SlashCommand = {
data: gdprCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const sub = interaction.options.getSubcommand(true);
if (sub !== 'delete') {
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
return;
}
const confirmed = interaction.options.getBoolean('confirm', true);
if (!confirmed) {
await interaction.reply({
content: t(locale, 'gdpr.delete.needConfirm'),
ephemeral: true
});
return;
}
await interaction.deferReply({ ephemeral: true });
const summary = await deleteUserPersonalData(
context.prisma,
interaction.user.id,
interaction.guildId
);
const embed = new EmbedBuilder()
.setTitle(t(locale, 'gdpr.delete.doneTitle'))
.setColor(NEXUMI_COLOR)
.setDescription(
tf(locale, 'gdpr.delete.doneBody', {
warnings: summary.warnings,
levels: summary.memberLevels,
economy: summary.memberEconomies,
cases: summary.casesAnonymized
})
);
await interaction.editReply({ embeds: [embed] });
}
};
export const gdprCommands: SlashCommand[] = [privacyCommand, gdprCommand];

View File

@@ -0,0 +1,2 @@
export { gdprCommands } from './commands.js';
export { deleteUserPersonalData } from './service.js';

View File

@@ -0,0 +1,96 @@
import type { PrismaClient } from '@prisma/client';
export type GdprDeletionSummary = {
warnings: number;
modNotes: number;
memberLevels: number;
memberEconomies: number;
inventoryItems: number;
birthdays: number;
afkStatuses: number;
reminders: number;
inviteStats: number;
casesAnonymized: number;
userPremium: number;
userRecord: number;
};
/**
* Deletes or anonymizes personal data for a Discord user across guilds.
* Moderation case history is anonymized (targetUserId cleared to a placeholder)
* so server audit integrity is preserved.
*/
export async function deleteUserPersonalData(
prisma: PrismaClient,
userId: string,
guildId: string | null
): Promise<GdprDeletionSummary> {
const scope = guildId ? { guildId, userId } : { userId };
const caseScope = guildId
? { guildId, targetUserId: userId }
: { targetUserId: userId };
const [
warnings,
modNotes,
memberLevels,
memberEconomies,
inventoryItems,
birthdays,
afkStatuses,
reminders,
inviteStats,
inviteJoins,
casesAnonymized,
userPremium
] = await prisma.$transaction([
prisma.warning.deleteMany({ where: scope }),
prisma.modNote.deleteMany({ where: scope }),
prisma.memberLevel.deleteMany({ where: scope }),
prisma.memberEconomy.deleteMany({ where: scope }),
prisma.inventoryItem.deleteMany({ where: scope }),
prisma.birthday.deleteMany({ where: scope }),
prisma.afkStatus.deleteMany({ where: scope }),
prisma.reminder.deleteMany({ where: { userId, ...(guildId ? { guildId } : {}) } }),
prisma.inviteStat.deleteMany({
where: guildId ? { guildId, inviterId: userId } : { inviterId: userId }
}),
prisma.inviteJoin.deleteMany({ where: scope }),
prisma.case.updateMany({
where: caseScope,
data: { targetUserId: '0', reason: '[redacted GDPR deletion]' }
}),
prisma.userPremiumAssignment.deleteMany({ where: { userId } })
]);
let userRecord = 0;
if (!guildId) {
const deleted = await prisma.user.deleteMany({ where: { id: userId } });
userRecord = deleted.count;
}
const summary: GdprDeletionSummary = {
warnings: warnings.count,
modNotes: modNotes.count,
memberLevels: memberLevels.count,
memberEconomies: memberEconomies.count,
inventoryItems: inventoryItems.count,
birthdays: birthdays.count,
afkStatuses: afkStatuses.count,
reminders: reminders.count,
inviteStats: inviteStats.count + inviteJoins.count,
casesAnonymized: casesAnonymized.count,
userPremium: userPremium.count,
userRecord
};
await prisma.gdprDeletionLog.create({
data: {
userId,
guildId,
summary
}
});
return summary;
}

View File

@@ -0,0 +1,42 @@
import type { ButtonInteraction } from 'discord.js';
import { t } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { GiveawayError, joinGiveaway } from './service.js';
export const GIVEAWAY_JOIN_PREFIX = 'gw:join:';
export function joinButtonId(giveawayId: string): string {
return `${GIVEAWAY_JOIN_PREFIX}${giveawayId}`;
}
export function isGiveawayButton(customId: string): boolean {
return customId.startsWith(GIVEAWAY_JOIN_PREFIX);
}
export async function handleGiveawayButton(
interaction: ButtonInteraction,
context: BotContext
): Promise<void> {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const giveawayId = interaction.customId.slice(GIVEAWAY_JOIN_PREFIX.length);
if (!giveawayId || !interaction.guildId) {
await interaction.reply({ content: t(locale, 'giveaway.error.not_found'), ephemeral: true });
return;
}
try {
await joinGiveaway(context, giveawayId, interaction.user.id, locale);
await interaction.reply({ content: t(locale, 'giveaway.joined'), ephemeral: true });
} catch (error) {
if (error instanceof GiveawayError) {
await interaction.reply({
content: t(locale, `giveaway.error.${error.code}`),
ephemeral: true
});
return;
}
throw error;
}
}

View File

@@ -0,0 +1,62 @@
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
export const giveawayCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('giveaway')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addSubcommand((s) =>
applyCommandDescription(s.setName('start'), 'giveaway.start.description')
.addStringOption((o) =>
applyOptionDescription(o.setName('duration').setRequired(true), 'giveaway.start.options.duration')
)
.addIntegerOption((o) =>
applyOptionDescription(
o.setName('winners').setRequired(true).setMinValue(1).setMaxValue(25),
'giveaway.start.options.winners'
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('prize').setRequired(true), 'giveaway.start.options.prize')
)
.addRoleOption((o) =>
applyOptionDescription(o.setName('role'), 'giveaway.start.options.role')
)
.addIntegerOption((o) =>
applyOptionDescription(
o.setName('level').setMinValue(0).setMaxValue(500),
'giveaway.start.options.level'
)
)
.addIntegerOption((o) =>
applyOptionDescription(
o.setName('member_days').setMinValue(0).setMaxValue(3650),
'giveaway.start.options.member_days'
)
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('end'), 'giveaway.end.description').addStringOption((o) =>
applyOptionDescription(o.setName('id').setRequired(true), 'giveaway.common.options.id')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('reroll'), 'giveaway.reroll.description').addStringOption(
(o) => applyOptionDescription(o.setName('id').setRequired(true), 'giveaway.common.options.id')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('list'), 'giveaway.list.description')
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('delete'), 'giveaway.delete.description').addStringOption(
(o) => applyOptionDescription(o.setName('id').setRequired(true), 'giveaway.common.options.id')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('pause'), 'giveaway.pause.description').addStringOption(
(o) => applyOptionDescription(o.setName('id').setRequired(true), 'giveaway.common.options.id')
)
),
'giveaway.description'
);

View File

@@ -0,0 +1,194 @@
import { PermissionFlagsBits } from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import { parseDuration } from '../moderation/duration.js';
import { giveawayCommandData } from './command-definitions.js';
import {
cancelGiveawayJob,
deleteGiveaway,
endGiveaway,
GiveawayError,
listActiveGiveaways,
pauseGiveaway,
rerollGiveaway,
startGiveaway
} from './service.js';
async function ensureManageGuild(
interaction: Parameters<SlashCommand['execute']>[0],
locale: 'de' | 'en'
): Promise<boolean> {
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return false;
}
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 = {
data: giveawayCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
const sub = interaction.options.getSubcommand();
if (sub === 'start') {
if (!(await ensureManageGuild(interaction, locale))) {
return;
}
const durationInput = interaction.options.getString('duration', true);
let durationMs: number;
try {
durationMs = parseDuration(durationInput);
} catch {
await interaction.reply({
content: t(locale, 'giveaway.error.invalid_duration'),
ephemeral: true
});
return;
}
if (durationMs < 60_000) {
await interaction.reply({
content: t(locale, 'giveaway.error.duration_too_short'),
ephemeral: true
});
return;
}
const channel = interaction.channel;
if (!channel?.isTextBased() || channel.isDMBased()) {
await interaction.reply({
content: t(locale, 'giveaway.error.invalid_channel'),
ephemeral: true
});
return;
}
await interaction.deferReply({ ephemeral: true });
const giveaway = await startGiveaway(
context,
{
guildId: interaction.guildId!,
channelId: channel.id,
hostId: interaction.user.id,
prize: interaction.options.getString('prize', true),
winnerCount: interaction.options.getInteger('winners', true),
durationMs,
requiredRoleId: interaction.options.getRole('role')?.id,
requiredLevel: interaction.options.getInteger('level'),
requiredMemberDays: interaction.options.getInteger('member_days')
},
locale
);
await interaction.editReply({
content: tf(locale, 'giveaway.started', { id: giveaway.id })
});
return;
}
if (!(await ensureManageGuild(interaction, locale))) {
return;
}
try {
if (sub === 'end') {
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 interaction.editReply({ content: t(locale, 'giveaway.ended') });
return;
}
if (sub === 'reroll') {
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);
await interaction.editReply({
content: tf(locale, 'giveaway.rerolled', {
winners: updated.winners.map((w) => `<@${w}>`).join(', ') || '—'
})
});
return;
}
if (sub === 'list') {
const active = await listActiveGiveaways(context, interaction.guildId!);
if (active.length === 0) {
await interaction.reply({ content: t(locale, 'giveaway.list.empty'), ephemeral: true });
return;
}
const lines = active.map(
(g) =>
`\`${g.id}\` — **${g.prize}** (${g.entrants.length} ${t(locale, 'giveaway.list.entrants')}, <t:${Math.floor(g.endsAt.getTime() / 1000)}:R>)`
);
await interaction.reply({
content: `${t(locale, 'giveaway.list.header')}\n${lines.join('\n')}`,
ephemeral: true
});
return;
}
if (sub === 'delete') {
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 interaction.reply({ content: t(locale, 'giveaway.deleted'), ephemeral: true });
return;
}
if (sub === 'pause') {
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);
await interaction.reply({
content: t(locale, updated.paused ? 'giveaway.paused' : 'giveaway.resumed'),
ephemeral: true
});
}
} catch (error) {
if (error instanceof GiveawayError) {
await replyEphemeral(interaction, t(locale, `giveaway.error.${error.code}`));
return;
}
throw error;
}
}
};
export const giveawayCommands = [giveawayCommand];

View File

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

View File

@@ -0,0 +1,565 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
EmbedBuilder,
type Guild,
type GuildMember,
type TextChannel
} from 'discord.js';
import { pickRandomWinners, t, tf } from '@nexumi/shared';
import type { Giveaway } from '@prisma/client';
import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js';
import { giveawayQueue } from '../../queues.js';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { joinButtonId } from './buttons.js';
export class GiveawayError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
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> {
await cancelGiveawayJob(giveawayId);
const delay = Math.max(0, endsAt.getTime() - Date.now());
await giveawayQueue.add(
'giveawayEnd',
{ giveawayId },
{
delay,
jobId: giveawayEndJobId(giveawayId),
removeOnComplete: true,
removeOnFail: 50
}
);
}
function requirementLines(locale: 'de' | 'en', giveaway: Giveaway): string[] {
const lines: string[] = [];
if (giveaway.requiredRoleId) {
lines.push(tf(locale, 'giveaway.requirement.role', { role: `<@&${giveaway.requiredRoleId}>` }));
}
if (giveaway.requiredLevel !== null && giveaway.requiredLevel !== undefined) {
lines.push(tf(locale, 'giveaway.requirement.level', { level: giveaway.requiredLevel }));
}
if (giveaway.requiredMemberDays !== null && giveaway.requiredMemberDays !== undefined) {
lines.push(
tf(locale, 'giveaway.requirement.memberDays', { days: giveaway.requiredMemberDays })
);
}
return lines;
}
export function buildGiveawayEmbed(locale: 'de' | 'en', giveaway: Giveaway): EmbedBuilder {
const ended = giveaway.endedAt !== null;
const endsAtUnix = Math.floor(giveaway.endsAt.getTime() / 1000);
// Discord does not parse <t:…> timestamps in embed footers — only in
// description/fields/content. Keep relative + absolute for clarity.
const endsAtText = `<t:${endsAtUnix}:R> (<t:${endsAtUnix}:f>)`;
const embed = new EmbedBuilder()
.setTitle(t(locale, 'giveaway.embed.title'))
.setColor(ended ? 0x6b7280 : 0x6366f1)
.addFields(
{ name: t(locale, 'giveaway.embed.prize'), value: giveaway.prize, inline: true },
{
name: t(locale, 'giveaway.embed.winners'),
value: String(giveaway.winnerCount),
inline: true
},
{
name: t(locale, 'giveaway.embed.entrants'),
value: String(giveaway.entrants.length),
inline: true
}
);
if (ended) {
embed.setDescription(
giveaway.winners.length > 0
? tf(locale, 'giveaway.embed.endedWinners', {
winners: giveaway.winners.map((id) => `<@${id}>`).join(', ')
})
: t(locale, 'giveaway.embed.endedNoWinners')
);
embed.setFooter({ text: t(locale, 'giveaway.embed.ended') });
} else if (giveaway.paused) {
embed.setDescription(
`${t(locale, 'giveaway.embed.paused')}\n\n${tf(locale, 'giveaway.embed.endsAt', {
time: endsAtText
})}`
);
} else {
embed.setDescription(
`${t(locale, 'giveaway.embed.active')}\n\n${tf(locale, 'giveaway.embed.endsAt', {
time: endsAtText
})}`
);
}
const requirements = requirementLines(locale, giveaway);
if (requirements.length > 0) {
embed.addFields({
name: t(locale, 'giveaway.embed.requirements'),
value: requirements.join('\n')
});
}
embed.addFields({
name: t(locale, 'giveaway.embed.host'),
value: `<@${giveaway.hostId}>`,
inline: true
});
return embed;
}
export function buildGiveawayComponents(
locale: 'de' | 'en',
giveaway: Giveaway
): ActionRowBuilder<ButtonBuilder>[] {
if (giveaway.endedAt) {
return [];
}
return [
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(joinButtonId(giveaway.id))
.setLabel(t(locale, 'giveaway.button.join'))
.setStyle(ButtonStyle.Primary)
.setDisabled(giveaway.paused)
)
];
}
async function updateGiveawayMessage(
context: BotContext,
giveaway: Giveaway,
locale: 'de' | 'en'
): Promise<void> {
if (!giveaway.messageId) {
return;
}
try {
const channel = (await context.client.channels.fetch(giveaway.channelId)) as TextChannel;
const message = await channel.messages.fetch(giveaway.messageId);
await message.edit({
embeds: [buildGiveawayEmbed(locale, giveaway)],
components: buildGiveawayComponents(locale, giveaway)
});
} catch (error) {
logger.warn({ error, giveawayId: giveaway.id }, 'Failed to update giveaway message');
}
}
export async function checkGiveawayRequirements(
context: BotContext,
guild: Guild,
member: GuildMember,
giveaway: Giveaway
): Promise<string | null> {
if (giveaway.requiredRoleId && !member.roles.cache.has(giveaway.requiredRoleId)) {
return 'missing_role';
}
if (giveaway.requiredLevel !== null && giveaway.requiredLevel !== undefined) {
const levelRow = await context.prisma.memberLevel.findUnique({
where: { guildId_userId: { guildId: guild.id, userId: member.id } }
});
const level = levelRow?.level ?? 0;
if (level < giveaway.requiredLevel) {
return 'insufficient_level';
}
}
if (giveaway.requiredMemberDays !== null && giveaway.requiredMemberDays !== undefined) {
const joinedAt = member.joinedAt;
const days = joinedAt
? Math.floor((Date.now() - joinedAt.getTime()) / 86_400_000)
: 0;
if (days < giveaway.requiredMemberDays) {
return 'insufficient_member_days';
}
}
return null;
}
export async function startGiveaway(
context: BotContext,
params: {
guildId: string;
channelId: string;
hostId: string;
prize: string;
winnerCount: number;
durationMs: number;
requiredRoleId?: string | null;
requiredLevel?: number | null;
requiredMemberDays?: number | null;
},
locale: 'de' | 'en'
): Promise<Giveaway> {
await ensureGuild(context.prisma, params.guildId);
const endsAt = new Date(Date.now() + params.durationMs);
const giveaway = await context.prisma.giveaway.create({
data: {
guildId: params.guildId,
channelId: params.channelId,
prize: params.prize,
winnerCount: params.winnerCount,
endsAt,
requiredRoleId: params.requiredRoleId ?? null,
requiredLevel: params.requiredLevel ?? null,
requiredMemberDays: params.requiredMemberDays ?? null,
hostId: params.hostId
}
});
const channel = (await context.client.channels.fetch(params.channelId)) as TextChannel;
const message = await channel.send({
embeds: [buildGiveawayEmbed(locale, giveaway)],
components: buildGiveawayComponents(locale, giveaway)
});
const updated = await context.prisma.giveaway.update({
where: { id: giveaway.id },
data: { messageId: message.id }
});
await scheduleGiveawayEnd(updated.id, updated.endsAt);
return updated;
}
export async function joinGiveaway(
context: BotContext,
giveawayId: string,
userId: string,
locale: 'de' | 'en'
): Promise<Giveaway> {
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
if (!giveaway || giveaway.endedAt) {
throw new GiveawayError('not_found');
}
if (giveaway.paused) {
throw new GiveawayError('paused');
}
if (giveaway.entrants.includes(userId)) {
throw new GiveawayError('already_joined');
}
const guild = await context.client.guilds.fetch(giveaway.guildId);
const member = await guild.members.fetch(userId).catch(() => null);
if (!member) {
throw new GiveawayError('member_not_found');
}
const requirementError = await checkGiveawayRequirements(context, guild, member, giveaway);
if (requirementError) {
throw new GiveawayError(requirementError);
}
const updated = await context.prisma.giveaway.update({
where: { id: giveawayId },
data: { entrants: { push: userId } }
});
await updateGiveawayMessage(context, updated, locale);
return updated;
}
async function filterEligibleEntrants(
context: BotContext,
guild: Guild,
giveaway: Giveaway
): Promise<string[]> {
const eligible: string[] = [];
for (const userId of giveaway.entrants) {
const member = await guild.members.fetch(userId).catch(() => null);
if (!member) {
continue;
}
const error = await checkGiveawayRequirements(context, guild, member, giveaway);
if (!error) {
eligible.push(userId);
}
}
return eligible;
}
async function dmWinners(
context: BotContext,
giveaway: Giveaway,
winnerIds: string[],
locale: 'de' | 'en'
): Promise<void> {
for (const userId of winnerIds) {
try {
const user = await context.client.users.fetch(userId);
await user.send(
tf(locale, 'giveaway.dm.won', {
prize: giveaway.prize,
guild: (await context.client.guilds.fetch(giveaway.guildId)).name
})
);
} catch (error) {
logger.warn({ error, userId, giveawayId: giveaway.id }, 'Failed to DM giveaway winner');
}
}
}
/**
* 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 } });
if (!giveaway) {
throw new GiveawayError('not_found');
}
if (giveaway.endedAt) {
throw new GiveawayError('already_ended');
}
const locale = await getGuildLocale(context.prisma, giveaway.guildId);
const guild = await context.client.guilds.fetch(giveaway.guildId);
const eligible = await filterEligibleEntrants(context, guild, giveaway);
const winners = pickRandomWinners(eligible, giveaway.winnerCount);
const endedAt = new Date();
const claimed = await context.prisma.giveaway.updateMany({
where: { id: giveawayId, endedAt: null },
data: {
endedAt,
winners,
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);
if (winners.length > 0) {
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(
context: BotContext,
giveawayId: string,
locale: 'de' | 'en'
): Promise<Giveaway> {
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
if (!giveaway || !giveaway.endedAt) {
throw new GiveawayError('not_ended');
}
const guild = await context.client.guilds.fetch(giveaway.guildId);
const eligible = (await filterEligibleEntrants(context, guild, giveaway)).filter(
(id) => !giveaway.winners.includes(id)
);
const newWinners = pickRandomWinners(eligible, giveaway.winnerCount);
const updated = await context.prisma.giveaway.update({
where: { id: giveawayId },
data: { winners: newWinners }
});
await updateGiveawayMessage(context, updated, locale);
if (newWinners.length > 0) {
await dmWinners(context, updated, newWinners, locale);
}
return updated;
}
export async function pauseGiveaway(
context: BotContext,
giveawayId: string,
locale: 'de' | 'en',
forcePaused?: boolean
): Promise<Giveaway> {
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
if (!giveaway || giveaway.endedAt) {
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({
where: { id: giveawayId },
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);
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> {
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
if (!giveaway) {
throw new GiveawayError('not_found');
}
await cancelGiveawayJob(giveawayId);
if (giveaway.messageId) {
try {
const channel = (await context.client.channels.fetch(giveaway.channelId)) as TextChannel;
const message = await channel.messages.fetch(giveaway.messageId);
await message.delete();
} catch {
// Message may already be gone
}
}
await context.prisma.giveaway.delete({ where: { id: giveawayId } });
}
/**
* Entry point for the `giveawayCreate` BullMQ job, enqueued by the WebUI
* dashboard (which has no discord.js Client to post the giveaway message
* itself). Resolves the guild's locale and delegates to {@link startGiveaway}
* so dashboard-created giveaways behave identically to `/giveaway start`.
*/
export async function runGiveawayCreateJob(
context: BotContext,
params: {
guildId: string;
channelId: string;
hostId: string;
prize: string;
winnerCount: number;
durationMs: number;
requiredRoleId?: string | null;
requiredLevel?: number | null;
requiredMemberDays?: number | null;
}
): Promise<{ id: string }> {
const locale = await getGuildLocale(context.prisma, params.guildId);
const giveaway = await startGiveaway(context, params, locale);
return { id: giveaway.id };
}
export async function listActiveGiveaways(
context: BotContext,
guildId: string
): Promise<Giveaway[]> {
return context.prisma.giveaway.findMany({
where: { guildId, endedAt: null },
orderBy: { endsAt: 'asc' }
});
}

View File

@@ -0,0 +1,33 @@
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
export const backupCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('backup')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addSubcommand((s) =>
applyCommandDescription(s.setName('create'), 'guildbackup.create.description').addStringOption(
(o) => applyOptionDescription(o.setName('name'), 'guildbackup.create.options.name')
)
)
.addSubcommand((s) => applyCommandDescription(s.setName('list'), 'guildbackup.list.description'))
.addSubcommand((s) =>
applyCommandDescription(s.setName('info'), 'guildbackup.info.description').addStringOption(
(o) =>
applyOptionDescription(o.setName('id').setRequired(true), 'guildbackup.common.options.id')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('delete'), 'guildbackup.delete.description').addStringOption(
(o) =>
applyOptionDescription(o.setName('id').setRequired(true), 'guildbackup.common.options.id')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('restore'), 'guildbackup.restore.description').addStringOption(
(o) =>
applyOptionDescription(o.setName('id').setRequired(true), 'guildbackup.common.options.id')
)
),
'guildbackup.description'
);

View File

@@ -0,0 +1,209 @@
import { PermissionFlagsBits } from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import { backupCommandData } from './command-definitions.js';
import {
createGuildBackup,
ensureBotCanRestore,
getGuildBackup,
GuildBackupError,
listGuildBackups,
parseBackupPayload
} from './service.js';
import { promptDeleteConfirmation, promptRestoreConfirmation } from './confirmations.js';
async function ensureManageGuild(
interaction: Parameters<SlashCommand['execute']>[0],
locale: 'de' | 'en'
): Promise<boolean> {
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return false;
}
return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale);
}
function defaultBackupName(): string {
return `backup-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}`;
}
const backupCommand: SlashCommand = {
data: backupCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
const sub = interaction.options.getSubcommand();
const guild = interaction.guild!;
if (sub === 'create') {
if (!(await ensureManageGuild(interaction, locale))) {
return;
}
const name = interaction.options.getString('name')?.trim() || defaultBackupName();
if (name.length < 1 || name.length > 100) {
await interaction.reply({
content: t(locale, 'guildbackup.error.invalid_name'),
ephemeral: true
});
return;
}
await interaction.deferReply({ ephemeral: true });
try {
const backup = await createGuildBackup(context, guild, name, interaction.user.id);
await interaction.editReply({
content: tf(locale, 'guildbackup.created', { id: backup.id, name: backup.name })
});
} catch (error) {
if (error instanceof GuildBackupError) {
await interaction.editReply({
content: t(locale, `guildbackup.error.${error.code}`)
});
return;
}
throw error;
}
return;
}
if (sub === 'list') {
if (!(await ensureManageGuild(interaction, locale))) {
return;
}
const backups = await listGuildBackups(context, guild.id);
if (backups.length === 0) {
await interaction.reply({
content: t(locale, 'guildbackup.list.empty'),
ephemeral: true
});
return;
}
const lines = backups.map((backup) =>
tf(locale, 'guildbackup.list.line', {
id: backup.id,
name: backup.name,
date: backup.createdAt.toISOString().slice(0, 10)
})
);
await interaction.reply({
content: `${t(locale, 'guildbackup.list.header')}\n${lines.join('\n')}`,
ephemeral: true
});
return;
}
if (sub === 'info') {
if (!(await ensureManageGuild(interaction, locale))) {
return;
}
const backupId = interaction.options.getString('id', true);
const backup = await getGuildBackup(context, guild.id, backupId);
if (!backup) {
await interaction.reply({
content: t(locale, 'guildbackup.error.not_found'),
ephemeral: true
});
return;
}
let roleCount = 0;
let channelCount = 0;
try {
const payload = parseBackupPayload(backup.payload);
roleCount = payload.roles.length;
channelCount = payload.channels.length;
} catch {
await interaction.reply({
content: t(locale, 'guildbackup.error.invalid_payload'),
ephemeral: true
});
return;
}
await interaction.reply({
content: [
tf(locale, 'guildbackup.info.id', { id: backup.id }),
tf(locale, 'guildbackup.info.name', { name: backup.name }),
tf(locale, 'guildbackup.info.created', { date: backup.createdAt.toISOString() }),
tf(locale, 'guildbackup.info.createdBy', { userId: backup.createdById }),
tf(locale, 'guildbackup.info.roles', { count: roleCount }),
tf(locale, 'guildbackup.info.channels', { count: channelCount }),
t(locale, 'guildbackup.info.limitations')
].join('\n'),
ephemeral: true
});
return;
}
if (sub === 'delete') {
if (!(await ensureManageGuild(interaction, locale))) {
return;
}
const backupId = interaction.options.getString('id', true);
const backup = await getGuildBackup(context, guild.id, backupId);
if (!backup) {
await interaction.reply({
content: t(locale, 'guildbackup.error.not_found'),
ephemeral: true
});
return;
}
await promptDeleteConfirmation(interaction, context, locale, backupId);
return;
}
if (sub === 'restore') {
if (guild.ownerId !== interaction.user.id) {
await interaction.reply({
content: t(locale, 'guildbackup.error.owner_only'),
ephemeral: true
});
return;
}
if (!ensureBotCanRestore(guild)) {
await interaction.reply({
content: t(locale, 'guildbackup.error.bot_missing_permissions'),
ephemeral: true
});
return;
}
const backupId = interaction.options.getString('id', true);
const backup = await getGuildBackup(context, guild.id, backupId);
if (!backup) {
await interaction.reply({
content: t(locale, 'guildbackup.error.not_found'),
ephemeral: true
});
return;
}
try {
parseBackupPayload(backup.payload);
} catch {
await interaction.reply({
content: t(locale, 'guildbackup.error.invalid_payload'),
ephemeral: true
});
return;
}
await promptRestoreConfirmation(interaction, context, locale, backupId);
}
}
};
export const guildBackupCommands = [backupCommand];

View File

@@ -0,0 +1,296 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
type ButtonInteraction,
type ChatInputCommandInteraction
} from 'discord.js';
import { randomUUID } from 'node:crypto';
import { type Locale, t, tf } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import {
deleteConfirmation,
getConfirmation,
setConfirmation,
takeConfirmation
} from '../../confirmation-store.js';
import {
deleteGuildBackup,
enqueueGuildBackupRestore,
getGuildBackup,
GuildBackupError,
parseBackupPayload,
restoreGuildBackup,
shouldQueueRestore
} from './service.js';
const DELETE_CONFIRM_PREFIX = 'gbackup:confirm:delete:';
const RESTORE_CONFIRM_PREFIX = 'gbackup:confirm:restore:';
const CANCEL_PREFIX = 'gbackup:cancel:';
const NAMESPACE = 'guildbackup';
const TTL_SECONDS = 120;
type PendingDelete = {
action: 'delete';
backupId: string;
userId: string;
guildId: string;
locale: Locale;
};
type PendingRestore = {
action: 'restore';
step: 1 | 2;
backupId: string;
userId: string;
guildId: string;
locale: Locale;
};
type PendingEntry = PendingDelete | PendingRestore;
function buildRows(token: string, locale: Locale, confirmPrefix: string): ActionRowBuilder<ButtonBuilder>[] {
return [
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(`${confirmPrefix}${token}`)
.setLabel(t(locale, 'guildbackup.confirm.button.confirm'))
.setStyle(ButtonStyle.Danger),
new ButtonBuilder()
.setCustomId(`${CANCEL_PREFIX}${token}`)
.setLabel(t(locale, 'guildbackup.confirm.button.cancel'))
.setStyle(ButtonStyle.Secondary)
)
];
}
export async function promptDeleteConfirmation(
interaction: ChatInputCommandInteraction,
context: BotContext,
locale: Locale,
backupId: string
): Promise<void> {
const token = randomUUID();
await setConfirmation(
context.redis,
NAMESPACE,
token,
{
action: 'delete',
backupId,
userId: interaction.user.id,
guildId: interaction.guildId!,
locale
} satisfies PendingDelete,
TTL_SECONDS
);
await interaction.reply({
content: tf(locale, 'guildbackup.confirm.delete', { id: backupId }),
components: buildRows(token, locale, DELETE_CONFIRM_PREFIX),
ephemeral: true
});
}
export async function promptRestoreConfirmation(
interaction: ChatInputCommandInteraction,
context: BotContext,
locale: Locale,
backupId: string
): Promise<void> {
const token = randomUUID();
await setConfirmation(
context.redis,
NAMESPACE,
token,
{
action: 'restore',
step: 1,
backupId,
userId: interaction.user.id,
guildId: interaction.guildId!,
locale
} satisfies PendingRestore,
TTL_SECONDS
);
await interaction.reply({
content: tf(locale, 'guildbackup.confirm.restore.step1', { id: backupId }),
components: buildRows(token, locale, RESTORE_CONFIRM_PREFIX),
ephemeral: true
});
}
export async function handleGuildBackupButton(
interaction: ButtonInteraction,
context: BotContext
): Promise<void> {
const customId = interaction.customId;
const isDeleteConfirm = customId.startsWith(DELETE_CONFIRM_PREFIX);
const isRestoreConfirm = customId.startsWith(RESTORE_CONFIRM_PREFIX);
const isCancel = customId.startsWith(CANCEL_PREFIX);
if (!isDeleteConfirm && !isRestoreConfirm && !isCancel) {
return;
}
const token = customId.slice(
isDeleteConfirm
? DELETE_CONFIRM_PREFIX.length
: isRestoreConfirm
? RESTORE_CONFIRM_PREFIX.length
: CANCEL_PREFIX.length
);
const peeked = await getConfirmation<PendingEntry>(context.redis, NAMESPACE, token);
if (!peeked) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
await interaction.reply({ content: t(locale, 'guildbackup.confirm.expired'), ephemeral: true });
return;
}
if (interaction.user.id !== peeked.userId) {
await interaction.reply({
content: t(peeked.locale, 'guildbackup.confirm.unauthorized'),
ephemeral: true
});
return;
}
if (isCancel) {
await deleteConfirmation(context.redis, NAMESPACE, token);
await interaction.update({
content: t(peeked.locale, 'guildbackup.confirm.cancelled'),
components: []
});
return;
}
if (peeked.action === 'delete') {
const entry = await takeConfirmation<PendingDelete>(context.redis, NAMESPACE, token);
if (!entry) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
await interaction.reply({ content: t(locale, 'guildbackup.confirm.expired'), ephemeral: true });
return;
}
await executeDelete(interaction, context, entry);
return;
}
if (peeked.step === 1) {
const next: PendingRestore = { ...peeked, step: 2 };
await setConfirmation(context.redis, NAMESPACE, token, next, TTL_SECONDS);
await interaction.update({
content: tf(peeked.locale, 'guildbackup.confirm.restore.step2', { id: peeked.backupId }),
components: buildRows(token, peeked.locale, RESTORE_CONFIRM_PREFIX)
});
return;
}
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);
}
async function executeDelete(
interaction: ButtonInteraction,
context: BotContext,
entry: PendingDelete
): Promise<void> {
const deleted = await deleteGuildBackup(context, entry.guildId, entry.backupId);
if (!deleted) {
await interaction.update({
content: t(entry.locale, 'guildbackup.error.not_found'),
components: []
});
return;
}
await interaction.update({
content: tf(entry.locale, 'guildbackup.deleted', { id: entry.backupId }),
components: []
});
}
async function executeRestore(
interaction: ButtonInteraction,
context: BotContext,
entry: PendingRestore
): Promise<void> {
const guild = interaction.guild;
if (!guild) {
await interaction.update({
content: t(entry.locale, 'generic.guildOnly'),
components: []
});
return;
}
const backup = await getGuildBackup(context, entry.guildId, entry.backupId);
if (!backup) {
await interaction.update({
content: t(entry.locale, 'guildbackup.error.not_found'),
components: []
});
return;
}
let payload;
try {
payload = parseBackupPayload(backup.payload);
} catch {
await interaction.update({
content: t(entry.locale, 'guildbackup.error.invalid_payload'),
components: []
});
return;
}
if (shouldQueueRestore(payload)) {
await enqueueGuildBackupRestore(entry.backupId, entry.guildId, entry.userId);
await interaction.update({
content: tf(entry.locale, 'guildbackup.restore.queued', { id: entry.backupId }),
components: []
});
return;
}
await interaction.deferUpdate();
try {
const summary = await restoreGuildBackup(guild, payload);
await interaction.editReply({
content: tf(entry.locale, 'guildbackup.restore.done', {
id: entry.backupId,
rolesCreated: summary.rolesCreated,
rolesSkipped: summary.rolesSkipped,
channelsCreated: summary.channelsCreated,
channelsUpdated: summary.channelsUpdated,
overwritesApplied: summary.overwritesApplied
}),
components: []
});
} catch (error) {
if (error instanceof GuildBackupError) {
await interaction.editReply({
content: t(entry.locale, `guildbackup.error.${error.code}`),
components: []
});
return;
}
throw error;
}
}
export function isGuildBackupButton(customId: string): boolean {
return (
customId.startsWith(DELETE_CONFIRM_PREFIX) ||
customId.startsWith(RESTORE_CONFIRM_PREFIX) ||
customId.startsWith(CANCEL_PREFIX)
);
}

View File

@@ -0,0 +1,11 @@
export { guildBackupCommands } from './commands.js';
export { isGuildBackupButton, handleGuildBackupButton } from './confirmations.js';
export {
createGuildBackup,
deleteGuildBackup,
getGuildBackup,
listGuildBackups,
restoreGuildBackup,
runGuildBackupRestoreJob,
shouldQueueRestore
} from './service.js';

View File

@@ -0,0 +1,645 @@
import {
ChannelType,
OverwriteType,
PermissionFlagsBits,
type Guild,
type GuildChannel,
type NonThreadGuildBasedChannel,
type OverwriteResolvable
} from 'discord.js';
import { GuildBackupPayloadSchema, type GuildBackupPayload } from '@nexumi/shared';
import type { GuildBackup } from '@prisma/client';
import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js';
import { assertPremiumLimit, PremiumLimitError } from '../../premium.js';
import { guildBackupQueue } from '../../queues.js';
import type { BotContext } from '../../types.js';
export class GuildBackupError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
const LARGE_RESTORE_THRESHOLD = 50;
const RESTORE_REASON = 'Nexumi guild backup restore';
export type RestoreSummary = {
rolesCreated: number;
rolesSkipped: number;
channelsCreated: number;
channelsUpdated: number;
overwritesApplied: number;
settingsApplied: string[];
};
export async function createGuildBackup(
context: BotContext,
guild: Guild,
name: string,
createdById: string
): Promise<GuildBackup> {
await guild.roles.fetch();
await guild.channels.fetch();
const payload = buildPayloadFromGuild(guild);
GuildBackupPayloadSchema.parse(payload);
await ensureGuild(context.prisma, guild.id);
const currentCount = await context.prisma.guildBackup.count({ where: { guildId: guild.id } });
try {
await assertPremiumLimit(context.prisma, guild.id, 'backups', currentCount);
} catch (error) {
if (error instanceof PremiumLimitError) {
throw new GuildBackupError(
error.code === 'feature_disabled' ? 'premium_feature' : 'premium_limit'
);
}
throw error;
}
return context.prisma.guildBackup.create({
data: {
guildId: guild.id,
name,
createdById,
payload
}
});
}
export function buildPayloadFromGuild(guild: Guild): GuildBackupPayload {
const roles = [...guild.roles.cache.values()]
.sort((a, b) => a.position - b.position)
.map((role) => ({
id: role.id,
name: role.name,
color: role.color,
hoist: role.hoist,
mentionable: role.mentionable,
permissions: role.permissions.bitfield.toString(),
position: role.position
}));
const channels = [...guild.channels.cache.values()]
.filter(
(channel): channel is NonThreadGuildBasedChannel =>
!channel.isThread() && 'permissionOverwrites' in channel
)
.sort((a, b) => {
if (a.parentId !== b.parentId) {
return (a.parentId ?? '').localeCompare(b.parentId ?? '');
}
return a.rawPosition - b.rawPosition;
})
.map((channel) => {
const base = {
id: channel.id,
name: channel.name,
type: channel.type,
parentId: channel.parentId,
position: channel.rawPosition,
permissionOverwrites: channel.permissionOverwrites.cache.map((overwrite) => ({
id: overwrite.id,
type: overwrite.type,
allow: overwrite.allow.bitfield.toString(),
deny: overwrite.deny.bitfield.toString()
}))
};
if (channel.type === ChannelType.GuildText || channel.type === ChannelType.GuildAnnouncement) {
return {
...base,
topic: channel.topic ?? undefined,
nsfw: channel.nsfw,
rateLimitPerUser: channel.rateLimitPerUser ?? undefined
};
}
if (channel.type === ChannelType.GuildVoice || channel.type === ChannelType.GuildStageVoice) {
return {
...base,
bitrate: channel.bitrate ?? undefined,
userLimit: channel.userLimit ?? undefined
};
}
if (channel.type === ChannelType.GuildForum) {
return {
...base,
topic: channel.topic ?? undefined,
nsfw: channel.nsfw,
rateLimitPerUser: channel.rateLimitPerUser ?? undefined
};
}
return base;
});
return {
version: 1,
guildName: guild.name,
roles,
channels,
settings: {
verificationLevel: guild.verificationLevel,
explicitContentFilter: guild.explicitContentFilter,
defaultMessageNotifications: guild.defaultMessageNotifications,
afkTimeout: guild.afkTimeout
}
};
}
export async function listGuildBackups(
context: BotContext,
guildId: string,
limit = 15
): Promise<GuildBackup[]> {
return context.prisma.guildBackup.findMany({
where: { guildId },
orderBy: { createdAt: 'desc' },
take: limit
});
}
export async function getGuildBackup(
context: BotContext,
guildId: string,
backupId: string
): Promise<GuildBackup | null> {
return context.prisma.guildBackup.findFirst({
where: { id: backupId, guildId }
});
}
export async function deleteGuildBackup(
context: BotContext,
guildId: string,
backupId: string
): Promise<boolean> {
const result = await context.prisma.guildBackup.deleteMany({
where: { id: backupId, guildId }
});
return result.count > 0;
}
export function parseBackupPayload(raw: unknown): GuildBackupPayload {
const parsed = GuildBackupPayloadSchema.safeParse(raw);
if (!parsed.success) {
throw new GuildBackupError('invalid_payload');
}
return parsed.data;
}
export function shouldQueueRestore(payload: GuildBackupPayload): boolean {
return payload.roles.length + payload.channels.length >= LARGE_RESTORE_THRESHOLD;
}
export async function enqueueGuildBackupRestore(
backupId: string,
guildId: string,
requestedById: string
): Promise<void> {
await guildBackupQueue.add(
'restoreGuildBackup',
{ backupId, guildId, requestedById },
{
jobId: `guild-backup-restore-${backupId}`,
removeOnComplete: true,
removeOnFail: 25
}
);
}
export async function runGuildBackupRestoreJob(
context: BotContext,
backupId: string,
guildId: string
): Promise<RestoreSummary> {
const backup = await getGuildBackup(context, guildId, backupId);
if (!backup) {
throw new GuildBackupError('not_found');
}
const guild = await context.client.guilds.fetch(guildId);
const payload = parseBackupPayload(backup.payload);
return restoreGuildBackup(guild, payload);
}
export async function restoreGuildBackup(
guild: Guild,
payload: GuildBackupPayload
): Promise<RestoreSummary> {
await guild.roles.fetch();
await guild.channels.fetch();
const summary: RestoreSummary = {
rolesCreated: 0,
rolesSkipped: 0,
channelsCreated: 0,
channelsUpdated: 0,
overwritesApplied: 0,
settingsApplied: []
};
const roleIdMap = buildRoleIdMap(guild, payload);
await createMissingRoles(guild, payload, roleIdMap, summary);
await applyRolePositions(guild, payload, roleIdMap);
const channelIdMap = new Map<string, string>();
await restoreCategories(guild, payload, roleIdMap, channelIdMap, summary);
await restoreOtherChannels(guild, payload, roleIdMap, channelIdMap, summary);
await applyGuildSettings(guild, payload, summary);
logger.info(
{
guildId: guild.id,
backupGuildName: payload.guildName,
...summary
},
'Guild backup restore completed'
);
return summary;
}
function buildRoleIdMap(guild: Guild, payload: GuildBackupPayload): Map<string, string> {
const map = new Map<string, string>();
map.set(guild.id, guild.id);
for (const roleData of payload.roles) {
if (roleData.id === guild.id) {
continue;
}
const existing = guild.roles.cache.find(
(role) => role.name.toLowerCase() === roleData.name.toLowerCase()
);
if (existing) {
map.set(roleData.id, existing.id);
}
}
return map;
}
async function createMissingRoles(
guild: Guild,
payload: GuildBackupPayload,
roleIdMap: Map<string, string>,
summary: RestoreSummary
): Promise<void> {
const sortedRoles = [...payload.roles].sort((a, b) => a.position - b.position);
for (const roleData of sortedRoles) {
if (roleData.id === guild.id) {
continue;
}
if (roleIdMap.has(roleData.id)) {
summary.rolesSkipped += 1;
continue;
}
const existingByName = guild.roles.cache.find(
(role) => role.name.toLowerCase() === roleData.name.toLowerCase()
);
if (existingByName) {
roleIdMap.set(roleData.id, existingByName.id);
summary.rolesSkipped += 1;
continue;
}
try {
const created = await guild.roles.create({
name: roleData.name,
color: roleData.color,
hoist: roleData.hoist,
mentionable: roleData.mentionable,
permissions: BigInt(roleData.permissions),
reason: RESTORE_REASON
});
roleIdMap.set(roleData.id, created.id);
summary.rolesCreated += 1;
} catch (error) {
logger.warn({ error, roleName: roleData.name, guildId: guild.id }, 'Failed to create role');
summary.rolesSkipped += 1;
}
}
}
async function applyRolePositions(
guild: Guild,
payload: GuildBackupPayload,
roleIdMap: Map<string, string>
): Promise<void> {
const positions = payload.roles
.map((roleData) => {
const mappedId = roleIdMap.get(roleData.id);
if (!mappedId || mappedId === guild.id) {
return null;
}
return { role: mappedId, position: roleData.position };
})
.filter((entry): entry is { role: string; position: number } => entry !== null);
if (positions.length === 0) {
return;
}
try {
await guild.roles.setPositions(positions);
} catch (error) {
logger.warn({ error, guildId: guild.id }, 'Failed to apply role positions during restore');
}
}
function resolveParentId(
payload: GuildBackupPayload,
channelData: GuildBackupPayload['channels'][number],
channelIdMap: Map<string, string>,
guild: Guild
): string | undefined {
if (!channelData.parentId) {
return undefined;
}
const mappedParentId = channelIdMap.get(channelData.parentId);
if (mappedParentId) {
return mappedParentId;
}
const parentData = payload.channels.find((channel) => channel.id === channelData.parentId);
if (!parentData) {
return undefined;
}
const existingParent = findChannelByName(guild, parentData.name, undefined);
return existingParent?.id;
}
function findChannelByName(
guild: Guild,
name: string,
parentId: string | undefined
): GuildChannel | undefined {
return guild.channels.cache.find((channel) => {
if (!('name' in channel) || channel.name.toLowerCase() !== name.toLowerCase()) {
return false;
}
if (parentId === undefined) {
return channel.parentId === null;
}
return channel.parentId === parentId;
}) as GuildChannel | undefined;
}
function mapOverwrites(
payloadOverwrites: GuildBackupPayload['channels'][number]['permissionOverwrites'],
roleIdMap: Map<string, string>
): OverwriteResolvable[] {
const mapped: OverwriteResolvable[] = [];
for (const overwrite of payloadOverwrites) {
let targetId = overwrite.id;
if (overwrite.type === OverwriteType.Role) {
const mapped = roleIdMap.get(overwrite.id);
if (!mapped) {
continue;
}
targetId = mapped;
}
mapped.push({
id: targetId,
type: overwrite.type,
allow: BigInt(overwrite.allow),
deny: BigInt(overwrite.deny)
});
}
return mapped;
}
async function restoreCategories(
guild: Guild,
payload: GuildBackupPayload,
roleIdMap: Map<string, string>,
channelIdMap: Map<string, string>,
summary: RestoreSummary
): Promise<void> {
const categories = payload.channels
.filter((channel) => channel.type === ChannelType.GuildCategory)
.sort((a, b) => a.position - b.position);
for (const categoryData of categories) {
const existing = findChannelByName(guild, categoryData.name, undefined);
if (existing) {
channelIdMap.set(categoryData.id, existing.id);
await applyChannelOverwrites(existing, categoryData, roleIdMap, summary);
summary.channelsUpdated += 1;
continue;
}
try {
const created = await guild.channels.create({
name: categoryData.name,
type: ChannelType.GuildCategory,
permissionOverwrites: mapOverwrites(categoryData.permissionOverwrites, roleIdMap),
reason: RESTORE_REASON
});
channelIdMap.set(categoryData.id, created.id);
summary.channelsCreated += 1;
summary.overwritesApplied += categoryData.permissionOverwrites.length;
} catch (error) {
logger.warn(
{ error, channelName: categoryData.name, guildId: guild.id },
'Failed to create category'
);
}
}
}
async function restoreOtherChannels(
guild: Guild,
payload: GuildBackupPayload,
roleIdMap: Map<string, string>,
channelIdMap: Map<string, string>,
summary: RestoreSummary
): Promise<void> {
const channels = payload.channels
.filter((channel) => channel.type !== ChannelType.GuildCategory)
.sort((a, b) => a.position - b.position);
for (const channelData of channels) {
const parentId = resolveParentId(payload, channelData, channelIdMap, guild);
const existing = findChannelByName(guild, channelData.name, parentId);
if (existing) {
channelIdMap.set(channelData.id, existing.id);
await applyChannelOverwrites(existing, channelData, roleIdMap, summary);
summary.channelsUpdated += 1;
continue;
}
try {
const created = await createChannelFromBackup(guild, channelData, parentId, roleIdMap);
if (created) {
channelIdMap.set(channelData.id, created.id);
summary.channelsCreated += 1;
summary.overwritesApplied += channelData.permissionOverwrites.length;
}
} catch (error) {
logger.warn(
{ error, channelName: channelData.name, guildId: guild.id },
'Failed to create channel'
);
}
}
}
async function createChannelFromBackup(
guild: Guild,
channelData: GuildBackupPayload['channels'][number],
parentId: string | undefined,
roleIdMap: Map<string, string>
): Promise<NonThreadGuildBasedChannel | null> {
const overwrites = mapOverwrites(channelData.permissionOverwrites, roleIdMap);
const base = {
name: channelData.name,
parent: parentId,
permissionOverwrites: overwrites,
reason: RESTORE_REASON
};
switch (channelData.type) {
case ChannelType.GuildText:
return guild.channels.create({
...base,
type: ChannelType.GuildText,
topic: channelData.topic ?? undefined,
nsfw: channelData.nsfw ?? false,
rateLimitPerUser: channelData.rateLimitPerUser ?? 0
});
case ChannelType.GuildAnnouncement:
return guild.channels.create({
...base,
type: ChannelType.GuildAnnouncement,
topic: channelData.topic ?? undefined,
nsfw: channelData.nsfw ?? false,
rateLimitPerUser: channelData.rateLimitPerUser ?? 0
});
case ChannelType.GuildVoice:
return guild.channels.create({
...base,
type: ChannelType.GuildVoice,
bitrate: channelData.bitrate ?? 64_000,
userLimit: channelData.userLimit ?? 0
});
case ChannelType.GuildStageVoice:
return guild.channels.create({
...base,
type: ChannelType.GuildStageVoice,
bitrate: channelData.bitrate ?? 64_000,
userLimit: channelData.userLimit ?? 0
});
case ChannelType.GuildForum:
return guild.channels.create({
...base,
type: ChannelType.GuildForum,
topic: channelData.topic ?? undefined,
nsfw: channelData.nsfw ?? false,
rateLimitPerUser: channelData.rateLimitPerUser ?? 0
});
default:
logger.warn(
{ channelType: channelData.type, channelName: channelData.name },
'Unsupported channel type in backup restore'
);
return null;
}
}
async function applyChannelOverwrites(
channel: GuildChannel,
channelData: GuildBackupPayload['channels'][number],
roleIdMap: Map<string, string>,
summary: RestoreSummary
): Promise<void> {
if (!('permissionOverwrites' in channel)) {
return;
}
const overwrites = mapOverwrites(channelData.permissionOverwrites, roleIdMap);
if (overwrites.length === 0) {
return;
}
try {
await channel.permissionOverwrites.set(overwrites, RESTORE_REASON);
summary.overwritesApplied += overwrites.length;
} catch (error) {
logger.warn(
{ error, channelId: channel.id, guildId: channel.guild.id },
'Failed to apply channel overwrites'
);
}
}
async function applyGuildSettings(
guild: Guild,
payload: GuildBackupPayload,
summary: RestoreSummary
): Promise<void> {
const settings = payload.settings;
if (!settings) {
return;
}
if (settings.verificationLevel !== undefined) {
try {
await guild.setVerificationLevel(settings.verificationLevel, RESTORE_REASON);
summary.settingsApplied.push('verificationLevel');
} catch (error) {
logger.warn({ error, guildId: guild.id }, 'Failed to restore verification level');
}
}
if (settings.explicitContentFilter !== undefined) {
try {
await guild.setExplicitContentFilter(settings.explicitContentFilter, RESTORE_REASON);
summary.settingsApplied.push('explicitContentFilter');
} catch (error) {
logger.warn({ error, guildId: guild.id }, 'Failed to restore explicit content filter');
}
}
if (settings.defaultMessageNotifications !== undefined) {
try {
await guild.setDefaultMessageNotifications(
settings.defaultMessageNotifications,
RESTORE_REASON
);
summary.settingsApplied.push('defaultMessageNotifications');
} catch (error) {
logger.warn({ error, guildId: guild.id }, 'Failed to restore default notifications');
}
}
if (settings.afkTimeout !== undefined) {
try {
await guild.setAFKTimeout(settings.afkTimeout, RESTORE_REASON);
summary.settingsApplied.push('afkTimeout');
} catch (error) {
logger.warn({ error, guildId: guild.id }, 'Failed to restore AFK timeout');
}
}
}
export function ensureBotCanRestore(guild: Guild): boolean {
const me = guild.members.me;
if (!me) {
return false;
}
return me.permissions.has(PermissionFlagsBits.ManageRoles | PermissionFlagsBits.ManageChannels);
}

View File

@@ -0,0 +1,49 @@
import { SlashCommandBuilder } from 'discord.js';
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
export const rankCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('rank'),
'leveling.rank.description'
).addUserOption((option) =>
applyOptionDescription(option.setName('user'), 'leveling.rank.options.user')
);
export const leaderboardCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('leaderboard'),
'leveling.leaderboard.description'
);
export const xpCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('xp'),
'leveling.xp.description'
)
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('give'), 'leveling.xp.give.description')
.addUserOption((option) =>
applyOptionDescription(option.setName('user').setRequired(true), 'leveling.xp.options.user')
)
.addIntegerOption((option) =>
applyOptionDescription(
option.setName('amount').setRequired(true).setMinValue(1).setMaxValue(1_000_000),
'leveling.xp.options.amount'
)
)
)
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('remove'), 'leveling.xp.remove.description')
.addUserOption((option) =>
applyOptionDescription(option.setName('user').setRequired(true), 'leveling.xp.options.user')
)
.addIntegerOption((option) =>
applyOptionDescription(
option.setName('amount').setRequired(true).setMinValue(1).setMaxValue(1_000_000),
'leveling.xp.options.amount'
)
)
)
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('reset'), 'leveling.xp.reset.description').addUserOption(
(option) =>
applyOptionDescription(option.setName('user').setRequired(true), 'leveling.xp.options.user')
)
);

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