217 Commits

Author SHA1 Message Date
syntaxbullet
f8436e9755 chore: (agent) remove tickets and skills 2026-01-15 11:13:37 +01:00
syntaxbullet
194a032c7f chore(cleanup): remove completed tickets 2026-01-14 18:10:31 +01:00
syntaxbullet
94a5a183d0 feat(economy): refactor exam command to use ExamService with status-based flow and full test coverage 2026-01-14 18:10:13 +01:00
syntaxbullet
c7730b9355 refactor: migrate web server to centralized logger 2026-01-14 17:58:28 +01:00
syntaxbullet
1e20a5a7a0 refactor: migrate bot handlers to centralized logger 2026-01-14 17:58:28 +01:00
syntaxbullet
54944283a3 feat: implement centralized logger with file persistence 2026-01-14 17:58:28 +01:00
syntaxbullet
f79ee6fbc7 refactor: remove completed ticket file 2026-01-14 16:27:49 +01:00
syntaxbullet
915f1bc4ad fix(economy): improve daily cooldown message and consolidate UserError class 2026-01-14 16:26:27 +01:00
syntaxbullet
4af2690bab feat: implement branded discord embeds and versioning 2026-01-14 16:10:23 +01:00
syntaxbullet
6e57ab07e4 chore: update gitiignore 2026-01-14 15:12:51 +01:00
syntaxbullet
3a620a84c5 feat: add trivia category selection and sync trivia fixes 2026-01-11 16:08:11 +01:00
syntaxbullet
7d68652ea5 fix: fix potential issues with trivia command 2026-01-11 15:00:10 +01:00
syntaxbullet
35bd1f58dd feat: trivia command! 2026-01-11 14:37:17 +01:00
syntaxbullet
1cd3dbcd72 agent: update agent workflows 2026-01-09 22:04:40 +01:00
syntaxbullet
c97249f2ca docs: update README with dashboard architecture and ssh tunnel guide 2026-01-09 22:02:09 +01:00
syntaxbullet
0d923491b5 feat: (ui) settings drawers 2026-01-09 19:28:14 +01:00
syntaxbullet
d870ef69d5 feat: (ui) leaderboards 2026-01-09 16:45:36 +01:00
syntaxbullet
682e9d208e feat: more stat components 2026-01-09 16:18:52 +01:00
syntaxbullet
4a691ac71d feat: (ui) first dynamic data 2026-01-09 15:22:13 +01:00
syntaxbullet
1b84dbd36d feat: (ui) new design 2026-01-09 15:12:35 +01:00
syntaxbullet
a5b8d922e3 feat(web): implement full activity page with charts and logs 2026-01-08 23:20:00 +01:00
syntaxbullet
238d9a8803 refactor(web): enhance ui visual polish and ux
- Replace native selects with Shadcn UI Select in Settings
- Increase ActivityChart height for better visibility
- specific Economy Overview card height to fill column
- Add hover/active scale animations to sidebar items
2026-01-08 23:10:14 +01:00
syntaxbullet
713ea07040 feat(ui): use shadcn switch for toggles and remove sidebar user footer 2026-01-08 23:00:44 +01:00
syntaxbullet
bea6c33024 feat(settings): group commands by category in system tab 2026-01-08 22:55:40 +01:00
syntaxbullet
8fe300c8a2 feat(web): add toast notifications for settings save status 2026-01-08 22:47:31 +01:00
syntaxbullet
9caa95a0d8 feat(settings): support toggling disabled commands and auto-reload bot on save 2026-01-08 22:44:48 +01:00
syntaxbullet
c6fd23b5fa feat(dashboard): implement bot settings page with partial updates and serialization fixes 2026-01-08 22:35:46 +01:00
syntaxbullet
d46434de18 feat(dashboard): expand stats & remove admin token auth 2026-01-08 22:14:13 +01:00
syntaxbullet
cf4c28e1df fix : 404 error fix 2026-01-08 21:45:53 +01:00
syntaxbullet
39e405afde chore: polish analytics API logging and typing 2026-01-08 21:39:53 +01:00
syntaxbullet
6763e3c543 fix: address code review findings for analytics and security 2026-01-08 21:39:01 +01:00
syntaxbullet
11e07a0068 feat: implement visual analytics and activity charts 2026-01-08 21:36:19 +01:00
syntaxbullet
5d2d4bb0c6 refactor: improve type safety and remove forced casts in dashboard service 2026-01-08 21:31:40 +01:00
syntaxbullet
19206b5cc7 fix: address security review findings, implement real cache clearing, and fix lifecycle promises 2026-01-08 21:29:09 +01:00
syntaxbullet
0f6cce9b6e feat: implement administrative control panel with real-time bot actions 2026-01-08 21:19:16 +01:00
syntaxbullet
3f3a6c88e8 fix(dash): resolve test regressions, await promises, and improve TypeScript strictness 2026-01-08 21:12:41 +01:00
syntaxbullet
8253de9f73 fix(dash): address safety constraints, validation, and test quality issues 2026-01-08 21:08:47 +01:00
syntaxbullet
1251df286e feat: implement real-time dashboard updates via WebSockets 2026-01-08 21:01:33 +01:00
syntaxbullet
fff90804c0 feat(dash): Revamp dashboard UI with glassmorphism and real bot data 2026-01-08 20:58:57 +01:00
syntaxbullet
8ebaf7b4ee docs: update ticket status to In Review with implementation notes 2026-01-08 18:51:58 +01:00
syntaxbullet
17cb70ec00 feat: integrate real data into dashboard
- Created dashboard service with DB queries for users, economy, events
- Added client stats provider with 30s caching for Discord metrics
- Implemented /api/stats endpoint aggregating all dashboard data
- Created useDashboardStats React hook with auto-refresh
- Updated Dashboard.tsx to display real data with loading/error states
- Added comprehensive test coverage (11 tests passing)
- Replaced all mock values with live Discord and database metrics
2026-01-08 18:50:44 +01:00
syntaxbullet
a207d511be docs: clarify drizzle studio access via proxy URL 2026-01-08 18:20:27 +01:00
syntaxbullet
cf4f180124 fix: add web network to studio for port publishing 2026-01-08 18:17:27 +01:00
syntaxbullet
5df1396b3f chore: update docker compose 2026-01-08 18:12:39 +01:00
syntaxbullet
daad7be01c chore: attempt fixing drizzle studio 2026-01-08 18:04:40 +01:00
syntaxbullet
05f27ca604 refactor: fix frontend 2026-01-08 17:01:36 +01:00
syntaxbullet
d37059d50f chore: remove tickets from future commits 2026-01-08 16:45:49 +01:00
syntaxbullet
caafe6b34d refactor: update graphics paths 2026-01-08 16:42:14 +01:00
syntaxbullet
017f5ad818 refactor: fix stale imports 2026-01-08 16:39:34 +01:00
syntaxbullet
f92415b89c refactor: move drizzle to shared 2026-01-08 16:29:31 +01:00
syntaxbullet
3f028eb76a refactor: consolidate config loading 2026-01-08 16:21:25 +01:00
syntaxbullet
2b641c952d refactor: move config loading to shared directory 2026-01-08 16:15:55 +01:00
syntaxbullet
88b266f81b refactor: initial moves 2026-01-08 16:09:26 +01:00
syntaxbullet
53a2f1ff0c chore: combine processes 2026-01-08 15:13:09 +01:00
syntaxbullet
dc15212ecf web: mock dashboard 2026-01-08 14:49:59 +01:00
syntaxbullet
99e847175e chore: remove frontend boilerplate 2026-01-08 14:26:16 +01:00
syntaxbullet
b2c7fa6e83 feat: improvements to update command 2026-01-08 14:13:24 +01:00
syntaxbullet
9e7f18787b feat: improvements to web dashboard 2026-01-08 13:56:25 +01:00
47507dd65a Merge pull request 'added react app' (#4) from HotPlate/discord-rpg-concept:reactApp into main
Reviewed-on: syntaxbullet/AuroraBot-discord#4
2026-01-08 11:51:18 +00:00
Vraj Ved
e6f94c3e71 added react app 2026-01-08 17:15:28 +05:30
syntaxbullet
66af870aa9 fix: make dashboard locally accessible only 2026-01-07 14:33:19 +01:00
syntaxbullet
8047bce755 feat: add bot action controls and real-time vital statistics to the web dashboard 2026-01-07 14:26:37 +01:00
syntaxbullet
9804456257 docs: Remove completed and draft feature tickets from the tickets directory. 2026-01-07 13:49:04 +01:00
syntaxbullet
259b8d6875 feat: replace mock dashboard data with live telemetry 2026-01-07 13:47:02 +01:00
syntaxbullet
a2cb684b71 Merge branch 'feat/web-interface-expansion-mockup' into main 2026-01-07 13:39:41 +01:00
syntaxbullet
9c2098bc46 fix(test): use dynamic port for websocket tests 2026-01-07 13:37:21 +01:00
syntaxbullet
618d973863 feat: expansion of web dashboard with live activity feed and metrics 2026-01-07 13:34:29 +01:00
syntaxbullet
63f55b6dfd feat: implement dashboard mockup and route 2026-01-07 13:29:06 +01:00
syntaxbullet
ac4025e179 feat: implement websocket realtime data streaming 2026-01-07 13:25:41 +01:00
syntaxbullet
ff23f22337 feat: move status to footer and clean up home page 2026-01-07 13:21:36 +01:00
syntaxbullet
292991c605 feat: responsive mobile layout and touch optimizations 2026-01-07 13:08:02 +01:00
syntaxbullet
4640cd11a7 feat: ux enhancements (animations, dynamic backgrounds, micro-interactions) 2026-01-07 13:05:42 +01:00
syntaxbullet
43a003f641 feat: visual design system overhaul (HSL palette, fonts, components) 2026-01-07 13:04:40 +01:00
syntaxbullet
6f4426e49d feat: save progress on web server foundation and add new tickets 2026-01-07 13:02:36 +01:00
syntaxbullet
894cad91a8 feat: Implement secure static file serving with path traversal protection and XSS prevention for template titles. 2026-01-07 12:51:08 +01:00
syntaxbullet
2a1c4e65ae feat(web): implement web server foundation 2026-01-07 12:40:21 +01:00
syntaxbullet
022f748517 feat: implement agent workflows for ticket creation, development, and code review. 2026-01-07 12:12:57 +01:00
syntaxbullet
ca392749e3 refactor: replace cleanup service with focused temp role service and fix daily streaks 2026-01-07 11:04:34 +01:00
syntaxbullet
4a1e72c5f3 chore: add additional stats to terminal 2026-01-06 21:05:51 +01:00
syntaxbullet
d29a1ec2b7 chore: update terminal adding nicer graphics 2026-01-06 20:51:39 +01:00
syntaxbullet
1dd269bf2f chore: update terminal service 2026-01-06 20:36:26 +01:00
syntaxbullet
69186ff3e9 chore: add more options to cleanup command 2026-01-06 19:44:18 +01:00
syntaxbullet
b989e807dc feat: Add /cleanup admin command and enhance lootdrop cleanup service to optionally include claimed items. 2026-01-06 19:27:41 +01:00
syntaxbullet
2e6bdec38c refactor: switch Drizzle ORM from postgres-js to bun-sql driver. 2026-01-06 18:52:25 +01:00
syntaxbullet
a9d5c806ad feat: Migrate Drizzle ORM to postgres.js, exclude test files from command loading, and adjust postgres dependency type. 2026-01-06 18:46:30 +01:00
syntaxbullet
6f73178375 feat: Bind docker-compose database and server ports to localhost. 2026-01-06 18:37:42 +01:00
syntaxbullet
dd62336571 fix(test): resolve typescript undefined errors in inventory service tests 2026-01-06 18:25:18 +01:00
syntaxbullet
8280111b66 feat(inventory): implement item name autocomplete with rarity and case-insensitive search 2026-01-06 18:24:15 +01:00
syntaxbullet
34347f0c63 feat: centralized constants and enums for project-wide use 2026-01-06 18:15:52 +01:00
syntaxbullet
c807fd4fd0 test: fix lint errors in moderation service tests 2026-01-06 18:05:05 +01:00
syntaxbullet
47b980eff1 feat: add moderation unit tests and refactor warning logic 2026-01-06 18:03:36 +01:00
syntaxbullet
bc89ddf7c0 feat: implement scheduled cleanup job for expired data 2026-01-06 17:44:08 +01:00
syntaxbullet
606d83a7ae feat: add health check command and tracking 2026-01-06 17:30:55 +01:00
syntaxbullet
3351295bdc feat: add database indexes for performance optimization 2026-01-06 17:26:34 +01:00
syntaxbullet
92cb048a7a test: fix mock leakage in db tests 2026-01-06 17:22:43 +01:00
syntaxbullet
6ead0c0393 feat: implement graceful shutdown handling 2026-01-06 17:21:50 +01:00
syntaxbullet
278ef4b6b0 fix: Normalize exam and cooldown dates to the start of the day for consistent calculations. 2026-01-05 17:36:53 +01:00
syntaxbullet
9a32ab298d feat: Implement a net worth leaderboard by aggregating user balance and inventory item values. 2026-01-05 16:40:26 +01:00
syntaxbullet
a2596d4124 docs: Add command reference and database schema documentation. 2026-01-05 13:13:46 +01:00
syntaxbullet
fbc8952e0a docs: Add guides for lootbox creation and configuration options. 2026-01-05 13:07:36 +01:00
syntaxbullet
d0b4cb80de feat: Add user existence checks to economy commands and refactor trade service to expose sessions for testing. 2026-01-05 12:57:22 +01:00
syntaxbullet
599684cde8 feat: Add lootbox item type with weighted rewards and dedicated UI for item usage results. 2026-01-05 12:52:34 +01:00
syntaxbullet
5606fb6e2f fix: Clarify daily claim cooldown message for daily claims. 2026-01-05 12:17:23 +01:00
syntaxbullet
fb260c5beb feat: Set daily claim cooldown to next UTC midnight and reset streak to 1 if missed by over 24 hours. 2026-01-05 12:10:41 +01:00
syntaxbullet
a227e5db59 feat: Implement graphical lootdrop cards for lootdrop and claimed messages. 2025-12-24 23:13:16 +01:00
syntaxbullet
66d5145885 docs: add guide for standard module structure patterns 2025-12-24 22:26:14 +01:00
syntaxbullet
2412098536 refactor(modules): standardize error handling in interaction handlers 2025-12-24 22:26:12 +01:00
syntaxbullet
d0c48188b9 refactor(core): centralize interaction error handling and organize routes 2025-12-24 22:26:10 +01:00
syntaxbullet
1523a392c2 refactor: add leveling view layer
Create leveling.view.ts with UI logic extracted from leaderboard command:
- getLeaderboardEmbed() for leaderboard display (XP and Balance)
- getMedalEmoji() helper for ranking medals (🥇🥈🥉)
- formatLeaderEntry() helper for entry formatting with null safety

Updated leaderboard.ts to use view functions instead of inline formatting.
2025-12-24 22:09:04 +01:00
syntaxbullet
7d6912cdee refactor: add quest view layer
Create quest.view.ts with UI logic extracted from quests command:
- getQuestListEmbed() for quest log display
- formatQuestRewards() helper for reward formatting
- getQuestStatus() helper for status display

Updated quests.ts to use view functions instead of inline embed building.
2025-12-24 22:08:55 +01:00
syntaxbullet
947bbc10d6 refactor: add inventory view layer
Create inventory.view.ts with UI logic extracted from commands:
- getInventoryEmbed() for inventory display
- getItemUseResultEmbed() for item use results

Updated commands with proper type safety:
- inventory.ts: add null check, convert user.id to string
- use.ts: add null check, convert user.id to string

Improves separation of concerns and type safety.
2025-12-24 22:08:51 +01:00
syntaxbullet
2933eaeafc refactor: convert TradeService to object export pattern
Convert from class-based to object-based export for consistency with
other services (economy, inventory, quest, etc).

Changes:
- Move sessions Map and helper functions to module scope
- Convert static methods to object properties
- Update executeTrade to use withTransaction helper
- Update all imports from TradeService to tradeService

Updated files:
- trade.service.ts (main refactor)
- trade.interaction.ts (update usages)
- trade.ts command (update import and usage)

All tests passing with no breaking changes.
2025-12-24 21:57:30 +01:00
syntaxbullet
77d3fafdce refactor: standardize transaction pattern in class.service.ts
Replace manual transaction handling with withTransaction helper pattern
for consistency with other services (economy, inventory, quest, leveling).

Also fix validation bug in modifyClassBalance:
- Before: if (balance < amount)
- After: if (balance + amount < 0n)

This correctly validates negative amounts (debits) to prevent balances
going below zero.
2025-12-24 21:57:14 +01:00
syntaxbullet
10a760edf4 refactor: replace console.* with logger in core lib files
Update loaders, handlers, and BotClient to use centralized logger:
- CommandLoader.ts and EventLoader.ts
- AutocompleteHandler.ts, CommandHandler.ts, ComponentInteractionHandler.ts
- BotClient.ts

Provides consistent formatting across all core library logging.
2025-12-24 21:56:50 +01:00
syntaxbullet
a53d30a0b3 feat: add centralized logger utility
Add logger.ts with consistent emoji prefixes for all log levels:
- info, success, warn, error, debug

This provides a single source of truth for logging and enables
future extensibility for file logging or external services.
2025-12-24 21:56:29 +01:00
syntaxbullet
5420653b2b refactor: Extract interaction handling logic into dedicated ComponentInteractionHandler, AutocompleteHandler, and CommandHandler classes. 2025-12-24 21:38:01 +01:00
syntaxbullet
f13ef781b6 refactor(lib): simplify BotClient using loader classes
- Delegate command loading to CommandLoader
- Delegate event loading to EventLoader
- Remove readCommandsRecursively and readEventsRecursively methods
- Remove isValidCommand and isValidEvent methods (moved to loaders)
- Add summary logging with load statistics
- Export Client class for better type safety
- Reduce file from 188 to 97 lines (48% reduction)

BREAKING CHANGE: Client class is now exported as a named export
2025-12-24 21:32:23 +01:00
syntaxbullet
82a4281f9b feat(lib): extract EventLoader from BotClient
- Create dedicated EventLoader class for event loading logic
- Implement recursive directory scanning
- Add event validation and registration (once vs on)
- Improve error handling with structured results
- Enable better testability and separation of concerns
2025-12-24 21:32:15 +01:00
syntaxbullet
0dbc532c7e feat(lib): extract CommandLoader from BotClient
- Create dedicated CommandLoader class for command loading logic
- Implement recursive directory scanning
- Add category extraction from file paths
- Add command validation and config-based filtering
- Improve error handling with structured results
- Enable better testability and separation of concerns
2025-12-24 21:32:08 +01:00
syntaxbullet
953942f563 feat(lib): add loader types for command/event loading
- Add LoadResult interface to track loading statistics
- Add LoadError interface for structured error reporting
- Foundation for modular loader architecture
2025-12-24 21:31:54 +01:00
syntaxbullet
6334275d02 refactor: modernize transaction patterns and improve type safety
- Refactored user.service.ts to use withTransaction() helper
- Added 14 comprehensive unit tests for user.service.ts
- Removed duplicate user creation in interactionCreate.ts
- Improved type safety in interaction.routes.ts
2025-12-24 21:23:58 +01:00
syntaxbullet
f44b053a10 feat: add admin moderation commands for managing cases, warnings, and notes. 2025-12-24 21:02:37 +01:00
syntaxbullet
fe58380d58 fix: Add null check for regex capture group and non-null assertion for type safety. 2025-12-24 20:59:10 +01:00
syntaxbullet
64cf47ee03 feat: add moderation module with case tracking database schema 2025-12-24 20:55:56 +01:00
syntaxbullet
37ac0ee934 feat: implement message pruning command with dedicated service and UI components. 2025-12-24 20:45:40 +01:00
syntaxbullet
5ab19bf826 fix: improve feedback type parsing from custom IDs and add validation for feedback types in interaction and view logic. 2025-12-24 20:23:52 +01:00
syntaxbullet
42d2313933 feat: Introduce a comprehensive feedback system with a slash command, interactive UI, and configuration. 2025-12-24 20:16:47 +01:00
syntaxbullet
cddd8cdf57 refactor: move terminal message and channel ID persistence from a dedicated file to the main application configuration. 2025-12-24 19:57:00 +01:00
syntaxbullet
eaaf569f4f feat: Implement cumulative XP leveling system with new helper functions and update XP bar to show progress within the current level. 2025-12-24 18:52:40 +01:00
syntaxbullet
8c28fe60fc feat: Enable Components V2, use user mentions, and enhance transaction log display in terminal service. 2025-12-24 18:07:50 +01:00
syntaxbullet
6d725b73db feat: Add MessageFlags.IsComponentsV2 to terminal message updates and remove redundant SectionBuilder wrappers. 2025-12-24 17:54:56 +01:00
syntaxbullet
da048eaad1 fix: Update Discord.js message edit property from containers to components for layout. 2025-12-24 17:47:53 +01:00
syntaxbullet
56da4818dc feat: Refactor terminal message to use new Discord.js UI builders for structured output. 2025-12-24 17:43:56 +01:00
syntaxbullet
ca443491cb refactor: Remove backticks and square brackets from terminal activity timestamp. 2025-12-24 17:26:40 +01:00
syntaxbullet
345e05f821 feat: Introduce dynamic Aurora Observatory terminal with admin command, scheduled updates, and lootdrop event integration. 2025-12-24 17:19:22 +01:00
syntaxbullet
419059904c refactor: relocate interaction routes from events to lib directory 2025-12-24 14:36:14 +01:00
syntaxbullet
7698a3abaa chore: delete test-student-id.png 2025-12-24 14:30:50 +01:00
syntaxbullet
83984faeae feat: Configure app service to restart automatically in Docker Compose and replace file-based restart trigger with process.exit(). 2025-12-24 14:28:23 +01:00
syntaxbullet
2106f06f8f chore: remove student id testing script 2025-12-24 14:23:55 +01:00
syntaxbullet
16d507991c feat: Enhance update requirements check to include database migrations and rename related interfaces and methods. 2025-12-24 14:17:02 +01:00
syntaxbullet
e2aa5ee760 feat: Implement stateful admin update with post-restart context, database migrations, and dedicated view components. 2025-12-24 14:03:15 +01:00
syntaxbullet
e084b6fa4e refactor: Reorganize admin update flow to prepare restart context before update and explicitly trigger restart. 2025-12-24 13:50:37 +01:00
syntaxbullet
3f6da16f89 feat: Add post-update dependency installation, refactor restart logic, and update completion messages. 2025-12-24 13:46:32 +01:00
syntaxbullet
71de87d3da chore: Remove migration generation command from update service. 2025-12-24 13:43:04 +01:00
syntaxbullet
fc7afd7d22 feat: implement a dedicated update service to centralize bot update logic, dependency checks, and post-restart handling. 2025-12-24 13:38:45 +01:00
syntaxbullet
fcc82292f2 feat: Introduce modular inventory item effect handling and centralize Discord interaction routing. 2025-12-24 12:20:42 +01:00
syntaxbullet
f75cc217e9 refactor: extract further UI components into views 2025-12-24 11:44:43 +01:00
syntaxbullet
5c36b9be25 refactor: Extract UI component creation into new view files for lootdrop, trade, item wizard, and enrollment. 2025-12-24 11:36:19 +01:00
syntaxbullet
eaf97572a4 refactor: replace direct EmbedBuilder usage with a new createBaseEmbed helper for consistent embed creation 2025-12-24 11:17:59 +01:00
syntaxbullet
1189483244 refactor: clean up unused imports and dead code across commands, services, and tests. 2025-12-24 11:02:13 +01:00
syntaxbullet
f39ccee0d3 fix: Cast user ID to string for member fetching 2025-12-24 10:07:51 +01:00
syntaxbullet
10282a2570 feat: Add admin command to create color roles and corresponding shop items. 2025-12-23 21:56:45 +01:00
syntaxbullet
a3099b80c5 feat: Add color role item effect with role swapping and implement item consumption toggle. 2025-12-23 21:12:36 +01:00
syntaxbullet
67d6298793 revert(pay): ping in separate message content due to API limitations 2025-12-23 18:50:35 +01:00
syntaxbullet
808fbef11b fix: allow included mentions on payment 2025-12-23 18:44:17 +01:00
syntaxbullet
b833796fb9 fix: make payments public 2025-12-23 18:42:13 +01:00
syntaxbullet
58ea8b92f1 fix: timestamp rendering issues 2025-12-23 18:36:22 +01:00
syntaxbullet
fbd2bd990f fix: correct Discord timestamp formatting in daily command 2025-12-22 14:32:33 +01:00
syntaxbullet
f859618367 feat: introduce weekly bonus for daily rewards, updating calculations, configuration, and command UI. 2025-12-22 13:16:44 +01:00
syntaxbullet
b7b1dd87b8 style: Standardize template literal spacing and remove extraneous markdown code block delimiters. 2025-12-22 13:08:35 +01:00
syntaxbullet
f3b6af019d refactor: remove unused import, markdown fences, and standardize string interpolation formatting. 2025-12-22 13:07:11 +01:00
syntaxbullet
0dea266a6d feat(commands): improve error feedback for economy and admin commands 2025-12-22 12:59:46 +01:00
syntaxbullet
fbcac51370 refactor(modules): propagate UserError in quest, trade, and class services 2025-12-22 12:58:47 +01:00
syntaxbullet
75e586cee8 feat(commands): improve error feedback for use command 2025-12-22 12:56:37 +01:00
syntaxbullet
6e1e6abf2d fix(config): enforce runtime schema validation 2025-12-22 12:56:20 +01:00
syntaxbullet
4a0a2a5878 refactor(inventory): propagate UserError for predictable failures 2025-12-22 12:55:46 +01:00
syntaxbullet
216189b0a4 fix: grammatical errors in daily warning cooldown message 2025-12-21 11:31:21 +01:00
syntaxbullet
ca1339728a chore: change cooldown display to use relative timestamps for better UX with international users. 2025-12-21 11:26:53 +01:00
syntaxbullet
5833224ba9 feat: Implement welcome messages for new enrollments using a new webhook utility and refactor the admin webhook command to utilize it. 2025-12-20 20:59:44 +01:00
syntaxbullet
65f5dc3721 chore: remove 'src' directory from config file path definition. 2025-12-20 20:48:37 +01:00
syntaxbullet
637f0826db feat: conditionally assign student and class roles to new members if returning, otherwise assign visitor role. 2025-12-20 20:12:27 +01:00
syntaxbullet
578987caea chore: update readme 2025-12-20 11:49:50 +01:00
syntaxbullet
064efb0ed2 test: add tests for item wizard 2025-12-20 11:41:53 +01:00
syntaxbullet
4229e5338f refactor: rename bot client, environment variables, and project name from Kyoko to Aurora. 2025-12-20 11:23:39 +01:00
syntaxbullet
1f7679e5a1 test: refactor mocks to use spyOn for better isolation 2025-12-19 13:31:14 +01:00
syntaxbullet
4e228bb7a3 test: add tests for trade service 2025-12-19 12:15:48 +01:00
syntaxbullet
95d5202d7f test: add tests for quest service 2025-12-19 12:15:32 +01:00
syntaxbullet
6c150f753e test: add tests for leveling service 2025-12-19 11:18:35 +01:00
syntaxbullet
c881b305f0 test: add tests for inventory service 2025-12-19 11:08:43 +01:00
syntaxbullet
ae5ef4c802 test: add tests for lootdrop service 2025-12-19 11:05:25 +01:00
syntaxbullet
2b365cb96d test: add tests for economy service 2025-12-19 11:04:00 +01:00
syntaxbullet
bcbbcaa6a4 test: add tests for class service 2025-12-19 11:02:31 +01:00
syntaxbullet
bdb8456f34 feat: add initial unit tests for user service and configure bun test script. 2025-12-19 10:59:06 +01:00
syntaxbullet
acaca46298 chore: add database migrations 2025-12-19 10:53:01 +01:00
syntaxbullet
7b831fa17c feat: log successful visitor role assignment and member's updated roles 2025-12-18 23:23:05 +01:00
syntaxbullet
c128c96aa8 feat: log new guild member joins with their tag and ID 2025-12-18 23:10:24 +01:00
syntaxbullet
d0f53dc37b fix: add guildmembers intent 2025-12-18 22:42:24 +01:00
syntaxbullet
28936a7f7a fix: properly give visitor role to new members 2025-12-18 22:32:45 +01:00
syntaxbullet
4642cf7f6a chore: remove internal class information from enrollment message 2025-12-18 20:35:18 +01:00
syntaxbullet
528a66a7ef feat: Implement user enrollment interaction to assign a random class role and add new role configurations. 2025-12-18 20:09:19 +01:00
syntaxbullet
a97a24f72a chore: updated listing command with autocomplete from items table 2025-12-18 19:41:50 +01:00
syntaxbullet
7bd4d811cd feat: Add script and configuration for remote Drizzle Studio access via SSH tunnel. 2025-12-18 19:34:05 +01:00
syntaxbullet
2ce768013d feat: implement interactive item creation wizard via new /createitem command 2025-12-18 19:16:43 +01:00
syntaxbullet
3c20b23cc1 fix: add missing fields to config schema 2025-12-18 17:39:46 +01:00
syntaxbullet
71fefb3a14 feat: Move database migration execution from update command to post-restart ready event. 2025-12-18 17:29:37 +01:00
syntaxbullet
1d650bb2c7 feat: add zod validation to config 2025-12-18 17:22:11 +01:00
syntaxbullet
7cf8d68d39 feat: persistent lootbox states, update command now runs db migrations 2025-12-18 17:02:21 +01:00
syntaxbullet
83cd33e439 refactor: Optimize item autocomplete by moving name filtering to the database query and increasing the limit. 2025-12-18 16:51:22 +01:00
syntaxbullet
34cbea2753 remove old reload command 2025-12-18 16:37:10 +01:00
syntaxbullet
ce7d4525b2 feat: split reload command into refresh for command reloading and update for git-based bot restarts with update checking and confirmation. 2025-12-18 16:36:23 +01:00
syntaxbullet
4ac8b4759e feat: Add /config admin command to dynamically edit and save bot configuration. 2025-12-18 16:25:54 +01:00
syntaxbullet
56ad5b49cd feat: Introduce lootdrop functionality, enabling activity-based spawning and interactive claiming, alongside new configuration parameters. 2025-12-18 16:09:52 +01:00
syntaxbullet
e8f6a56057 git: modify gitignore 2025-12-18 15:01:50 +01:00
syntaxbullet
a7f66a98b9 chore: Ignore the src/config directory. 2025-12-18 15:00:34 +01:00
syntaxbullet
6d54695325 feat: add new exam economy command with its configuration. 2025-12-18 14:48:40 +01:00
syntaxbullet
8c1f80981b feat: Introduce a dedicated autocomplete handler for commands and refactor the inventory use command to utilize it. 2025-12-18 14:34:47 +01:00
syntaxbullet
3a96b67e89 feat: Allow item effects to specify durations in hours, minutes, or seconds. 2025-12-15 23:26:51 +01:00
syntaxbullet
d3ade218ec feat: add /use command for inventory items with effects, implement XP boosts, and enhance scheduler for temporary role removal. 2025-12-15 23:22:51 +01:00
syntaxbullet
1d4263e178 feat: Introduced an admin listing command and shop interaction module, replacing the sell command, and added a type-checking script. 2025-12-15 22:52:26 +01:00
syntaxbullet
727b63b4dc refactor: trigger application reload by appending to entry file instead of updating its times. 2025-12-15 22:38:32 +01:00
syntaxbullet
d2edde77e6 build: Install git system dependency in Dockerfile. 2025-12-15 22:32:27 +01:00
syntaxbullet
3acb5304f5 feat: Introduce admin webhook and enhanced reload commands with redeploy functionality, implement post-restart notifications, and update Docker container names from Kyoko to Aurora. 2025-12-15 22:29:03 +01:00
syntaxbullet
9333d6ac6c feat: Prevent inventory, profile, balance, and pay commands from targeting bot users. 2025-12-15 22:21:29 +01:00
syntaxbullet
7e986fae5a feat: Implement custom error classes, a Drizzle transaction utility, and update Discord.js ephemeral message flags. 2025-12-15 22:14:17 +01:00
syntaxbullet
3c81fd8396 refactor: rename game.json to config.json and update file path references 2025-12-15 22:02:35 +01:00
syntaxbullet
3984d6112b refactor: rename KyokoClient to BotClient and update all imports. 2025-12-15 22:01:19 +01:00
syntaxbullet
ac6283e60c feat: Introduce dynamic JSON-based configuration for game settings and command toggling via a new admin command. 2025-12-15 21:59:28 +01:00
242 changed files with 23874 additions and 1810 deletions

View File

@@ -1,9 +1,12 @@
DB_USER=kyoko
DB_PASSWORD=kyoko
DB_NAME=kyoko
DB_USER=aurora
DB_PASSWORD=aurora
DB_NAME=aurora
DB_PORT=5432
DB_HOST=db
DISCORD_BOT_TOKEN=your-discord-bot-token
DISCORD_CLIENT_ID=your-discord-client-id
DISCORD_GUILD_ID=your-discord-guild-id
DATABASE_URL=postgres://kyoko:kyoko@db:5432/kyoko
DATABASE_URL=postgres://aurora:aurora@db:5432/aurora
VPS_USER=your-vps-user
VPS_HOST=your-vps-ip

12
.gitignore vendored
View File

@@ -1,11 +1,16 @@
.env
node_modules
db-logs
db-data
docker-compose.override.yml
shared/db-logs
shared/db/data
shared/db/loga
.cursor
# dependencies (bun install)
node_modules
config/
# output
out
dist
@@ -39,4 +44,5 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
.DS_Store
src/db/data
src/db/log
src/db/log
scratchpad/

View File

@@ -1,14 +1,21 @@
FROM oven/bun:latest AS base
WORKDIR /app
# Install dependencies
# Install system dependencies
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
# Install root project dependencies
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
# Install web project dependencies
COPY web/package.json web/bun.lock ./web/
RUN cd web && bun install --frozen-lockfile
# Copy source code
COPY . .
# Expose port
# Expose ports (3000 for web dashboard)
EXPOSE 3000
# Default command

174
README.md
View File

@@ -1,42 +1,160 @@
# Kyoko - Discord Rpg
# Aurora
A Discord bot built with [Bun](https://bun.sh), [Discord.js](https://discord.js.org/), and [Drizzle ORM](https://orm.drizzle.team/).
> A comprehensive, feature-rich Discord RPG bot built with modern technologies.
## Architecture
![Version](https://img.shields.io/badge/version-1.0.0-blue.svg)
![Bun](https://img.shields.io/badge/Bun-1.0+-black)
![Discord.js](https://img.shields.io/badge/Discord.js-14.x-5865F2)
![Drizzle ORM](https://img.shields.io/badge/Drizzle_ORM-0.30+-C5F74F)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-336791)
![React](https://img.shields.io/badge/React-19-61DAFB)
This project uses a modular architecture:
Aurora is a powerful Discord bot designed to facilitate RPG-like elements within a Discord server. It features a robust economy, class system, inventory management, quests, and more, all built on top of a high-performance stack using Bun and Drizzle ORM.
- **`src/index.ts`**: Entry point. initializes the client.
- **`src/lib/KyokoClient.ts`**: Custom Discord Client wrapper handling command loading and events.
- **`src/lib/env.ts`**: **Centralized Environment Configuration**. Validates environment variables using `zod` at startup.
- **`src/lib/DrizzleClient.ts`**: Database client instance.
- **`src/commands/`**: Command files.
- **`src/db/`**: Database schema and migrations.
**New in v1.0:** Aurora now includes a fully integrated **Web Dashboard** for managing the bot, viewing statistics, and configuring settings, running alongside the bot in a single process.
## Setup
## ✨ Features
1. **Install Dependencies**:
### Discord Bot
* **Class System**: Users can join different classes.
* **Economy**: Complete economy system with balance, transactions, and daily rewards.
* **Inventory & Items**: Sophisticated item system with rarities, types (Material, Consumable, etc.), and inventory management.
* **Leveling**: XP-based leveling system to track user activity and progress.
* **Quests**: Quest system with requirements and rewards.
* **Trading**: Secure trading system between users.
* **Lootdrops**: Random loot drops in channels to engage users.
* **Admin Tools**: Administrative commands for server management.
### Web Dashboard
* **Live Analytics**: View real-time activity charts (commands, transactions).
* **Configuration Management**: Update bot settings without restarting.
* **Database Inspection**: Integrated Drizzle Studio access.
* **State Monitoring**: View internal bot state (Lootdrops, etc.).
## 🏗️ Architecture
Aurora uses a **Single Process Monolith** architecture to maximize performance and simplify resource sharing.
* **Unified Runtime**: Both the Discord Client and the Web Dashboard run within the same Bun process.
* **Shared State**: This allows the Dashboard to access live bot memory (caches, gateways) directly without complex inter-process communication (IPC).
* **Simplified Deployment**: You only need to deploy a single Docker container.
## 🛠️ Tech Stack
* **Runtime**: [Bun](https://bun.sh/)
* **Bot Framework**: [Discord.js](https://discord.js.org/)
* **Web Framework**: [React 19](https://react.dev/) + [Vite](https://vitejs.dev/) (served via Bun)
* **Styling**: [Tailwind CSS v4](https://tailwindcss.com/) + [Radix UI](https://www.radix-ui.com/)
* **Database**: [PostgreSQL](https://www.postgresql.org/)
* **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
* **Validation**: [Zod](https://zod.dev/)
* **Containerization**: [Docker](https://www.docker.com/)
## 🚀 Getting Started
### Prerequisites
* [Bun](https://bun.sh/) (latest version)
* [Docker](https://www.docker.com/) & Docker Compose
### Installation
1. **Clone the repository**
```bash
git clone <repository-url>
cd aurora
```
2. **Install dependencies**
```bash
bun install
```
2. **Environment Variables**:
Copy `.env.example` to `.env` (create one if it doesn't exist) and fill in the required values:
3. **Environment Setup**
Copy the example environment file and configure it:
```bash
cp .env.example .env
```
Edit `.env` with your Discord bot token, Client ID, and database credentials.
> **Note**: The `DATABASE_URL` in `.env.example` is pre-configured for Docker.
4. **Start the Database**
Run the database service using Docker Compose:
```bash
docker compose up -d db
```
5. **Run Migrations**
```bash
bun run migrate
```
OR
```bash
bun run db:push
```
### Running the Bot & Dashboard
**Development Mode** (with hot reload):
```bash
bun run dev
```
* Bot: Online in Discord
* Dashboard: http://localhost:3000
**Production Mode**:
Build and run with Docker (recommended):
```bash
docker compose up -d app
```
### 🔐 Accessing Production Services (SSH Tunnel)
For security, the Production Database and Dashboard are **not exposed** to the public internet by default. They are only accessible via localhost on the server.
To access them from your local machine, use the included SSH tunnel script.
1. Add your VPS details to your local `.env` file:
```env
DISCORD_BOT_TOKEN=your_token_here
DISCORD_CLIENT_ID=your_client_id
DISCORD_GUILD_ID=your_guild_id_optional
DATABASE_URL=postgres://user:pass@localhost:5432/db_name
```
*Note: The app will fail to start if `DISCORD_BOT_TOKEN` or `DATABASE_URL` are missing or invalid.*
3. **Run Development**:
```bash
bun run dev
VPS_USER=root
VPS_HOST=123.45.67.89
```
4. **Database Migrations**:
2. Run the remote connection script:
```bash
bun run db:push # Apply schema changes
bun run generate # Generate migrations
```
bun run remote
```
This will establish secure tunnels for:
* **Dashboard**: http://localhost:3000
* **Drizzle Studio**: http://localhost:4983
## 📜 Scripts
* `bun run dev`: Start the bot and dashboard in watch mode.
* `bun run remote`: Open SSH tunnel to production services.
* `bun run generate`: Generate Drizzle migrations.
* `bun run migrate`: Apply migrations (via Docker).
* `bun run db:studio`: Open Drizzle Studio to inspect the database.
* `bun test`: Run tests.
## 📂 Project Structure
```
├── bot # Discord Bot logic & entry point
├── web # React Web Dashboard (Frontend + Server)
├── shared # Shared code (Database, Config, Types)
├── drizzle # Drizzle migration files
├── scripts # Utility scripts
├── docker-compose.yml
└── package.json
```
## 🤝 Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## 📄 License
This project is licensed under the MIT License.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@@ -0,0 +1,54 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@shared/modules/moderation/moderation.service";
import { getCaseEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
export const moderationCase = createCommand({
data: new SlashCommandBuilder()
.setName("case")
.setDescription("View details of a specific moderation case")
.addStringOption(option =>
option
.setName("case_id")
.setDescription("The case ID (e.g., CASE-0001)")
.setRequired(true)
)
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
try {
const caseId = interaction.options.getString("case_id", true).toUpperCase();
// Validate case ID format
if (!caseId.match(/^CASE-\d+$/)) {
await interaction.editReply({
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
});
return;
}
// Get the case
const moderationCase = await ModerationService.getCaseById(caseId);
if (!moderationCase) {
await interaction.editReply({
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
});
return;
}
// Display the case
await interaction.editReply({
embeds: [getCaseEmbed(moderationCase)]
});
} catch (error) {
console.error("Case command error:", error);
await interaction.editReply({
embeds: [getModerationErrorEmbed("An error occurred while fetching the case.")]
});
}
}
});

View File

@@ -0,0 +1,54 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@shared/modules/moderation/moderation.service";
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
export const cases = createCommand({
data: new SlashCommandBuilder()
.setName("cases")
.setDescription("View all moderation cases for a user")
.addUserOption(option =>
option
.setName("user")
.setDescription("The user to check cases for")
.setRequired(true)
)
.addBooleanOption(option =>
option
.setName("active_only")
.setDescription("Show only active cases (warnings)")
.setRequired(false)
)
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
try {
const targetUser = interaction.options.getUser("user", true);
const activeOnly = interaction.options.getBoolean("active_only") || false;
// Get cases for the user
const userCases = await ModerationService.getUserCases(targetUser.id, activeOnly);
const title = activeOnly
? `⚠️ Active Cases for ${targetUser.username}`
: `📋 All Cases for ${targetUser.username}`;
const description = userCases.length === 0
? undefined
: `Total cases: **${userCases.length}**`;
// Display the cases
await interaction.editReply({
embeds: [getCasesListEmbed(userCases, title, description)]
});
} catch (error) {
console.error("Cases command error:", error);
await interaction.editReply({
embeds: [getModerationErrorEmbed("An error occurred while fetching cases.")]
});
}
}
});

View File

@@ -0,0 +1,84 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@shared/modules/moderation/moderation.service";
import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
export const clearwarning = createCommand({
data: new SlashCommandBuilder()
.setName("clearwarning")
.setDescription("Clear/resolve a warning")
.addStringOption(option =>
option
.setName("case_id")
.setDescription("The case ID to clear (e.g., CASE-0001)")
.setRequired(true)
)
.addStringOption(option =>
option
.setName("reason")
.setDescription("Reason for clearing the warning")
.setRequired(false)
.setMaxLength(500)
)
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
try {
const caseId = interaction.options.getString("case_id", true).toUpperCase();
const reason = interaction.options.getString("reason") || "Cleared by moderator";
// Validate case ID format
if (!caseId.match(/^CASE-\d+$/)) {
await interaction.editReply({
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
});
return;
}
// Check if case exists and is active
const existingCase = await ModerationService.getCaseById(caseId);
if (!existingCase) {
await interaction.editReply({
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
});
return;
}
if (!existingCase.active) {
await interaction.editReply({
embeds: [getModerationErrorEmbed(`Case **${caseId}** is already resolved.`)]
});
return;
}
if (existingCase.type !== 'warn') {
await interaction.editReply({
embeds: [getModerationErrorEmbed(`Case **${caseId}** is not a warning. Only warnings can be cleared.`)]
});
return;
}
// Clear the warning
await ModerationService.clearCase({
caseId,
clearedBy: interaction.user.id,
clearedByName: interaction.user.username,
reason
});
// Send success message
await interaction.editReply({
embeds: [getClearSuccessEmbed(caseId)]
});
} catch (error) {
console.error("Clear warning command error:", error);
await interaction.editReply({
embeds: [getModerationErrorEmbed("An error occurred while clearing the warning.")]
});
}
}
});

View File

@@ -0,0 +1,68 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, ModalSubmitInteraction } from "discord.js";
import { config, saveConfig } from "@shared/lib/config";
import type { GameConfigType } from "@shared/lib/config";
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
export const configCommand = createCommand({
data: new SlashCommandBuilder()
.setName("config")
.setDescription("Edit the bot configuration")
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
console.log(`Config command executed by ${interaction.user.tag}`);
const replacer = (key: string, value: any) => {
if (typeof value === 'bigint') {
return value.toString();
}
return value;
};
const currentConfigJson = JSON.stringify(config, replacer, 4);
const modal = new ModalBuilder()
.setCustomId("config-modal")
.setTitle("Edit Configuration");
const jsonInput = new TextInputBuilder()
.setCustomId("json-input")
.setLabel("Configuration JSON")
.setStyle(TextInputStyle.Paragraph)
.setValue(currentConfigJson)
.setRequired(true);
const actionRow = new ActionRowBuilder<TextInputBuilder>().addComponents(jsonInput);
modal.addComponents(actionRow);
await interaction.showModal(modal);
try {
const submitted = await interaction.awaitModalSubmit({
time: 300000, // 5 minutes
filter: (i) => i.customId === "config-modal" && i.user.id === interaction.user.id
});
const jsonString = submitted.fields.getTextInputValue("json-input");
try {
const newConfig = JSON.parse(jsonString);
saveConfig(newConfig as GameConfigType);
await submitted.reply({
embeds: [createSuccessEmbed("Configuration updated successfully.", "Config Saved")]
});
} catch (parseError) {
await submitted.reply({
embeds: [createErrorEmbed(`Invalid JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`, "Config Update Failed")],
ephemeral: true
});
}
} catch (error) {
// Timeout or other error handling if needed, usually just ignore timeouts for modals
if (error instanceof Error && error.message.includes('time')) {
// specific timeout handling if desired
}
}
}
});

View File

@@ -0,0 +1,94 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, AttachmentBuilder } from "discord.js";
import { config, saveConfig } from "@shared/lib/config";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { items } from "@db/schema";
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
export const createColor = createCommand({
data: new SlashCommandBuilder()
.setName("createcolor")
.setDescription("Create a new Color Role and corresponding Item")
.addStringOption(option =>
option.setName("name")
.setDescription("The name of the role and item")
.setRequired(true)
)
.addStringOption(option =>
option.setName("color")
.setDescription("The hex color code (e.g. #FF0000)")
.setRequired(true)
)
.addNumberOption(option =>
option.setName("price")
.setDescription("Price of the item (Default: 500)")
.setRequired(false)
)
.addStringOption(option =>
option.setName("image")
.setDescription("Image URL for the item")
.setRequired(false)
)
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
await interaction.deferReply();
const name = interaction.options.getString("name", true);
const colorInput = interaction.options.getString("color", true);
const price = interaction.options.getNumber("price") || 500;
const imageUrl = interaction.options.getString("image") || "https://cdn.discordapp.com/attachments/1450061247365124199/1453122950822760559/Main_Chip_1.png";
// 1. Validate Color
const colorRegex = /^#([0-9A-F]{3}){1,2}$/i;
if (!colorRegex.test(colorInput)) {
await interaction.editReply({ embeds: [createErrorEmbed("Invalid Hex Color code. Format: #RRGGBB")] });
return;
}
try {
// 2. Create Role
const role = await interaction.guild?.roles.create({
name: name,
color: colorInput as any, // Discord.js types are a bit strict on ColorResolvable, but string generally works or needs parsing
reason: `Created via /createcolor by ${interaction.user.tag}`
});
if (!role) {
throw new Error("Failed to create role.");
}
// 3. Update Config
if (!config.colorRoles.includes(role.id)) {
config.colorRoles.push(role.id);
saveConfig(config);
}
// 4. Create Item
await DrizzleClient.insert(items).values({
name: `Color Role - ${name}`,
description: `Use this item to apply the ${name} color to your name.`,
type: "CONSUMABLE",
rarity: "Common",
price: BigInt(price),
iconUrl: "",
imageUrl: imageUrl,
usageData: {
consume: false,
effects: [{ type: "COLOR_ROLE", roleId: role.id }]
} as any
});
// 5. Success
await interaction.editReply({
embeds: [createSuccessEmbed(
`**Role:** <@&${role.id}> (${colorInput})\n**Item:** Color Role - ${name}\n**Price:** ${price} 🪙`,
"✅ Color Role & Item Created"
)]
});
} catch (error: any) {
console.error("Error in createcolor:", error);
await interaction.editReply({ embeds: [createErrorEmbed(`Failed to create color role: ${error.message}`)] });
}
}
});

View File

@@ -0,0 +1,14 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { renderWizard } from "@/modules/admin/item_wizard";
export const createItem = createCommand({
data: new SlashCommandBuilder()
.setName("createitem")
.setDescription("Create a new item using the interactive wizard")
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
const payload = renderWizard(interaction.user.id);
await interaction.reply({ ...payload, flags: MessageFlags.Ephemeral });
}
});

View File

@@ -0,0 +1,94 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { createBaseEmbed } from "@lib/embeds";
import { config, reloadConfig, toggleCommand } from "@shared/lib/config";
import { AuroraClient } from "@/lib/BotClient";
export const features = createCommand({
data: new SlashCommandBuilder()
.setName("features")
.setDescription("Manage bot features and commands")
.addSubcommand(sub =>
sub.setName("list")
.setDescription("List all commands and their status")
)
.addSubcommand(sub =>
sub.setName("toggle")
.setDescription("Enable or disable a command")
.addStringOption(option =>
option.setName("command")
.setDescription("The name of the command")
.setRequired(true)
)
.addBooleanOption(option =>
option.setName("enabled")
.setDescription("Whether the command should be enabled")
.setRequired(true)
)
)
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
const subcommand = interaction.options.getSubcommand();
if (subcommand === "list") {
const activeCommands = AuroraClient.commands;
const categories = new Map<string, string[]>();
// Group active commands
activeCommands.forEach(cmd => {
const cat = cmd.category || 'Uncategorized';
if (!categories.has(cat)) categories.set(cat, []);
categories.get(cat)!.push(cmd.data.name);
});
// Config overrides
const overrides = Object.entries(config.commands)
.map(([name, enabled]) => `• **${name}**: ${enabled ? "✅ Enabled (Override)" : "❌ Disabled"}`);
const embed = createBaseEmbed("Command Features", undefined, "Blue");
// Add fields for each category
const sortedCategories = [...categories.keys()].sort();
for (const cat of sortedCategories) {
const cmds = categories.get(cat)!.sort();
const cmdList = cmds.map(name => {
const isOverride = config.commands[name] !== undefined;
return isOverride ? `**${name}** (See Overrides)` : `**${name}**`;
}).join(", ");
embed.addFields({ name: `📂 ${cat.toUpperCase()}`, value: cmdList || "None" });
}
if (overrides.length > 0) {
embed.addFields({ name: "⚙️ Configuration Overrides", value: overrides.join("\n") });
} else {
embed.addFields({ name: "⚙️ Configuration Overrides", value: "No overrides set." });
}
// Check permissions manually as a fallback (though defaultMemberPermissions handles it at the API level)
if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) {
await interaction.reply({ content: "❌ You need Administrator permissions to use this command.", flags: MessageFlags.Ephemeral });
return;
}
await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral });
} else if (subcommand === "toggle") {
const commandName = interaction.options.getString("command", true);
const enabled = interaction.options.getBoolean("enabled", true);
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
toggleCommand(commandName, enabled);
await interaction.editReply({ content: `✅ Command **${commandName}** has been ${enabled ? "enabled" : "disabled"}. Reloading configuration...` });
// Reload config from disk (which was updated by toggleCommand)
reloadConfig();
await AuroraClient.loadCommands(true);
await AuroraClient.deployCommands();
await interaction.editReply({ content: `✅ Command **${commandName}** has been ${enabled ? "enabled" : "disabled"}. Commands reloaded!` });
}
}
});

View File

@@ -0,0 +1,84 @@
import { describe, test, expect, mock, beforeEach } from "bun:test";
import { health } from "./health";
import { ChatInputCommandInteraction, Colors } from "discord.js";
import { AuroraClient } from "@/lib/BotClient";
// Mock DrizzleClient
const executeMock = mock(() => Promise.resolve());
mock.module("@shared/db/DrizzleClient", () => ({
DrizzleClient: {
execute: executeMock
}
}));
// Mock BotClient (already has lastCommandTimestamp if imported, but we might want to control it)
AuroraClient.lastCommandTimestamp = 1641481200000; // Fixed timestamp for testing
describe("Health Command", () => {
beforeEach(() => {
executeMock.mockClear();
});
test("should execute successfully and return health embed", async () => {
const interaction = {
deferReply: mock(() => Promise.resolve()),
editReply: mock(() => Promise.resolve()),
client: {
ws: {
ping: 42
}
},
user: { id: "123", username: "testuser" },
commandName: "health"
} as unknown as ChatInputCommandInteraction;
await health.execute(interaction);
expect(interaction.deferReply).toHaveBeenCalled();
expect(executeMock).toHaveBeenCalled();
expect(interaction.editReply).toHaveBeenCalled();
const editReplyCall = (interaction.editReply as any).mock.calls[0][0];
const embed = editReplyCall.embeds[0];
expect(embed.data.title).toBe("System Health Status");
expect(embed.data.color).toBe(Colors.Aqua);
// Check fields
const fields = embed.data.fields;
expect(fields).toBeDefined();
// Connectivity field
const connectivityField = fields.find((f: any) => f.name === "📡 Connectivity");
expect(connectivityField.value).toContain("42ms");
expect(connectivityField.value).toContain("Connected");
// Activity field
const activityField = fields.find((f: any) => f.name === "⌨️ Activity");
expect(activityField.value).toContain("R>"); // Relative Discord timestamp
});
test("should handle database disconnection", async () => {
executeMock.mockImplementationOnce(() => Promise.reject(new Error("DB Down")));
const interaction = {
deferReply: mock(() => Promise.resolve()),
editReply: mock(() => Promise.resolve()),
client: {
ws: {
ping: 42
}
},
user: { id: "123", username: "testuser" },
commandName: "health"
} as unknown as ChatInputCommandInteraction;
await health.execute(interaction);
const editReplyCall = (interaction.editReply as any).mock.calls[0][0];
const embed = editReplyCall.embeds[0];
const connectivityField = embed.data.fields.find((f: any) => f.name === "📡 Connectivity");
expect(connectivityField.value).toContain("Disconnected");
});
});

View File

@@ -0,0 +1,60 @@
import { createCommand } from "@shared/lib/utils";
import { AuroraClient } from "@/lib/BotClient";
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, Colors } from "discord.js";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { sql } from "drizzle-orm";
import { createBaseEmbed } from "@lib/embeds";
export const health = createCommand({
data: new SlashCommandBuilder()
.setName("health")
.setDescription("Check the bot's health status")
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
await interaction.deferReply();
// 1. Check Discord API latency
const wsPing = interaction.client.ws.ping;
// 2. Verify database connection
let dbStatus = "Connected";
let dbPing = -1;
try {
const start = Date.now();
await DrizzleClient.execute(sql`SELECT 1`);
dbPing = Date.now() - start;
} catch (error) {
dbStatus = "Disconnected";
console.error("Health check DB error:", error);
}
// 3. Uptime
const uptime = process.uptime();
const days = Math.floor(uptime / 86400);
const hours = Math.floor((uptime % 86400) / 3600);
const minutes = Math.floor((uptime % 3600) / 60);
const seconds = Math.floor(uptime % 60);
const uptimeString = `${days}d ${hours}h ${minutes}m ${seconds}s`;
// 4. Memory usage
const memory = process.memoryUsage();
const heapUsed = (memory.heapUsed / 1024 / 1024).toFixed(2);
const heapTotal = (memory.heapTotal / 1024 / 1024).toFixed(2);
const rss = (memory.rss / 1024 / 1024).toFixed(2);
// 5. Last successful command
const lastCommand = AuroraClient.lastCommandTimestamp
? `<t:${Math.floor(AuroraClient.lastCommandTimestamp / 1000)}:R>`
: "None since startup";
const embed = createBaseEmbed("System Health Status", undefined, Colors.Aqua)
.addFields(
{ name: "📡 Connectivity", value: `**Discord WS:** ${wsPing}ms\n**Database:** ${dbStatus} ${dbPing >= 0 ? `(${dbPing}ms)` : ""}`, inline: true },
{ name: "⏱️ Uptime", value: uptimeString, inline: true },
{ name: "🧠 Memory Usage", value: `**RSS:** ${rss} MB\n**Heap:** ${heapUsed} / ${heapTotal} MB`, inline: false },
{ name: "⌨️ Activity", value: `**Last Command:** ${lastCommand}`, inline: true }
);
await interaction.editReply({ embeds: [embed] });
}
});

View File

@@ -0,0 +1,99 @@
import { createCommand } from "@shared/lib/utils";
import {
SlashCommandBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
type BaseGuildTextChannel,
PermissionFlagsBits,
MessageFlags
} from "discord.js";
import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { createSuccessEmbed, createErrorEmbed, createBaseEmbed } from "@lib/embeds";
import { UserError } from "@shared/lib/errors";
import { items } from "@db/schema";
import { ilike, isNotNull, and } from "drizzle-orm";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { getShopListingMessage } from "@/modules/economy/shop.view";
export const listing = createCommand({
data: new SlashCommandBuilder()
.setName("listing")
.setDescription("Post an item listing in the channel for users to buy")
.addNumberOption(option =>
option.setName("item")
.setDescription("The item to list")
.setRequired(true)
.setAutocomplete(true)
)
.addChannelOption(option =>
option.setName("channel")
.setDescription("The channel to post the listing in (defaults to current)")
.setRequired(false)
)
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const itemId = interaction.options.getNumber("item", true);
const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel;
if (!targetChannel || !targetChannel.isSendable()) {
await interaction.editReply({ content: "", embeds: [createErrorEmbed("Target channel is invalid or not sendable.")] });
return;
}
const item = await inventoryService.getItem(itemId);
if (!item) {
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item with ID ${itemId} not found.`)] });
return;
}
if (!item.price) {
await interaction.editReply({ content: "", embeds: [createErrorEmbed(`Item "${item.name}" is not for sale (no price set).`)] });
return;
}
const listingMessage = getShopListingMessage({
...item,
formattedPrice: `${item.price} 🪙`,
price: item.price
});
try {
await targetChannel.send(listingMessage);
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
} catch (error: any) {
if (error instanceof UserError) {
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
} else {
console.error("Error creating listing:", error);
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
}
}
},
autocomplete: async (interaction) => {
const focusedValue = interaction.options.getFocused();
const results = await DrizzleClient.select({
id: items.id,
name: items.name,
price: items.price
})
.from(items)
.where(
and(
ilike(items.name, `%${focusedValue}%`),
isNotNull(items.price)
)
)
.limit(20);
await interaction.respond(
results.map(item => ({
name: `${item.name} (Price: ${item.price})`,
value: item.id
}))
);
}
});

View File

@@ -0,0 +1,62 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@shared/modules/moderation/moderation.service";
import { CaseType } from "@shared/lib/constants";
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
export const note = createCommand({
data: new SlashCommandBuilder()
.setName("note")
.setDescription("Add a staff-only note about a user")
.addUserOption(option =>
option
.setName("user")
.setDescription("The user to add a note for")
.setRequired(true)
)
.addStringOption(option =>
option
.setName("note")
.setDescription("The note to add")
.setRequired(true)
.setMaxLength(1000)
)
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
try {
const targetUser = interaction.options.getUser("user", true);
const noteText = interaction.options.getString("note", true);
// Create the note case
const moderationCase = await ModerationService.createCase({
type: CaseType.NOTE,
userId: targetUser.id,
username: targetUser.username,
moderatorId: interaction.user.id,
moderatorName: interaction.user.username,
reason: noteText,
});
if (!moderationCase) {
await interaction.editReply({
embeds: [getModerationErrorEmbed("Failed to create note.")]
});
return;
}
// Send success message
await interaction.editReply({
embeds: [getNoteSuccessEmbed(moderationCase.caseId, targetUser.username)]
});
} catch (error) {
console.error("Note command error:", error);
await interaction.editReply({
embeds: [getModerationErrorEmbed("An error occurred while adding the note.")]
});
}
}
});

View File

@@ -0,0 +1,43 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@shared/modules/moderation/moderation.service";
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
export const notes = createCommand({
data: new SlashCommandBuilder()
.setName("notes")
.setDescription("View all staff notes for a user")
.addUserOption(option =>
option
.setName("user")
.setDescription("The user to check notes for")
.setRequired(true)
)
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
try {
const targetUser = interaction.options.getUser("user", true);
// Get all notes for the user
const userNotes = await ModerationService.getUserNotes(targetUser.id);
// Display the notes
await interaction.editReply({
embeds: [getCasesListEmbed(
userNotes,
`📝 Staff Notes for ${targetUser.username}`,
userNotes.length === 0 ? undefined : `Total notes: **${userNotes.length}**`
)]
});
} catch (error) {
console.error("Notes command error:", error);
await interaction.editReply({
embeds: [getModerationErrorEmbed("An error occurred while fetching notes.")]
});
}
}
});

179
bot/commands/admin/prune.ts Normal file
View File

@@ -0,0 +1,179 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
import { config } from "@shared/lib/config";
import { PruneService } from "@shared/modules/moderation/prune.service";
import {
getConfirmationMessage,
getProgressEmbed,
getSuccessEmbed,
getPruneErrorEmbed,
getPruneWarningEmbed,
getCancelledEmbed
} from "@/modules/moderation/prune.view";
export const prune = createCommand({
data: new SlashCommandBuilder()
.setName("prune")
.setDescription("Delete messages in bulk (admin only)")
.addIntegerOption(option =>
option
.setName("amount")
.setDescription(`Number of messages to delete (1-${config.moderation?.prune?.maxAmount || 100})`)
.setRequired(false)
.setMinValue(1)
.setMaxValue(config.moderation?.prune?.maxAmount || 100)
)
.addUserOption(option =>
option
.setName("user")
.setDescription("Only delete messages from this user")
.setRequired(false)
)
.addBooleanOption(option =>
option
.setName("all")
.setDescription("Delete all messages in the channel")
.setRequired(false)
)
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
try {
const amount = interaction.options.getInteger("amount");
const user = interaction.options.getUser("user");
const all = interaction.options.getBoolean("all") || false;
// Validate inputs
if (!amount && !all) {
// Default to 10 messages
} else if (amount && all) {
await interaction.editReply({
embeds: [getPruneErrorEmbed("Cannot specify both `amount` and `all`. Please use one or the other.")]
});
return;
}
const finalAmount = all ? 'all' : (amount || 10);
const confirmThreshold = config.moderation.prune.confirmThreshold;
// Check if confirmation is needed
const needsConfirmation = all || (typeof finalAmount === 'number' && finalAmount > confirmThreshold);
if (needsConfirmation) {
// Estimate message count for confirmation
let estimatedCount: number | undefined;
if (all) {
try {
estimatedCount = await PruneService.estimateMessageCount(interaction.channel!);
} catch {
estimatedCount = undefined;
}
}
const { embeds, components } = getConfirmationMessage(finalAmount, estimatedCount);
const response = await interaction.editReply({ embeds, components });
try {
const confirmation = await response.awaitMessageComponent({
filter: (i) => i.user.id === interaction.user.id,
componentType: ComponentType.Button,
time: 30000
});
if (confirmation.customId === "cancel_prune") {
await confirmation.update({
embeds: [getCancelledEmbed()],
components: []
});
return;
}
// User confirmed, proceed with deletion
await confirmation.update({
embeds: [getProgressEmbed({ current: 0, total: estimatedCount || finalAmount as number })],
components: []
});
// Execute deletion with progress callback for 'all' mode
const result = await PruneService.deleteMessages(
interaction.channel!,
{
amount: typeof finalAmount === 'number' ? finalAmount : undefined,
userId: user?.id,
all
},
all ? async (progress) => {
await interaction.editReply({
embeds: [getProgressEmbed(progress)]
});
} : undefined
);
// Show success
await interaction.editReply({
embeds: [getSuccessEmbed(result)],
components: []
});
} catch (error) {
if (error instanceof Error && error.message.includes("time")) {
await interaction.editReply({
embeds: [getPruneWarningEmbed("Confirmation timed out. Please try again.")],
components: []
});
} else {
throw error;
}
}
} else {
// No confirmation needed, proceed directly
const result = await PruneService.deleteMessages(
interaction.channel!,
{
amount: finalAmount as number,
userId: user?.id,
all: false
}
);
// Check if no messages were found
if (result.deletedCount === 0) {
if (user) {
await interaction.editReply({
embeds: [getPruneWarningEmbed(`No messages found from **${user.username}** in the last ${finalAmount} messages.`)]
});
} else {
await interaction.editReply({
embeds: [getPruneWarningEmbed("No messages found to delete.")]
});
}
return;
}
await interaction.editReply({
embeds: [getSuccessEmbed(result)]
});
}
} catch (error) {
console.error("Prune command error:", error);
let errorMessage = "An unexpected error occurred while trying to delete messages.";
if (error instanceof Error) {
if (error.message.includes("permission")) {
errorMessage = "I don't have permission to delete messages in this channel.";
} else if (error.message.includes("channel type")) {
errorMessage = "This command cannot be used in this type of channel.";
} else {
errorMessage = error.message;
}
}
await interaction.editReply({
embeds: [getPruneErrorEmbed(errorMessage)]
});
}
}
});

View File

@@ -0,0 +1,33 @@
import { createCommand } from "@shared/lib/utils";
import { AuroraClient } from "@/lib/BotClient";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
export const refresh = createCommand({
data: new SlashCommandBuilder()
.setName("refresh")
.setDescription("Reloads all commands and config without restarting")
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
try {
const start = Date.now();
await AuroraClient.loadCommands(true);
const duration = Date.now() - start;
// Deploy commands
await AuroraClient.deployCommands();
const embed = createSuccessEmbed(
`Successfully reloaded ${AuroraClient.commands.size} commands in ${duration}ms.`,
"System Refreshed"
);
await interaction.editReply({ embeds: [embed] });
} catch (error) {
console.error(error);
await interaction.editReply({ embeds: [createErrorEmbed("An error occurred while refreshing commands. Check console for details.", "Refresh Failed")] });
}
}
});

View File

@@ -0,0 +1,37 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js";
import { terminalService } from "@shared/modules/terminal/terminal.service";
import { createBaseEmbed, createErrorEmbed } from "@/lib/embeds";
export const terminal = createCommand({
data: new SlashCommandBuilder()
.setName("terminal")
.setDescription("Manage the Aurora Terminal")
.addSubcommand(sub =>
sub.setName("init")
.setDescription("Initialize the terminal in the current channel")
)
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
const subcommand = interaction.options.getSubcommand();
if (subcommand === "init") {
const channel = interaction.channel;
if (!channel || channel.type !== ChannelType.GuildText) {
await interaction.reply({ embeds: [createErrorEmbed("Terminal can only be initialized in text channels.")] });
return;
}
await interaction.reply({ ephemeral: true, content: "Initializing terminal..." });
try {
await terminalService.init(channel as TextChannel);
await interaction.editReply({ content: "✅ Terminal initialized!" });
} catch (error) {
console.error(error);
await interaction.editReply({ content: "❌ Failed to initialize terminal." });
}
}
}
});

View File

@@ -0,0 +1,176 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
import { UpdateService } from "@shared/modules/admin/update.service";
import {
getCheckingEmbed,
getNoUpdatesEmbed,
getUpdatesAvailableMessage,
getPreparingEmbed,
getUpdatingEmbed,
getCancelledEmbed,
getTimeoutEmbed,
getErrorEmbed,
getRollbackSuccessEmbed,
getRollbackFailedEmbed
} from "@/modules/admin/update.view";
export const update = createCommand({
data: new SlashCommandBuilder()
.setName("update")
.setDescription("Check for updates and restart the bot")
.addSubcommand(sub =>
sub.setName("check")
.setDescription("Check for and apply available updates")
.addBooleanOption(option =>
option.setName("force")
.setDescription("Force update even if no changes detected")
.setRequired(false)
)
)
.addSubcommand(sub =>
sub.setName("rollback")
.setDescription("Rollback to the previous version")
)
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
const subcommand = interaction.options.getSubcommand();
if (subcommand === "rollback") {
await handleRollback(interaction);
} else {
await handleUpdate(interaction);
}
}
});
async function handleUpdate(interaction: any) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const force = interaction.options.getBoolean("force") || false;
try {
// 1. Check for updates
await interaction.editReply({ embeds: [getCheckingEmbed()] });
const updateInfo = await UpdateService.checkForUpdates();
if (!updateInfo.hasUpdates && !force) {
await interaction.editReply({
embeds: [getNoUpdatesEmbed(updateInfo.currentCommit)]
});
return;
}
// 2. Analyze requirements
const requirements = await UpdateService.checkUpdateRequirements(updateInfo.branch);
const categories = UpdateService.categorizeChanges(requirements.changedFiles);
// 3. Show confirmation with details
const { embeds, components } = getUpdatesAvailableMessage(
updateInfo,
requirements,
categories,
force
);
const response = await interaction.editReply({ embeds, components });
// 4. Wait for confirmation
try {
const confirmation = await response.awaitMessageComponent({
filter: (i: any) => i.user.id === interaction.user.id,
componentType: ComponentType.Button,
time: 30000
});
if (confirmation.customId === "confirm_update") {
await confirmation.update({
embeds: [getPreparingEmbed()],
components: []
});
// 5. Save rollback point
const previousCommit = await UpdateService.saveRollbackPoint();
// 6. Prepare restart context
await UpdateService.prepareRestartContext({
channelId: interaction.channelId,
userId: interaction.user.id,
timestamp: Date.now(),
runMigrations: requirements.needsMigrations,
installDependencies: requirements.needsRootInstall || requirements.needsWebInstall,
previousCommit: previousCommit.substring(0, 7),
newCommit: updateInfo.latestCommit
});
// 7. Show updating status
await interaction.editReply({
embeds: [getUpdatingEmbed(requirements)]
});
// 8. Perform update
await UpdateService.performUpdate(updateInfo.branch);
// 9. Trigger restart
await UpdateService.triggerRestart();
} else {
await confirmation.update({
embeds: [getCancelledEmbed()],
components: []
});
}
} catch (e) {
if (e instanceof Error && e.message.includes("time")) {
await interaction.editReply({
embeds: [getTimeoutEmbed()],
components: []
});
} else {
throw e;
}
}
} catch (error) {
console.error("Update failed:", error);
await interaction.editReply({
embeds: [getErrorEmbed(error)],
components: []
});
}
}
async function handleRollback(interaction: any) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
try {
const hasRollback = await UpdateService.hasRollbackPoint();
if (!hasRollback) {
await interaction.editReply({
embeds: [getRollbackFailedEmbed("No rollback point available. Rollback is only possible after a recent update.")]
});
return;
}
const result = await UpdateService.rollback();
if (result.success) {
await interaction.editReply({
embeds: [getRollbackSuccessEmbed(result.message.split(" ").pop() || "unknown")]
});
// Restart after rollback
setTimeout(() => UpdateService.triggerRestart(), 1000);
} else {
await interaction.editReply({
embeds: [getRollbackFailedEmbed(result.message)]
});
}
} catch (error) {
console.error("Rollback failed:", error);
await interaction.editReply({
embeds: [getErrorEmbed(error)]
});
}
}

View File

@@ -0,0 +1,87 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@shared/modules/moderation/moderation.service";
import {
getWarnSuccessEmbed,
getModerationErrorEmbed,
getUserWarningEmbed
} from "@/modules/moderation/moderation.view";
import { config } from "@shared/lib/config";
export const warn = createCommand({
data: new SlashCommandBuilder()
.setName("warn")
.setDescription("Issue a warning to a user")
.addUserOption(option =>
option
.setName("user")
.setDescription("The user to warn")
.setRequired(true)
)
.addStringOption(option =>
option
.setName("reason")
.setDescription("Reason for the warning")
.setRequired(true)
.setMaxLength(1000)
)
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
try {
const targetUser = interaction.options.getUser("user", true);
const reason = interaction.options.getString("reason", true);
// Don't allow warning bots
if (targetUser.bot) {
await interaction.editReply({
embeds: [getModerationErrorEmbed("You cannot warn bots.")]
});
return;
}
// Don't allow self-warnings
if (targetUser.id === interaction.user.id) {
await interaction.editReply({
embeds: [getModerationErrorEmbed("You cannot warn yourself.")]
});
return;
}
// Issue the warning via service
const { moderationCase, warningCount, autoTimeoutIssued } = await ModerationService.issueWarning({
userId: targetUser.id,
username: targetUser.username,
moderatorId: interaction.user.id,
moderatorName: interaction.user.username,
reason,
guildName: interaction.guild?.name || undefined,
dmTarget: targetUser,
timeoutTarget: await interaction.guild?.members.fetch(targetUser.id)
});
// Send success message to moderator
await interaction.editReply({
embeds: [getWarnSuccessEmbed(moderationCase.caseId, targetUser.username, reason)]
});
// Follow up if auto-timeout was issued
if (autoTimeoutIssued) {
await interaction.followUp({
embeds: [getModerationErrorEmbed(
`⚠️ User has reached ${warningCount} warnings and has been automatically timed out for 24 hours.`
)],
flags: MessageFlags.Ephemeral
});
}
} catch (error) {
console.error("Warn command error:", error);
await interaction.editReply({
embeds: [getModerationErrorEmbed("An error occurred while issuing the warning.")]
});
}
}
});

View File

@@ -0,0 +1,39 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@shared/modules/moderation/moderation.service";
import { getWarningsEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
export const warnings = createCommand({
data: new SlashCommandBuilder()
.setName("warnings")
.setDescription("View active warnings for a user")
.addUserOption(option =>
option
.setName("user")
.setDescription("The user to check warnings for")
.setRequired(true)
)
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
try {
const targetUser = interaction.options.getUser("user", true);
// Get active warnings for the user
const activeWarnings = await ModerationService.getUserWarnings(targetUser.id);
// Display the warnings
await interaction.editReply({
embeds: [getWarningsEmbed(activeWarnings, targetUser.username)]
});
} catch (error) {
console.error("Warnings command error:", error);
await interaction.editReply({
embeds: [getModerationErrorEmbed("An error occurred while fetching warnings.")]
});
}
}
});

View File

@@ -1,6 +1,7 @@
import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, TextChannel, NewsChannel, VoiceChannel } from "discord.js";
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { createErrorEmbed } from "@/lib/embeds";
import { sendWebhookMessage } from "@/lib/webhookUtils";
export const webhook = createCommand({
data: new SlashCommandBuilder()
@@ -13,7 +14,7 @@ export const webhook = createCommand({
.setRequired(true)
),
execute: async (interaction) => {
await interaction.deferReply({ ephemeral: true });
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const payloadString = interaction.options.getString("payload", true);
let payload;
@@ -36,37 +37,17 @@ export const webhook = createCommand({
return;
}
let webhook;
try {
webhook = await channel.createWebhook({
name: `${interaction.client.user.username} - Proxy`,
avatar: interaction.client.user.displayAvatarURL(),
reason: `Proxy message requested by ${interaction.user.tag}`
});
// Support snake_case keys for raw API compatibility
if (payload.avatar_url && !payload.avatarURL) {
payload.avatarURL = payload.avatar_url;
delete payload.avatar_url;
}
await webhook.send(payload);
await webhook.delete("Proxy message sent");
await sendWebhookMessage(
channel,
payload,
interaction.client.user,
`Proxy message requested by ${interaction.user.tag}`
);
await interaction.editReply({ content: "Message sent successfully!" });
} catch (error) {
console.error("Webhook error:", error);
// Attempt cleanup if webhook was created but sending failed
if (webhook) {
try {
await webhook.delete("Cleanup after failure");
} catch (cleanupError) {
console.error("Failed to delete webhook during cleanup:", cleanupError);
}
}
await interaction.editReply({
embeds: [createErrorEmbed("Failed to send message via webhook. Ensure the bot has 'Manage Webhooks' permission and the payload is valid.", "Delivery Failed")]
});

View File

@@ -1,6 +1,7 @@
import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder, EmbedBuilder } from "discord.js";
import { userService } from "@/modules/user/user.service";
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js";
import { userService } from "@shared/modules/user/user.service";
import { createBaseEmbed } from "@lib/embeds";
export const balance = createCommand({
data: new SlashCommandBuilder()
@@ -15,12 +16,17 @@ export const balance = createCommand({
await interaction.deferReply();
const targetUser = interaction.options.getUser("user") || interaction.user;
if (targetUser.bot) {
return;
}
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
const embed = new EmbedBuilder()
.setAuthor({ name: targetUser.username, iconURL: targetUser.displayAvatarURL() })
.setDescription(`**Balance**: ${user.balance || 0n} AU`)
.setColor("Yellow");
if (!user) throw new Error("Failed to retrieve user data.");
const embed = createBaseEmbed(undefined, `**Balance**: ${user.balance || 0n} AU`, "Yellow")
.setAuthor({ name: targetUser.username, iconURL: targetUser.displayAvatarURL() });
await interaction.editReply({ embeds: [embed] });
}

View File

@@ -0,0 +1,36 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js";
import { economyService } from "@shared/modules/economy/economy.service";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { UserError } from "@shared/lib/errors";
export const daily = createCommand({
data: new SlashCommandBuilder()
.setName("daily")
.setDescription("Claim your daily reward"),
execute: async (interaction) => {
await interaction.deferReply();
try {
const result = await economyService.claimDaily(interaction.user.id);
const embed = createSuccessEmbed(`You claimed ** ${result.amount}** Astral Units!${result.isWeekly ? `\n🎉 **Weekly Bonus!** +${result.weeklyBonus} extra!` : ''}`, "💰 Daily Reward Claimed!")
.addFields(
{ name: "Streak", value: `🔥 ${result.streak} days`, inline: true },
{ name: "Weekly Progress", value: `${"🟩".repeat(result.streak % 7 || 7)}${"⬜".repeat(7 - (result.streak % 7 || 7))} (${result.streak % 7 || 7}/7)`, inline: true },
{ name: "Next Reward", value: `<t:${Math.floor(result.nextReadyAt.getTime() / 1000)}:R> `, inline: true }
)
.setColor("Gold");
await interaction.editReply({ embeds: [embed] });
} catch (error: any) {
if (error instanceof UserError) {
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
} else {
console.error("Error claiming daily:", error);
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
}
}
}
});

View File

@@ -0,0 +1,75 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { examService, ExamStatus } from "@shared/modules/economy/exam.service";
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
export const exam = createCommand({
data: new SlashCommandBuilder()
.setName("exam")
.setDescription("Take your weekly exam to earn rewards based on your XP progress."),
execute: async (interaction) => {
await interaction.deferReply();
try {
// First, try to take the exam or check status
const result = await examService.takeExam(interaction.user.id);
if (result.status === ExamStatus.NOT_REGISTERED) {
// Register the user
const regResult = await examService.registerForExam(interaction.user.id, interaction.user.username);
const nextRegTimestamp = Math.floor(regResult.nextExamAt!.getTime() / 1000);
await interaction.editReply({
embeds: [createSuccessEmbed(
`You have registered for the exam! Your exam day is **${DAYS[regResult.examDay!]}** (Server Time).\n` +
`Come back on <t:${nextRegTimestamp}:D> (<t:${nextRegTimestamp}:R>) to take your first exam!`,
"Exam Registration Successful"
)]
});
return;
}
const nextExamTimestamp = Math.floor(result.nextExamAt!.getTime() / 1000);
if (result.status === ExamStatus.COOLDOWN) {
await interaction.editReply({
embeds: [createErrorEmbed(
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
`Next exam available: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`
)]
});
return;
}
if (result.status === ExamStatus.MISSED) {
await interaction.editReply({
embeds: [createErrorEmbed(
`You missed your exam day! Your exam day is **${DAYS[result.examDay!]}** (Server Time).\n` +
`You verify your attendance but score a **0**.\n` +
`Your next exam opportunity is: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`,
"Exam Failed"
)]
});
return;
}
// If it reached here with AVAILABLE, it means they passed
await interaction.editReply({
embeds: [createSuccessEmbed(
`**XP Gained:** ${result.xpDiff?.toString()}\n` +
`**Multiplier:** x${result.multiplier?.toFixed(2)}\n` +
`**Reward:** ${result.reward?.toString()} Currency\n\n` +
`See you next week: <t:${nextExamTimestamp}:D>`,
"Exam Passed!"
)]
});
} catch (error: any) {
console.error("Error in exam command:", error);
await interaction.editReply({ embeds: [createErrorEmbed(error.message || "An unexpected error occurred.")] });
}
}
});

View File

@@ -0,0 +1,69 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, MessageFlags } from "discord.js";
import { economyService } from "@shared/modules/economy/economy.service";
import { userService } from "@shared/modules/user/user.service";
import { config } from "@shared/lib/config";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { UserError } from "@shared/lib/errors";
export const pay = createCommand({
data: new SlashCommandBuilder()
.setName("pay")
.setDescription("Transfer Astral Units to another user")
.addUserOption(option =>
option.setName("user")
.setDescription("The user to pay")
.setRequired(true)
)
.addIntegerOption(option =>
option.setName("amount")
.setDescription("Amount to transfer")
.setMinValue(1)
.setRequired(true)
),
execute: async (interaction) => {
const targetUser = await userService.getOrCreateUser(interaction.options.getUser("user", true).id, interaction.options.getUser("user", true).username);
const discordUser = interaction.options.getUser("user", true);
if (discordUser.bot) {
await interaction.reply({ embeds: [createErrorEmbed("You cannot send money to bots.")], flags: MessageFlags.Ephemeral });
return;
}
const amount = BigInt(interaction.options.getInteger("amount", true));
const senderId = interaction.user.id;
if (!targetUser) {
await interaction.reply({ embeds: [createErrorEmbed("User not found.")], flags: MessageFlags.Ephemeral });
return;
}
const receiverId = targetUser.id;
if (amount < config.economy.transfers.minAmount) {
await interaction.reply({ embeds: [createErrorEmbed(`Amount must be at least ${config.economy.transfers.minAmount}.`)], flags: MessageFlags.Ephemeral });
return;
}
if (senderId === receiverId.toString()) {
await interaction.reply({ embeds: [createErrorEmbed("You cannot pay yourself.")], flags: MessageFlags.Ephemeral });
return;
}
try {
await interaction.deferReply();
await economyService.transfer(senderId, receiverId.toString(), amount);
const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful");
await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` });
} catch (error: any) {
if (error instanceof UserError) {
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
} else {
console.error("Error sending payment:", error);
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
}
}
}
});

View File

@@ -1,6 +1,7 @@
import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder, EmbedBuilder, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, ThreadAutoArchiveDuration } from "discord.js";
import { TradeService } from "@/modules/trade/trade.service";
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags } from "discord.js";
import { tradeService } from "@shared/modules/trade/trade.service";
import { getTradeDashboard } from "@/modules/trade/trade.view";
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
export const trade = createCommand({
@@ -16,19 +17,19 @@ export const trade = createCommand({
const targetUser = interaction.options.getUser("user", true);
if (targetUser.id === interaction.user.id) {
await interaction.reply({ embeds: [createWarningEmbed("You cannot trade with yourself.")], ephemeral: true });
await interaction.reply({ embeds: [createWarningEmbed("You cannot trade with yourself.")], flags: MessageFlags.Ephemeral });
return;
}
if (targetUser.bot) {
await interaction.reply({ embeds: [createWarningEmbed("You cannot trade with bots.")], ephemeral: true });
await interaction.reply({ embeds: [createWarningEmbed("You cannot trade with bots.")], flags: MessageFlags.Ephemeral });
return;
}
// Create Thread
const channel = interaction.channel;
if (!channel || channel.type === ChannelType.DM) {
await interaction.reply({ embeds: [createErrorEmbed("Cannot start trade in DMs.")], ephemeral: true });
await interaction.reply({ embeds: [createErrorEmbed("Cannot start trade in DMs.")], flags: MessageFlags.Ephemeral });
return;
}
@@ -53,37 +54,20 @@ export const trade = createCommand({
} catch (err) {
console.error("Failed to delete setup message", err);
}
await interaction.followUp({ embeds: [createErrorEmbed("Failed to create trade thread. Check permissions.")], ephemeral: true });
await interaction.followUp({ embeds: [createErrorEmbed("Failed to create trade thread. Check permissions.")], flags: MessageFlags.Ephemeral });
return;
}
// Setup Session
TradeService.createSession(thread.id,
const session = tradeService.createSession(thread.id,
{ id: interaction.user.id, username: interaction.user.username },
{ id: targetUser.id, username: targetUser.username }
);
// Send Dashboard to Thread
const embed = new EmbedBuilder()
.setTitle("🤝 Trading Session")
.setDescription(`Trade started between ${interaction.user} and ${targetUser}.\nUse the controls below to build your offer.`)
.setColor(0xFFD700)
.addFields(
{ name: interaction.user.username, value: "*Empty Offer*", inline: true },
{ name: targetUser.username, value: "*Empty Offer*", inline: true }
)
.setFooter({ text: "Both parties must click Lock to confirm trade." });
const dashboard = getTradeDashboard(session);
const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(
new ButtonBuilder().setCustomId('trade_add_item').setLabel('Add Item').setStyle(ButtonStyle.Secondary),
new ButtonBuilder().setCustomId('trade_add_money').setLabel('Add Money').setStyle(ButtonStyle.Success),
new ButtonBuilder().setCustomId('trade_remove_item').setLabel('Remove Item').setStyle(ButtonStyle.Secondary),
new ButtonBuilder().setCustomId('trade_lock').setLabel('Lock / Unlock').setStyle(ButtonStyle.Primary),
new ButtonBuilder().setCustomId('trade_cancel').setLabel('Cancel').setStyle(ButtonStyle.Danger),
);
await thread.send({ content: `${interaction.user} ${targetUser} Welcome to your trading session!`, embeds: [embed], components: [row] });
await thread.send({ content: `${interaction.user} ${targetUser} Welcome to your trading session!`, ...dashboard });
// Update original reply
await interaction.editReply({ content: `✅ Trade opened: <#${thread.id}>` });

View File

@@ -0,0 +1,117 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js";
import { triviaService } from "@shared/modules/trivia/trivia.service";
import { getTriviaQuestionView } from "@/modules/trivia/trivia.view";
import { createErrorEmbed } from "@lib/embeds";
import { UserError } from "@shared/lib/errors";
import { config } from "@shared/lib/config";
import { TriviaCategory } from "@shared/lib/constants";
export const trivia = createCommand({
data: new SlashCommandBuilder()
.setName("trivia")
.setDescription("Play trivia to win currency! Answer correctly within the time limit.")
.addStringOption(option =>
option.setName('category')
.setDescription('Select a specific category')
.setRequired(false)
.addChoices(
{ name: 'General Knowledge', value: String(TriviaCategory.GENERAL_KNOWLEDGE) },
{ name: 'Books', value: String(TriviaCategory.BOOKS) },
{ name: 'Film', value: String(TriviaCategory.FILM) },
{ name: 'Music', value: String(TriviaCategory.MUSIC) },
{ name: 'Video Games', value: String(TriviaCategory.VIDEO_GAMES) },
{ name: 'Science & Nature', value: String(TriviaCategory.SCIENCE_NATURE) },
{ name: 'Computers', value: String(TriviaCategory.COMPUTERS) },
{ name: 'Mathematics', value: String(TriviaCategory.MATHEMATICS) },
{ name: 'Mythology', value: String(TriviaCategory.MYTHOLOGY) },
{ name: 'Sports', value: String(TriviaCategory.SPORTS) },
{ name: 'Geography', value: String(TriviaCategory.GEOGRAPHY) },
{ name: 'History', value: String(TriviaCategory.HISTORY) },
{ name: 'Politics', value: String(TriviaCategory.POLITICS) },
{ name: 'Art', value: String(TriviaCategory.ART) },
{ name: 'Animals', value: String(TriviaCategory.ANIMALS) },
{ name: 'Anime & Manga', value: String(TriviaCategory.ANIME_MANGA) },
)
),
execute: async (interaction) => {
try {
const categoryId = interaction.options.getString('category');
// Check if user can play BEFORE deferring
const canPlay = await triviaService.canPlayTrivia(interaction.user.id);
if (!canPlay.canPlay) {
// Cooldown error - ephemeral
const timestamp = Math.floor(canPlay.nextAvailable!.getTime() / 1000);
await interaction.reply({
embeds: [createErrorEmbed(
`You're on cooldown! Try again <t:${timestamp}:R>.`
)],
ephemeral: true
});
return;
}
// User can play - defer publicly for trivia question
await interaction.deferReply();
// Start trivia session (deducts entry fee)
const session = await triviaService.startTrivia(
interaction.user.id,
interaction.user.username,
categoryId ? parseInt(categoryId) : undefined
);
// Generate Components v2 message
const { components, flags } = getTriviaQuestionView(session, interaction.user.username);
// Reply with Components v2 question
await interaction.editReply({
components,
flags
});
// Set up automatic timeout cleanup
setTimeout(async () => {
const stillActive = triviaService.getSession(session.sessionId);
if (stillActive) {
// User didn't answer - clean up session with no reward
try {
await triviaService.submitAnswer(session.sessionId, interaction.user.id, false);
} catch (error) {
// Session already cleaned up, ignore
}
}
}, config.trivia.timeoutSeconds * 1000 + 5000); // 5 seconds grace period
} catch (error: any) {
if (error instanceof UserError) {
// Check if we've already deferred
if (interaction.deferred) {
await interaction.editReply({
embeds: [createErrorEmbed(error.message)]
});
} else {
await interaction.reply({
embeds: [createErrorEmbed(error.message)],
ephemeral: true
});
}
} else {
console.error("Error in trivia command:", error);
// Check if we've already deferred
if (interaction.deferred) {
await interaction.editReply({
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")]
});
} else {
await interaction.reply({
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")],
ephemeral: true
});
}
}
}
}
});

View File

@@ -0,0 +1,29 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js";
import { config } from "@shared/lib/config";
import { createErrorEmbed } from "@/lib/embeds";
import { getFeedbackTypeMenu } from "@/modules/feedback/feedback.view";
export const feedback = createCommand({
data: new SlashCommandBuilder()
.setName("feedback")
.setDescription("Submit feedback, feature requests, or bug reports"),
execute: async (interaction) => {
// Check if feedback channel is configured
if (!config.feedbackChannelId) {
await interaction.reply({
embeds: [createErrorEmbed("Feedback system is not configured. Please contact an administrator.")],
ephemeral: true
});
return;
}
// Show feedback type selection menu
const menu = getFeedbackTypeMenu();
await interaction.reply({
content: "## 🌟 Share Your Thoughts\n\nThank you for helping improve Aurora! Please select the type of feedback you'd like to submit:",
...menu,
ephemeral: true
});
}
});

View File

@@ -1,8 +1,9 @@
import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder, EmbedBuilder } from "discord.js";
import { inventoryService } from "@/modules/inventory/inventory.service";
import { userService } from "@/modules/user/user.service";
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js";
import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { userService } from "@shared/modules/user/user.service";
import { createWarningEmbed } from "@lib/embeds";
import { getInventoryEmbed } from "@/modules/inventory/inventory.view";
export const inventory = createCommand({
data: new SlashCommandBuilder()
@@ -17,23 +18,26 @@ export const inventory = createCommand({
await interaction.deferReply();
const targetUser = interaction.options.getUser("user") || interaction.user;
if (targetUser.bot) {
await interaction.editReply({ embeds: [createWarningEmbed("Bots do not have inventories.", "Inventory Check")] });
return;
}
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
const items = await inventoryService.getInventory(user.id);
if (!user) {
await interaction.editReply({ embeds: [createWarningEmbed("Failed to load user data.", "Error")] });
return;
}
const items = await inventoryService.getInventory(user.id.toString());
if (!items || items.length === 0) {
await interaction.editReply({ embeds: [createWarningEmbed("Inventory is empty.", `${user.username}'s Inventory`)] });
return;
}
const description = items.map(entry => {
return `**${entry.item.name}** x${entry.quantity}`;
}).join("\n");
const embed = new EmbedBuilder()
.setTitle(`${user.username}'s Inventory`)
.setDescription(description)
.setColor("Blue")
.setTimestamp();
const embed = getInventoryEmbed(items, user.username);
await interaction.editReply({ embeds: [embed] });
}

View File

@@ -0,0 +1,79 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js";
import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { userService } from "@shared/modules/user/user.service";
import { createErrorEmbed } from "@lib/embeds";
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
import type { ItemUsageData } from "@shared/lib/types";
import { UserError } from "@shared/lib/errors";
import { config } from "@shared/lib/config";
export const use = createCommand({
data: new SlashCommandBuilder()
.setName("use")
.setDescription("Use an item from your inventory")
.addNumberOption(option =>
option.setName("item")
.setDescription("The item to use")
.setRequired(true)
.setAutocomplete(true)
),
execute: async (interaction) => {
await interaction.deferReply();
const itemId = interaction.options.getNumber("item", true);
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
if (!user) {
await interaction.editReply({ embeds: [createErrorEmbed("Failed to load user data.")] });
return;
}
try {
const result = await inventoryService.useItem(user.id.toString(), itemId);
const usageData = result.usageData;
if (usageData) {
for (const effect of usageData.effects) {
if (effect.type === 'TEMP_ROLE' || effect.type === 'COLOR_ROLE') {
try {
const member = await interaction.guild?.members.fetch(user.id.toString());
if (member) {
if (effect.type === 'TEMP_ROLE') {
await member.roles.add(effect.roleId);
} else if (effect.type === 'COLOR_ROLE') {
// Remove existing color roles
const rolesToRemove = config.colorRoles.filter(r => member.roles.cache.has(r));
if (rolesToRemove.length > 0) await member.roles.remove(rolesToRemove);
await member.roles.add(effect.roleId);
}
}
} catch (e) {
console.error("Failed to assign role in /use command:", e);
result.results.push("⚠️ Failed to assign role (Check bot permissions)");
}
}
}
}
const embed = getItemUseResultEmbed(result.results, result.item);
await interaction.editReply({ embeds: [embed] });
} catch (error: any) {
if (error instanceof UserError) {
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
} else {
console.error("Error using item:", error);
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred while using the item.")] });
}
}
},
autocomplete: async (interaction) => {
const focusedValue = interaction.options.getFocused();
const userId = interaction.user.id;
const results = await inventoryService.getAutocompleteItems(userId, focusedValue);
await interaction.respond(results);
}
});

View File

@@ -0,0 +1,61 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { users, items, inventory } from "@db/schema";
import { desc, sql, eq } from "drizzle-orm";
import { createWarningEmbed } from "@lib/embeds";
import { getLeaderboardEmbed } from "@/modules/leveling/leveling.view";
export const leaderboard = createCommand({
data: new SlashCommandBuilder()
.setName("leaderboard")
.setDescription("View the top players")
.addStringOption(option =>
option.setName("type")
.setDescription("Sort by XP, Balance, or Net Worth")
.setRequired(true)
.addChoices(
{ name: "Level / XP", value: "xp" },
{ name: "Balance", value: "balance" },
{ name: "Net Worth", value: "networth" }
)
),
execute: async (interaction) => {
await interaction.deferReply();
const type = interaction.options.getString("type", true);
let leaders;
if (type === 'networth') {
leaders = await DrizzleClient.select({
username: users.username,
level: users.level,
xp: users.xp,
balance: users.balance,
netWorth: sql<bigint>`${users.balance} + COALESCE(SUM(${items.price} * ${inventory.quantity}), 0)`.as('net_worth')
})
.from(users)
.leftJoin(inventory, eq(users.id, inventory.userId))
.leftJoin(items, eq(inventory.itemId, items.id))
.groupBy(users.id)
.orderBy(desc(sql`net_worth`))
.limit(10);
} else {
const isXp = type === "xp";
leaders = await DrizzleClient.query.users.findMany({
orderBy: isXp ? desc(users.xp) : desc(users.balance),
limit: 10
});
}
if (leaders.length === 0) {
await interaction.editReply({ embeds: [createWarningEmbed("No users found.", "Leaderboard")] });
return;
}
const embed = getLeaderboardEmbed(leaders, type as 'xp' | 'balance' | 'networth');
await interaction.editReply({ embeds: [embed] });
}
});

View File

@@ -0,0 +1,25 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, MessageFlags } from "discord.js";
import { questService } from "@shared/modules/quest/quest.service";
import { createWarningEmbed } from "@lib/embeds";
import { getQuestListEmbed } from "@/modules/quest/quest.view";
export const quests = createCommand({
data: new SlashCommandBuilder()
.setName("quests")
.setDescription("View your active quests"),
execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const userQuests = await questService.getUserQuests(interaction.user.id);
if (!userQuests || userQuests.length === 0) {
await interaction.editReply({ embeds: [createWarningEmbed("You have no active quests.", "Quest Log")] });
return;
}
const embed = getQuestListEmbed(userQuests);
await interaction.editReply({ embeds: [embed] });
}
});

View File

@@ -1,7 +1,8 @@
import { createCommand } from "@/lib/utils";
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, AttachmentBuilder } from "discord.js";
import { userService } from "@/modules/user/user.service";
import { userService } from "@shared/modules/user/user.service";
import { generateStudentIdCard } from "@/graphics/studentID";
import { createWarningEmbed } from "@/lib/embeds";
export const profile = createCommand({
data: new SlashCommandBuilder()
@@ -16,6 +17,12 @@ export const profile = createCommand({
await interaction.deferReply();
const targetUser = interaction.options.getUser("user") || interaction.user;
if (targetUser.bot) {
await interaction.editReply({ embeds: [createWarningEmbed("Bots do not have profiles.", "Profile Check")] });
return;
}
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
const cardBuffer = await generateStudentIdCard({

View File

@@ -0,0 +1,35 @@
import { Events } from "discord.js";
import type { Event } from "@shared/lib/types";
import { config } from "@shared/lib/config";
import { userService } from "@shared/modules/user/user.service";
// Visitor role
const event: Event<Events.GuildMemberAdd> = {
name: Events.GuildMemberAdd,
execute: async (member) => {
console.log(`👤 New member joined: ${member.user.tag} (${member.id})`);
try {
const user = await userService.getUserById(member.id);
if (user && user.class) {
console.log(`🔄 Returning student detected: ${member.user.tag}`);
await member.roles.remove(config.visitorRole);
await member.roles.add(config.studentRole);
if (user.class.roleId) {
await member.roles.add(user.class.roleId);
console.log(`Restored class role ${user.class.name} to ${member.user.tag}`);
}
console.log(`Restored student role to ${member.user.tag}`);
} else {
await member.roles.add(config.visitorRole);
console.log(`Assigned visitor role to ${member.user.tag}`);
}
console.log(`User Roles: ${member.roles.cache.map(role => role.name).join(", ")}`);
} catch (error) {
console.error(`Failed to handle role assignment for ${member.user.tag}:`, error);
}
},
};
export default event;

View File

@@ -0,0 +1,22 @@
import { Events } from "discord.js";
import { ComponentInteractionHandler, AutocompleteHandler, CommandHandler } from "@/lib/handlers";
import type { Event } from "@shared/lib/types";
const event: Event<Events.InteractionCreate> = {
name: Events.InteractionCreate,
execute: async (interaction) => {
if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) {
return ComponentInteractionHandler.handle(interaction);
}
if (interaction.isAutocomplete()) {
return AutocompleteHandler.handle(interaction);
}
if (interaction.isChatInputCommand()) {
return CommandHandler.handle(interaction);
}
},
};
export default event;

View File

@@ -1,7 +1,7 @@
import { Events } from "discord.js";
import { userService } from "@/modules/user/user.service";
import { levelingService } from "@/modules/leveling/leveling.service";
import type { Event } from "@lib/types";
import { userService } from "@shared/modules/user/user.service";
import { levelingService } from "@shared/modules/leveling/leveling.service";
import type { Event } from "@shared/lib/types";
const event: Event<Events.MessageCreate> = {
name: Events.MessageCreate,
@@ -13,6 +13,9 @@ const event: Event<Events.MessageCreate> = {
if (!user) return;
levelingService.processChatXp(message.author.id);
// Activity Tracking for Lootdrops
import("@shared/modules/economy/lootdrop.service").then(m => m.lootdropService.processMessage(message));
},
};

View File

@@ -1,6 +1,6 @@
import { Events } from "discord.js";
import { schedulerService } from "@/modules/system/scheduler";
import type { Event } from "@lib/types";
import type { Event } from "@shared/lib/types";
const event: Event<Events.ClientReady> = {
name: Events.ClientReady,
@@ -8,6 +8,10 @@ const event: Event<Events.ClientReady> = {
execute: async (c) => {
console.log(`Ready! Logged in as ${c.user.tag}`);
schedulerService.start();
// Handle post-update tasks
const { UpdateService } = await import("@shared/modules/admin/update.service");
await UpdateService.handlePostRestart(c);
},
};

135
bot/graphics/lootdrop.ts Normal file
View File

@@ -0,0 +1,135 @@
import { GlobalFonts, createCanvas, loadImage } from '@napi-rs/canvas';
import path from 'path';
// Register Fonts (same as studentID.ts)
const fontDir = path.join(process.cwd(), 'bot', 'assets', 'fonts');
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexSansCondensed-SemiBold.ttf'), 'IBMPlexSansCondensed-SemiBold');
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexMono-Bold.ttf'), 'IBMPlexMono-Bold');
export async function generateLootdropCard(amount: number, currency: string): Promise<Buffer> {
const templatePath = path.join(process.cwd(), 'bot', 'assets', 'graphics', 'lootdrop', 'template.png');
const template = await loadImage(templatePath);
const canvas = createCanvas(template.width, template.height);
const ctx = canvas.getContext('2d');
// Draw Template
ctx.drawImage(template, 0, 0);
// Draw Lootdrop Text (Title-ish)
ctx.save();
ctx.font = '48px IBMPlexSansCondensed-SemiBold';
ctx.fillStyle = '#FFFFFF';
ctx.textAlign = 'center';
ctx.shadowBlur = 10;
ctx.shadowColor = 'rgba(255, 255, 255, 0.5)';
// Center of lower half (512-1024) is roughly 768
ctx.fillText('A STAR IS FALLING', canvas.width / 2, 660);
ctx.restore();
// Draw Reward Amount
ctx.save();
ctx.font = '72px IBMPlexMono-Bold';
ctx.fillStyle = '#DAC7A1';
ctx.textAlign = 'center';
//ctx.shadowBlur = 15;
//ctx.shadowColor = 'rgba(255, 215, 0, 0.8)';
ctx.fillText(`${amount} ${currency}`, canvas.width / 2, 760); // Below title
ctx.restore();
// Crop the image by 64px on all sides
const croppedWidth = template.width - 128;
const croppedHeight = template.height - 128;
const croppedCanvas = createCanvas(croppedWidth, croppedHeight);
const croppedCtx = croppedCanvas.getContext('2d');
// Draw the original canvas onto the cropped canvas, shifted by -64
croppedCtx.drawImage(canvas, -64, -64);
return croppedCanvas.toBuffer('image/png');
}
export async function generateClaimedLootdropCard(amount: number, currency: string, username: string, avatarUrl: string): Promise<Buffer> {
const templatePath = path.join(process.cwd(), 'bot', 'assets', 'graphics', 'lootdrop', 'template.png');
const template = await loadImage(templatePath);
const canvas = createCanvas(template.width, template.height);
const ctx = canvas.getContext('2d');
// Draw Template
ctx.drawImage(template, 0, 0);
// Add a colored overlay to signify "claimed"
ctx.fillStyle = 'rgba(10, 10, 20, 0.85)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw Claimed Text (Title-ish)
ctx.save();
ctx.font = '48px IBMPlexSansCondensed-SemiBold';
ctx.fillStyle = '#FFFFFF';
ctx.textAlign = 'center';
ctx.shadowBlur = 10;
ctx.shadowColor = 'rgba(255, 255, 255, 0.5)';
ctx.fillText('STAR CLAIMED', canvas.width / 2, 660);
ctx.restore();
// Draw "by username" with Avatar
ctx.save();
ctx.font = '36px IBMPlexSansCondensed-SemiBold';
ctx.fillStyle = '#AAAAAA';
// Calculate layout for centering Group (Avatar + Text)
const text = `by ${username}`;
const textMetrics = ctx.measureText(text);
const textWidth = textMetrics.width;
const avatarSize = 50;
const gap = 15;
const totalWidth = avatarSize + gap + textWidth;
const startX = (canvas.width - totalWidth) / 2;
const baselineY = 830;
// Draw Avatar
try {
const avatar = await loadImage(avatarUrl);
ctx.save();
ctx.beginPath();
// Center avatar vertically relative to text roughly (baseline - ~half cap height)
// 36px text ~ 27px cap height. Center roughly at baselineY - 14
const avatarCenterY = baselineY - 14;
ctx.arc(startX + avatarSize / 2, avatarCenterY, avatarSize / 2, 0, Math.PI * 2);
ctx.closePath();
ctx.clip();
ctx.drawImage(avatar, startX, avatarCenterY - avatarSize / 2, avatarSize, avatarSize);
ctx.restore();
} catch (e) {
// Fallback if avatar fails to load, just don't draw it (or maybe shift text?)
// For now, let's just proceed, the text will be off-center if avatar is missing but that's acceptable edge case
console.error("Failed to load avatar", e);
}
// Draw Text
ctx.textAlign = 'left';
ctx.fillText(text, startX + avatarSize + gap, baselineY);
ctx.restore();
ctx.save();
ctx.font = '72px IBMPlexMono-Bold'; // Match Amount size
ctx.fillStyle = '#E6D2B5'; // Lighter gold/beige for better contrast
ctx.textAlign = 'center';
ctx.shadowBlur = 10;
ctx.shadowColor = 'rgba(0, 0, 0, 0.8)'; // Dark shadow for contrast
ctx.fillText(`${amount} ${currency}`, canvas.width / 2, 760); // Same position as Unclaimed Amount
ctx.restore();
// Crop the image by 64px on all sides
const croppedWidth = template.width - 128;
const croppedHeight = template.height - 128;
const croppedCanvas = createCanvas(croppedWidth, croppedHeight);
const croppedCtx = croppedCanvas.getContext('2d');
// Draw the original canvas onto the cropped canvas, shifted by -64
croppedCtx.drawImage(canvas, -64, -64);
return croppedCanvas.toBuffer('image/png');
}

View File

@@ -1,9 +1,9 @@
import { GlobalFonts, createCanvas, loadImage } from '@napi-rs/canvas';
import { levelingService } from '@/modules/leveling/leveling.service';
import { levelingService } from '@shared/modules/leveling/leveling.service';
import path from 'path';
// Register Fonts
const fontDir = path.join(process.cwd(), 'src', 'assets', 'fonts');
const fontDir = path.join(process.cwd(), 'bot', 'assets', 'fonts');
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexSansCondensed-SemiBold.ttf'), 'IBMPlexSansCondensed-SemiBold');
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexMono-Bold.ttf'), 'IBMPlexMono-Bold');
@@ -18,8 +18,8 @@ interface StudentCardData {
}
export async function generateStudentIdCard(data: StudentCardData): Promise<Buffer> {
const templatePath = path.join(process.cwd(), 'src', 'assets', 'graphics', 'studentID', 'template.png');
const classTemplatePath = path.join(process.cwd(), 'src', 'assets', 'graphics', 'studentID', `Constellation-${data.className}.png`);
const templatePath = path.join(process.cwd(), 'bot', 'assets', 'graphics', 'studentID', 'template.png');
const classTemplatePath = path.join(process.cwd(), 'bot', 'assets', 'graphics', 'studentID', `Constellation-${data.className}.png`);
const template = await loadImage(templatePath);
const classTemplate = await loadImage(classTemplatePath);
@@ -97,9 +97,12 @@ export async function generateStudentIdCard(data: StudentCardData): Promise<Buff
ctx.restore();
// Draw XP Bar
const xpForNextLevel = levelingService.getXpForLevel(data.level);
const xpForThisLevel = levelingService.getXpForNextLevel(data.level); // The total size of the current level bucket
const xpAtStartOfLevel = levelingService.getXpToReachLevel(data.level); // The accumulated XP when this level started
const currentLevelProgress = Number(data.xp) - xpAtStartOfLevel; // How much XP into this level
const xpBarMaxWidth = 382;
const xpBarWidth = xpBarMaxWidth * Number(data.xp) / Number(xpForNextLevel);
const xpBarWidth = Math.max(0, Math.min(xpBarMaxWidth, xpBarMaxWidth * currentLevelProgress / xpForThisLevel));
const xpBarHeight = 3;
ctx.save();
ctx.fillStyle = '#B3AD93';

49
bot/index.ts Normal file
View File

@@ -0,0 +1,49 @@
import { AuroraClient } from "@/lib/BotClient";
import { env } from "@shared/lib/env";
import { join } from "node:path";
import { startWebServerFromRoot } from "../web/src/server";
// Load commands & events
await AuroraClient.loadCommands();
await AuroraClient.loadEvents();
await AuroraClient.deployCommands();
await AuroraClient.setupSystemEvents();
console.log("🌐 Starting web server...");
let shuttingDown = false;
const webProjectPath = join(import.meta.dir, "../web");
const webPort = Number(process.env.WEB_PORT) || 3000;
const webHost = process.env.HOST || "0.0.0.0";
// Start web server in the same process
const webServer = await startWebServerFromRoot(webProjectPath, {
port: webPort,
hostname: webHost,
});
// login with the token from .env
if (!env.DISCORD_BOT_TOKEN) {
throw new Error("❌ DISCORD_BOT_TOKEN is not set in environment variables.");
}
AuroraClient.login(env.DISCORD_BOT_TOKEN);
// Handle graceful shutdown
const shutdownHandler = async () => {
if (shuttingDown) return;
shuttingDown = true;
console.log("🛑 Shutdown signal received. Stopping services...");
// Stop web server
await webServer.stop();
// Stop bot
AuroraClient.shutdown();
process.exit(0);
};
process.on("SIGINT", shutdownHandler);
process.on("SIGTERM", shutdownHandler);

111
bot/lib/BotClient.test.ts Normal file
View File

@@ -0,0 +1,111 @@
import { describe, expect, test, mock, beforeEach, spyOn } from "bun:test";
import { systemEvents, EVENTS } from "@shared/lib/events";
// Mock Discord.js Client and related classes
mock.module("discord.js", () => ({
Client: class {
constructor() { }
on() { }
once() { }
login() { }
destroy() { }
removeAllListeners() { }
},
Collection: Map,
GatewayIntentBits: { Guilds: 1, MessageContent: 1, GuildMessages: 1, GuildMembers: 1 },
REST: class {
setToken() { return this; }
put() { return Promise.resolve([]); }
},
Routes: {
applicationGuildCommands: () => 'guild_route',
applicationCommands: () => 'global_route'
}
}));
// Mock loaders to avoid filesystem access during client init
mock.module("../lib/loaders/CommandLoader", () => ({
CommandLoader: class {
constructor() { }
loadFromDirectory() { return Promise.resolve({ loaded: 0, skipped: 0, errors: [] }); }
}
}));
mock.module("../lib/loaders/EventLoader", () => ({
EventLoader: class {
constructor() { }
loadFromDirectory() { return Promise.resolve({ loaded: 0, skipped: 0, errors: [] }); }
}
}));
// Mock dashboard service to prevent network/db calls during event handling
mock.module("@shared/modules/economy/lootdrop.service", () => ({
lootdropService: { clearCaches: mock(async () => { }) }
}));
mock.module("@shared/modules/trade/trade.service", () => ({
tradeService: { clearSessions: mock(() => { }) }
}));
mock.module("@/modules/admin/item_wizard", () => ({
clearDraftSessions: mock(() => { })
}));
mock.module("@shared/modules/dashboard/dashboard.service", () => ({
dashboardService: {
recordEvent: mock(() => Promise.resolve())
}
}));
describe("AuroraClient System Events", () => {
let AuroraClient: any;
beforeEach(async () => {
systemEvents.removeAllListeners();
const module = await import("./BotClient");
AuroraClient = module.AuroraClient;
AuroraClient.maintenanceMode = false;
// MUST call explicitly now
await AuroraClient.setupSystemEvents();
});
/**
* Test Case: Maintenance Mode Toggle
* Requirement: Client state should update when event is received
*/
test("should toggle maintenanceMode when MAINTENANCE_MODE event is received", async () => {
systemEvents.emit(EVENTS.ACTIONS.MAINTENANCE_MODE, { enabled: true, reason: "Testing" });
await new Promise(resolve => setTimeout(resolve, 30));
expect(AuroraClient.maintenanceMode).toBe(true);
systemEvents.emit(EVENTS.ACTIONS.MAINTENANCE_MODE, { enabled: false });
await new Promise(resolve => setTimeout(resolve, 30));
expect(AuroraClient.maintenanceMode).toBe(false);
});
/**
* Test Case: Command Reload
* Requirement: loadCommands and deployCommands should be called
*/
test("should reload commands when RELOAD_COMMANDS event is received", async () => {
const loadSpy = spyOn(AuroraClient, "loadCommands").mockImplementation(() => Promise.resolve());
const deploySpy = spyOn(AuroraClient, "deployCommands").mockImplementation(() => Promise.resolve());
systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS);
await new Promise(resolve => setTimeout(resolve, 50));
expect(loadSpy).toHaveBeenCalled();
expect(deploySpy).toHaveBeenCalled();
});
/**
* Test Case: Cache Clearance
* Requirement: Service clear methods should be triggered
*/
test("should trigger service cache clearance when CLEAR_CACHE is received", async () => {
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
const { tradeService } = await import("@shared/modules/trade/trade.service");
systemEvents.emit(EVENTS.ACTIONS.CLEAR_CACHE);
await new Promise(resolve => setTimeout(resolve, 50));
expect(lootdropService.clearCaches).toHaveBeenCalled();
expect(tradeService.clearSessions).toHaveBeenCalled();
});
});

179
bot/lib/BotClient.ts Normal file
View File

@@ -0,0 +1,179 @@
import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes } from "discord.js";
import { join } from "node:path";
import type { Command } from "@shared/lib/types";
import { env } from "@shared/lib/env";
import { CommandLoader } from "@lib/loaders/CommandLoader";
import { EventLoader } from "@lib/loaders/EventLoader";
export class Client extends DiscordClient {
commands: Collection<string, Command>;
knownCommands: Map<string, string>;
lastCommandTimestamp: number | null = null;
maintenanceMode: boolean = false;
private commandLoader: CommandLoader;
private eventLoader: EventLoader;
constructor({ intents }: { intents: number[] }) {
super({ intents });
this.commands = new Collection<string, Command>();
this.knownCommands = new Map<string, string>();
this.commandLoader = new CommandLoader(this);
this.eventLoader = new EventLoader(this);
}
public async setupSystemEvents() {
const { systemEvents, EVENTS } = await import("@shared/lib/events");
systemEvents.on(EVENTS.ACTIONS.RELOAD_COMMANDS, async () => {
console.log("🔄 System Action: Reloading commands...");
try {
await this.loadCommands(true);
await this.deployCommands();
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
await dashboardService.recordEvent({
type: "success",
message: "Bot: Commands reloaded and redeployed",
icon: "✅"
});
} catch (error) {
console.error("Failed to reload commands:", error);
}
});
systemEvents.on(EVENTS.ACTIONS.CLEAR_CACHE, async () => {
console.log("<22> System Action: Clearing all internal caches...");
try {
// 1. Lootdrop Service
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
await lootdropService.clearCaches();
// 2. Trade Service
const { tradeService } = await import("@shared/modules/trade/trade.service");
tradeService.clearSessions();
// 3. Item Wizard
const { clearDraftSessions } = await import("@/modules/admin/item_wizard");
clearDraftSessions();
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
await dashboardService.recordEvent({
type: "success",
message: "Bot: All internal caches and sessions cleared",
icon: "🧼"
});
} catch (error) {
console.error("Failed to clear caches:", error);
}
});
systemEvents.on(EVENTS.ACTIONS.MAINTENANCE_MODE, async (data: { enabled: boolean, reason?: string }) => {
const { enabled, reason } = data;
console.log(`🛠️ System Action: Maintenance mode ${enabled ? "ON" : "OFF"}${reason ? ` (${reason})` : ""}`);
this.maintenanceMode = enabled;
});
}
async loadCommands(reload: boolean = false) {
if (reload) {
this.commands.clear();
this.knownCommands.clear();
console.log("♻️ Reloading commands...");
}
const commandsPath = join(import.meta.dir, '../commands');
const result = await this.commandLoader.loadFromDirectory(commandsPath, reload);
console.log(`📦 Command loading complete: ${result.loaded} loaded, ${result.skipped} skipped, ${result.errors.length} errors`);
}
async loadEvents(reload: boolean = false) {
if (reload) {
this.removeAllListeners();
console.log("♻️ Reloading events...");
}
const eventsPath = join(import.meta.dir, '../events');
const result = await this.eventLoader.loadFromDirectory(eventsPath, reload);
console.log(`📦 Event loading complete: ${result.loaded} loaded, ${result.skipped} skipped, ${result.errors.length} errors`);
}
async deployCommands() {
// We use env.DISCORD_BOT_TOKEN directly so this can run without client.login()
const token = env.DISCORD_BOT_TOKEN;
if (!token) {
console.error("DISCORD_BOT_TOKEN is not set.");
return;
}
const rest = new REST().setToken(token);
const commandsData = this.commands.map(c => c.data.toJSON());
const guildId = env.DISCORD_GUILD_ID;
const clientId = env.DISCORD_CLIENT_ID;
if (!clientId) {
console.error("DISCORD_CLIENT_ID is not set.");
return;
}
try {
console.log(`Started refreshing ${commandsData.length} application (/) commands.`);
let data;
if (guildId) {
console.log(`Registering commands to guild: ${guildId}`);
data = await rest.put(
Routes.applicationGuildCommands(clientId, guildId),
{ body: commandsData },
);
// Clear global commands to avoid duplicates
await rest.put(Routes.applicationCommands(clientId), { body: [] });
} else {
console.log('Registering commands globally');
data = await rest.put(
Routes.applicationCommands(clientId),
{ body: commandsData },
);
}
console.log(`Successfully reloaded ${(data as any).length} application (/) commands.`);
} catch (error: any) {
if (error.code === 50001) {
console.warn("Missing Access: The bot is not in the guild or lacks 'applications.commands' scope.");
console.warn(" If you are testing locally, make sure you invited the bot with scope 'bot applications.commands'.");
} else {
console.error(error);
}
}
}
async shutdown() {
const { setShuttingDown, waitForTransactions } = await import("./shutdown");
const { closeDatabase } = await import("@shared/db/DrizzleClient");
console.log("🛑 Shutdown signal received. Starting graceful shutdown...");
setShuttingDown(true);
// Wait for transactions to complete
console.log("⏳ Waiting for active transactions to complete...");
await waitForTransactions(10000);
// Destroy Discord client
console.log("🔌 Disconnecting from Discord...");
this.destroy();
// Close database
console.log("🗄️ Closing database connection...");
await closeDatabase();
console.log("👋 Graceful shutdown complete. Exiting.");
process.exit(0);
}
}
export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers] });

View File

@@ -0,0 +1,74 @@
import { describe, test, expect, beforeEach, mock, afterEach } from "bun:test";
import { getClientStats, clearStatsCache } from "./clientStats";
// Mock AuroraClient
mock.module("./BotClient", () => ({
AuroraClient: {
guilds: {
cache: {
size: 5,
},
},
ws: {
ping: 42,
},
users: {
cache: {
size: 100,
},
},
commands: {
size: 20,
},
lastCommandTimestamp: 1641481200000,
},
}));
describe("clientStats", () => {
beforeEach(() => {
clearStatsCache();
});
test("should return client stats", () => {
const stats = getClientStats();
expect(stats.guilds).toBe(5);
expect(stats.ping).toBe(42);
expect(stats.cachedUsers).toBe(100);
expect(stats.commandsRegistered).toBe(20);
expect(typeof stats.uptime).toBe("number"); // Can't mock process.uptime easily
expect(stats.lastCommandTimestamp).toBe(1641481200000);
});
test("should cache stats for 30 seconds", () => {
const stats1 = getClientStats();
const stats2 = getClientStats();
// Should return same object (cached)
expect(stats1).toBe(stats2);
});
test("should refresh cache after TTL expires", async () => {
const stats1 = getClientStats();
// Wait for cache to expire (simulate by clearing and waiting)
await new Promise(resolve => setTimeout(resolve, 35));
clearStatsCache();
const stats2 = getClientStats();
// Should be different objects (new fetch)
expect(stats1).not.toBe(stats2);
// But values should be the same (mocked client)
expect(stats1.guilds).toBe(stats2.guilds);
});
test("clearStatsCache should invalidate cache", () => {
const stats1 = getClientStats();
clearStatsCache();
const stats2 = getClientStats();
// Should be different objects
expect(stats1).not.toBe(stats2);
});
});

49
bot/lib/clientStats.ts Normal file
View File

@@ -0,0 +1,49 @@
import { AuroraClient } from "./BotClient";
import type { ClientStats } from "@shared/modules/dashboard/dashboard.types";
// Cache for client stats (30 second TTL)
let cachedStats: ClientStats | null = null;
let lastFetchTime: number = 0;
const CACHE_TTL_MS = 30 * 1000; // 30 seconds
/**
* Get Discord client statistics with caching
* Respects rate limits by caching for 30 seconds
*/
export function getClientStats(): ClientStats {
const now = Date.now();
// Return cached stats if still valid
if (cachedStats && (now - lastFetchTime) < CACHE_TTL_MS) {
return cachedStats;
}
// Fetch fresh stats
const stats: ClientStats = {
bot: {
name: AuroraClient.user?.username || "Aurora",
avatarUrl: AuroraClient.user?.displayAvatarURL() || null,
},
guilds: AuroraClient.guilds.cache.size,
ping: AuroraClient.ws.ping,
cachedUsers: AuroraClient.users.cache.size,
commandsRegistered: AuroraClient.commands.size,
commandsKnown: AuroraClient.knownCommands.size,
uptime: process.uptime(),
lastCommandTimestamp: AuroraClient.lastCommandTimestamp,
};
// Update cache
cachedStats = stats;
lastFetchTime = now;
return stats;
}
/**
* Clear the stats cache (useful for testing)
*/
export function clearStatsCache(): void {
cachedStats = null;
lastFetchTime = 0;
}

47
bot/lib/db.test.ts Normal file
View File

@@ -0,0 +1,47 @@
import { describe, it, expect, mock, beforeEach } from "bun:test";
// Mock DrizzleClient
mock.module("./DrizzleClient", () => ({
DrizzleClient: {
transaction: async (cb: any) => cb("MOCK_TX")
}
}));
import { withTransaction } from "./db";
import { setShuttingDown, getActiveTransactions, decrementTransactions } from "./shutdown";
describe("db withTransaction", () => {
beforeEach(() => {
setShuttingDown(false);
// Reset transaction count
while (getActiveTransactions() > 0) {
decrementTransactions();
}
});
it("should allow transactions when not shutting down", async () => {
const result = await withTransaction(async (tx) => {
return "success";
});
expect(result).toBe("success");
expect(getActiveTransactions()).toBe(0);
});
it("should throw error when shutting down", async () => {
setShuttingDown(true);
expect(withTransaction(async (tx) => {
return "success";
})).rejects.toThrow("System is shutting down, no new transactions allowed.");
expect(getActiveTransactions()).toBe(0);
});
it("should increment and decrement transaction count", async () => {
let countDuring = 0;
await withTransaction(async (tx) => {
countDuring = getActiveTransactions();
return "ok";
});
expect(countDuring).toBe(1);
expect(getActiveTransactions()).toBe(0);
});
});

25
bot/lib/db.ts Normal file
View File

@@ -0,0 +1,25 @@
import { DrizzleClient } from "@shared/db/DrizzleClient";
import type { Transaction } from "@shared/lib/types";
import { isShuttingDown, incrementTransactions, decrementTransactions } from "./shutdown";
export const withTransaction = async <T>(
callback: (tx: Transaction) => Promise<T>,
tx?: Transaction
): Promise<T> => {
if (tx) {
return await callback(tx);
} else {
if (isShuttingDown()) {
throw new Error("System is shutting down, no new transactions allowed.");
}
incrementTransactions();
try {
return await DrizzleClient.transaction(async (newTx) => {
return await callback(newTx);
});
} finally {
decrementTransactions();
}
}
};

View File

@@ -1,4 +1,15 @@
import { EmbedBuilder, Colors } from "discord.js";
import { Colors, type ColorResolvable, EmbedBuilder } from "discord.js";
import { BRANDING } from "@shared/lib/constants";
import pkg from "../../package.json";
/**
* Applies standard branding to an embed.
*/
function applyBranding(embed: EmbedBuilder): EmbedBuilder {
return embed.setFooter({
text: `${BRANDING.FOOTER_TEXT} v${pkg.version}`
});
}
/**
* Creates a standardized error embed.
@@ -7,11 +18,13 @@ import { EmbedBuilder, Colors } from "discord.js";
* @returns An EmbedBuilder instance configured as an error.
*/
export function createErrorEmbed(message: string, title: string = "Error"): EmbedBuilder {
return new EmbedBuilder()
const embed = new EmbedBuilder()
.setTitle(`${title}`)
.setDescription(message)
.setColor(Colors.Red)
.setTimestamp();
return applyBranding(embed);
}
/**
@@ -21,11 +34,13 @@ export function createErrorEmbed(message: string, title: string = "Error"): Embe
* @returns An EmbedBuilder instance configured as a warning.
*/
export function createWarningEmbed(message: string, title: string = "Warning"): EmbedBuilder {
return new EmbedBuilder()
const embed = new EmbedBuilder()
.setTitle(`⚠️ ${title}`)
.setDescription(message)
.setColor(Colors.Yellow)
.setTimestamp();
return applyBranding(embed);
}
/**
@@ -35,11 +50,13 @@ export function createWarningEmbed(message: string, title: string = "Warning"):
* @returns An EmbedBuilder instance configured as a success.
*/
export function createSuccessEmbed(message: string, title: string = "Success"): EmbedBuilder {
return new EmbedBuilder()
const embed = new EmbedBuilder()
.setTitle(`${title}`)
.setDescription(message)
.setColor(Colors.Green)
.setTimestamp();
return applyBranding(embed);
}
/**
@@ -49,9 +66,30 @@ export function createSuccessEmbed(message: string, title: string = "Success"):
* @returns An EmbedBuilder instance configured as info.
*/
export function createInfoEmbed(message: string, title: string = "Info"): EmbedBuilder {
return new EmbedBuilder()
const embed = new EmbedBuilder()
.setTitle(` ${title}`)
.setDescription(message)
.setColor(Colors.Blue)
.setTimestamp();
return applyBranding(embed);
}
/**
* Creates a standardized base embed with common configuration.
* @param title Optional title for the embed.
* @param description Optional description for the embed.
* @param color Optional color for the embed.
* @returns An EmbedBuilder instance with base configuration.
*/
export function createBaseEmbed(title?: string, description?: string, color?: ColorResolvable): EmbedBuilder {
const embed = new EmbedBuilder()
.setTimestamp()
.setColor(color ?? BRANDING.COLOR);
if (title) embed.setTitle(title);
if (description) embed.setDescription(description);
return applyBranding(embed);
}

View File

@@ -0,0 +1,23 @@
import { AutocompleteInteraction } from "discord.js";
import { AuroraClient } from "@/lib/BotClient";
import { logger } from "@shared/lib/logger";
/**
* Handles autocomplete interactions for slash commands
*/
export class AutocompleteHandler {
static async handle(interaction: AutocompleteInteraction): Promise<void> {
const command = AuroraClient.commands.get(interaction.commandName);
if (!command || !command.autocomplete) {
return;
}
try {
await command.autocomplete(interaction);
} catch (error) {
logger.error("bot", `Error handling autocomplete for ${interaction.commandName}`, error);
}
}
}

View File

@@ -0,0 +1,83 @@
import { describe, test, expect, mock, beforeEach } from "bun:test";
import { CommandHandler } from "./CommandHandler";
import { AuroraClient } from "@/lib/BotClient";
import { ChatInputCommandInteraction } from "discord.js";
// Mock UserService
mock.module("@shared/modules/user/user.service", () => ({
userService: {
getOrCreateUser: mock(() => Promise.resolve())
}
}));
describe("CommandHandler", () => {
beforeEach(() => {
AuroraClient.commands.clear();
AuroraClient.lastCommandTimestamp = null;
});
test("should update lastCommandTimestamp on successful execution", async () => {
const executeSuccess = mock(() => Promise.resolve());
AuroraClient.commands.set("test", {
data: { name: "test" } as any,
execute: executeSuccess
} as any);
const interaction = {
commandName: "test",
user: { id: "123", username: "testuser" }
} as unknown as ChatInputCommandInteraction;
await CommandHandler.handle(interaction);
expect(executeSuccess).toHaveBeenCalled();
expect(AuroraClient.lastCommandTimestamp).not.toBeNull();
expect(AuroraClient.lastCommandTimestamp).toBeGreaterThan(0);
});
test("should not update lastCommandTimestamp on failed execution", async () => {
const executeError = mock(() => Promise.reject(new Error("Command Failed")));
AuroraClient.commands.set("fail", {
data: { name: "fail" } as any,
execute: executeError
} as any);
const interaction = {
commandName: "fail",
user: { id: "123", username: "testuser" },
replied: false,
deferred: false,
reply: mock(() => Promise.resolve()),
followUp: mock(() => Promise.resolve())
} as unknown as ChatInputCommandInteraction;
await CommandHandler.handle(interaction);
expect(executeError).toHaveBeenCalled();
expect(AuroraClient.lastCommandTimestamp).toBeNull();
});
test("should block execution when maintenance mode is active", async () => {
AuroraClient.maintenanceMode = true;
const executeSpy = mock(() => Promise.resolve());
AuroraClient.commands.set("maint-test", {
data: { name: "maint-test" } as any,
execute: executeSpy
} as any);
const interaction = {
commandName: "maint-test",
user: { id: "123", username: "testuser" },
reply: mock(() => Promise.resolve())
} as unknown as ChatInputCommandInteraction;
await CommandHandler.handle(interaction);
expect(executeSpy).not.toHaveBeenCalled();
expect(interaction.reply).toHaveBeenCalledWith(expect.objectContaining({
flags: expect.anything()
}));
AuroraClient.maintenanceMode = false; // Reset for other tests
});
});

View File

@@ -0,0 +1,49 @@
import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
import { AuroraClient } from "@/lib/BotClient";
import { userService } from "@shared/modules/user/user.service";
import { createErrorEmbed } from "@lib/embeds";
import { logger } from "@shared/lib/logger";
/**
* Handles slash command execution
* Includes user validation and comprehensive error handling
*/
export class CommandHandler {
static async handle(interaction: ChatInputCommandInteraction): Promise<void> {
const command = AuroraClient.commands.get(interaction.commandName);
if (!command) {
logger.error("bot", `No command matching ${interaction.commandName} was found.`);
return;
}
// Check maintenance mode
if (AuroraClient.maintenanceMode) {
const errorEmbed = createErrorEmbed('The bot is currently undergoing maintenance. Please try again later.');
await interaction.reply({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
return;
}
// Ensure user exists in database
try {
await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
} catch (error) {
logger.error("bot", "Failed to ensure user exists", error);
}
try {
await command.execute(interaction);
AuroraClient.lastCommandTimestamp = Date.now();
} catch (error) {
logger.error("bot", `Error executing command ${interaction.commandName}`, error);
const errorEmbed = createErrorEmbed('There was an error while executing this command!');
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
} else {
await interaction.reply({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
}
}
}
}

View File

@@ -0,0 +1,79 @@
import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction, MessageFlags } from "discord.js";
import { UserError } from "@shared/lib/errors";
import { createErrorEmbed } from "@lib/embeds";
import { logger } from "@shared/lib/logger";
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
/**
* Handles component interactions (buttons, select menus, modals)
* Routes to appropriate handlers based on customId patterns
* Provides centralized error handling with UserError differentiation
*/
export class ComponentInteractionHandler {
static async handle(interaction: ComponentInteraction): Promise<void> {
const { interactionRoutes } = await import("@lib/interaction.routes");
for (const route of interactionRoutes) {
if (route.predicate(interaction)) {
const module = await route.handler();
const handlerMethod = module[route.method];
if (typeof handlerMethod === 'function') {
try {
await handlerMethod(interaction);
return;
} catch (error) {
await this.handleError(interaction, error, route.method);
return;
}
} else {
logger.error("bot", `Handler method ${route.method} not found in module`);
}
}
}
}
/**
* Handles errors from interaction handlers
* Differentiates between UserError (user-facing) and system errors
*/
private static async handleError(
interaction: ComponentInteraction,
error: unknown,
handlerName: string
): Promise<void> {
const isUserError = error instanceof UserError;
// Determine error message
const errorMessage = isUserError
? (error as Error).message
: 'An unexpected error occurred. Please try again later.';
// Log system errors (non-user errors) for debugging
if (!isUserError) {
logger.error("bot", `Error in ${handlerName}`, error);
}
const errorEmbed = createErrorEmbed(errorMessage);
try {
// Handle different interaction states
if (interaction.replied || interaction.deferred) {
await interaction.followUp({
embeds: [errorEmbed],
flags: MessageFlags.Ephemeral
});
} else {
await interaction.reply({
embeds: [errorEmbed],
flags: MessageFlags.Ephemeral
});
}
} catch (replyError) {
// If we can't send a reply, log it
logger.error("bot", `Failed to send error response in ${handlerName}`, replyError);
}
}
}

View File

@@ -0,0 +1,3 @@
export { ComponentInteractionHandler } from "./ComponentInteractionHandler";
export { AutocompleteHandler } from "./AutocompleteHandler";
export { CommandHandler } from "./CommandHandler";

View File

@@ -0,0 +1,66 @@
import { ButtonInteraction, ModalSubmitInteraction, StringSelectMenuInteraction } from "discord.js";
// Union type for all component interactions
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
// Type for the handler function that modules export
type InteractionHandler = (interaction: ComponentInteraction) => Promise<void>;
// Type for the dynamically imported module containing the handler
interface InteractionModule {
[key: string]: (...args: any[]) => Promise<void> | any;
}
// Route definition
interface InteractionRoute {
predicate: (interaction: ComponentInteraction) => boolean;
handler: () => Promise<InteractionModule>;
method: string;
}
export const interactionRoutes: InteractionRoute[] = [
// --- TRADE MODULE ---
{
predicate: (i) => i.customId.startsWith("trade_") || i.customId === "amount",
handler: () => import("@/modules/trade/trade.interaction"),
method: 'handleTradeInteraction'
},
// --- ECONOMY MODULE ---
{
predicate: (i) => i.isButton() && i.customId.startsWith("shop_buy_"),
handler: () => import("@/modules/economy/shop.interaction"),
method: 'handleShopInteraction'
},
{
predicate: (i) => i.isButton() && i.customId.startsWith("lootdrop_"),
handler: () => import("@/modules/economy/lootdrop.interaction"),
method: 'handleLootdropInteraction'
},
{
predicate: (i) => i.isButton() && i.customId.startsWith("trivia_"),
handler: () => import("@/modules/trivia/trivia.interaction"),
method: 'handleTriviaInteraction'
},
// --- ADMIN MODULE ---
{
predicate: (i) => i.customId.startsWith("createitem_"),
handler: () => import("@/modules/admin/item_wizard"),
method: 'handleItemWizardInteraction'
},
// --- USER MODULE ---
{
predicate: (i) => i.isButton() && i.customId === "enrollment",
handler: () => import("@/modules/user/enrollment.interaction"),
method: 'handleEnrollmentInteraction'
},
// --- FEEDBACK MODULE ---
{
predicate: (i) => i.customId.startsWith("feedback_"),
handler: () => import("@/modules/feedback/feedback.interaction"),
method: 'handleFeedbackInteraction'
}
];

View File

@@ -0,0 +1,114 @@
import { readdir } from "node:fs/promises";
import { join } from "node:path";
import type { Command } from "@shared/lib/types";
import { config } from "@shared/lib/config";
import type { LoadResult, LoadError } from "./types";
import type { Client } from "../BotClient";
/**
* Handles loading commands from the file system
*/
export class CommandLoader {
private client: Client;
constructor(client: Client) {
this.client = client;
}
/**
* Load commands from a directory recursively
*/
async loadFromDirectory(dir: string, reload: boolean = false): Promise<LoadResult> {
const result: LoadResult = { loaded: 0, skipped: 0, errors: [] };
await this.scanDirectory(dir, reload, result);
return result;
}
/**
* Recursively scan directory for command files
*/
private async scanDirectory(dir: string, reload: boolean, result: LoadResult): Promise<void> {
try {
const files = await readdir(dir, { withFileTypes: true });
for (const file of files) {
const filePath = join(dir, file.name);
if (file.isDirectory()) {
await this.scanDirectory(filePath, reload, result);
continue;
}
if ((!file.name.endsWith('.ts') && !file.name.endsWith('.js')) || file.name.endsWith('.test.ts') || file.name.endsWith('.spec.ts')) continue;
await this.loadCommandFile(filePath, reload, result);
}
} catch (error) {
console.error(`Error reading directory ${dir}:`, error);
result.errors.push({ file: dir, error });
}
}
/**
* Load a single command file
*/
private async loadCommandFile(filePath: string, reload: boolean, result: LoadResult): Promise<void> {
try {
const importPath = reload ? `${filePath}?t=${Date.now()}` : filePath;
const commandModule = await import(importPath);
const commands = Object.values(commandModule);
if (commands.length === 0) {
console.warn(`No commands found in ${filePath}`);
result.skipped++;
return;
}
const category = this.extractCategory(filePath);
for (const command of commands) {
if (this.isValidCommand(command)) {
command.category = category;
// Track all known commands regardless of enabled status
this.client.knownCommands.set(command.data.name, category);
const isEnabled = config.commands[command.data.name] !== false;
if (!isEnabled) {
console.log(`🚫 Skipping disabled command: ${command.data.name}`);
result.skipped++;
continue;
}
this.client.commands.set(command.data.name, command);
console.log(`Loaded command: ${command.data.name}`);
result.loaded++;
} else {
console.warn(`Skipping invalid command in ${filePath}`);
result.skipped++;
}
}
} catch (error) {
console.error(`Failed to load command from ${filePath}:`, error);
result.errors.push({ file: filePath, error });
}
}
/**
* Extract category from file path
* e.g., /path/to/commands/admin/features.ts -> "admin"
*/
private extractCategory(filePath: string): string {
const pathParts = filePath.split('/');
return pathParts[pathParts.length - 2] ?? "uncategorized";
}
/**
* Type guard to validate command structure
*/
private isValidCommand(command: any): command is Command {
return command && typeof command === 'object' && 'data' in command && 'execute' in command;
}
}

View File

@@ -0,0 +1,85 @@
import { readdir } from "node:fs/promises";
import { join } from "node:path";
import type { Event } from "@shared/lib/types";
import type { LoadResult } from "./types";
import type { Client } from "../BotClient";
/**
* Handles loading events from the file system
*/
export class EventLoader {
private client: Client;
constructor(client: Client) {
this.client = client;
}
/**
* Load events from a directory recursively
*/
async loadFromDirectory(dir: string, reload: boolean = false): Promise<LoadResult> {
const result: LoadResult = { loaded: 0, skipped: 0, errors: [] };
await this.scanDirectory(dir, reload, result);
return result;
}
/**
* Recursively scan directory for event files
*/
private async scanDirectory(dir: string, reload: boolean, result: LoadResult): Promise<void> {
try {
const files = await readdir(dir, { withFileTypes: true });
for (const file of files) {
const filePath = join(dir, file.name);
if (file.isDirectory()) {
await this.scanDirectory(filePath, reload, result);
continue;
}
if (!file.name.endsWith('.ts') && !file.name.endsWith('.js')) continue;
await this.loadEventFile(filePath, reload, result);
}
} catch (error) {
console.error(`Error reading directory ${dir}:`, error);
result.errors.push({ file: dir, error });
}
}
/**
* Load a single event file
*/
private async loadEventFile(filePath: string, reload: boolean, result: LoadResult): Promise<void> {
try {
const importPath = reload ? `${filePath}?t=${Date.now()}` : filePath;
const eventModule = await import(importPath);
const event = eventModule.default;
if (this.isValidEvent(event)) {
if (event.once) {
this.client.once(event.name, (...args) => event.execute(...args));
} else {
this.client.on(event.name, (...args) => event.execute(...args));
}
console.log(`Loaded event: ${event.name}`);
result.loaded++;
} else {
console.warn(`Skipping invalid event in ${filePath}`);
result.skipped++;
}
} catch (error) {
console.error(`Failed to load event from ${filePath}:`, error);
result.errors.push({ file: filePath, error });
}
}
/**
* Type guard to validate event structure
*/
private isValidEvent(event: any): event is Event<any> {
return event && typeof event === 'object' && 'name' in event && 'execute' in event;
}
}

16
bot/lib/loaders/types.ts Normal file
View File

@@ -0,0 +1,16 @@
/**
* Result of loading commands or events
*/
export interface LoadResult {
loaded: number;
skipped: number;
errors: LoadError[];
}
/**
* Error that occurred during loading
*/
export interface LoadError {
file: string;
error: unknown;
}

56
bot/lib/shutdown.test.ts Normal file
View File

@@ -0,0 +1,56 @@
import { describe, it, expect, beforeEach } from "bun:test";
import { isShuttingDown, setShuttingDown, incrementTransactions, decrementTransactions, getActiveTransactions, waitForTransactions } from "./shutdown";
describe("shutdown logic", () => {
beforeEach(() => {
setShuttingDown(false);
while (getActiveTransactions() > 0) {
decrementTransactions();
}
});
it("should initialize with shuttingDown as false", () => {
expect(isShuttingDown()).toBe(false);
});
it("should update shuttingDown state", () => {
setShuttingDown(true);
expect(isShuttingDown()).toBe(true);
});
it("should track active transactions", () => {
expect(getActiveTransactions()).toBe(0);
incrementTransactions();
expect(getActiveTransactions()).toBe(1);
decrementTransactions();
expect(getActiveTransactions()).toBe(0);
});
it("should wait for transactions to complete", async () => {
incrementTransactions();
const start = Date.now();
const waitPromise = waitForTransactions(1000);
// Simulate completion after 200ms
setTimeout(() => {
decrementTransactions();
}, 200);
await waitPromise;
const duration = Date.now() - start;
expect(duration).toBeGreaterThanOrEqual(200);
expect(getActiveTransactions()).toBe(0);
});
it("should timeout if transactions never complete", async () => {
incrementTransactions();
const start = Date.now();
await waitForTransactions(500);
const duration = Date.now() - start;
expect(duration).toBeGreaterThanOrEqual(500);
expect(getActiveTransactions()).toBe(1); // Still 1 because we didn't decrement
});
});

30
bot/lib/shutdown.ts Normal file
View File

@@ -0,0 +1,30 @@
let shuttingDown = false;
let activeTransactions = 0;
export const isShuttingDown = () => shuttingDown;
export const setShuttingDown = (value: boolean) => {
shuttingDown = value;
};
export const incrementTransactions = () => {
activeTransactions++;
};
export const decrementTransactions = () => {
activeTransactions--;
};
export const getActiveTransactions = () => activeTransactions;
export const waitForTransactions = async (timeoutMs: number = 10000) => {
const start = Date.now();
while (activeTransactions > 0) {
if (Date.now() - start > timeoutMs) {
console.warn(`Shutdown timed out waiting for ${activeTransactions} transactions after ${timeoutMs}ms`);
break;
}
await new Promise(resolve => setTimeout(resolve, 100));
}
};

56
bot/lib/webhookUtils.ts Normal file
View File

@@ -0,0 +1,56 @@
import { type TextBasedChannel, User } from 'discord.js';
/**
* Sends a message to a channel using a temporary webhook (imitating the bot or custom persona).
*
* @param channel The channel to send the message to (must support webhooks).
* @param payload The message payload (string content or JSON object for embeds/options).
* @param clientUser The client user (bot) to fallback for avatar/name if not specified in payload.
* @param reason The reason for creating the webhook (for audit logs).
*/
export async function sendWebhookMessage(
channel: TextBasedChannel,
payload: any,
clientUser: User,
reason: string
): Promise<void> {
if (!('createWebhook' in channel)) {
throw new Error("Channel does not support webhooks.");
}
// Normalize payload if it's just a string, wrap it in content
if (typeof payload === 'string') {
payload = { content: payload };
}
let webhook;
try {
webhook = await channel.createWebhook({
name: payload.username || `${clientUser.username}`, // Use payload name or bot name
avatar: payload.avatar_url || payload.avatarURL || clientUser.displayAvatarURL(),
reason: reason
});
// Support snake_case keys for raw API compatibility if passed from config
if (payload.avatar_url && !payload.avatarURL) {
payload.avatarURL = payload.avatar_url;
delete payload.avatar_url;
}
await webhook.send(payload);
await webhook.delete(reason);
} catch (error) {
// Attempt cleanup if webhook was created but sending failed
if (webhook) {
try {
await webhook.delete("Cleanup after failure");
} catch (cleanupError) {
console.error("Failed to delete webhook during cleanup:", cleanupError);
}
}
throw error;
}
}

View File

@@ -0,0 +1,260 @@
import { describe, test, expect, spyOn, beforeEach, mock } from "bun:test";
import { handleItemWizardInteraction, renderWizard } from "./item_wizard";
import { ButtonInteraction, ModalSubmitInteraction, StringSelectMenuInteraction } from "discord.js";
// Mock Setup
const valuesMock = mock((_args: any) => Promise.resolve());
const insertMock = mock(() => ({ values: valuesMock }));
mock.module("@shared/db/DrizzleClient", () => ({
DrizzleClient: {
insert: insertMock
}
}));
mock.module("@db/schema", () => ({
items: "items_schema"
}));
describe("ItemWizard", () => {
const userId = "test-user-123";
beforeEach(() => {
valuesMock.mockClear();
insertMock.mockClear();
// Since draftSession is internal, we can't easily clear it.
// We will use unique user IDs or rely on overwrite behavior.
});
// Helper to create base interaction
const createBaseInteraction = (id: string, customId: string) => ({
user: { id },
customId,
deferUpdate: mock(() => Promise.resolve()),
editReply: mock(() => Promise.resolve()),
update: mock(() => Promise.resolve()),
showModal: mock(() => Promise.resolve()),
followUp: mock(() => Promise.resolve()),
reply: mock(() => Promise.resolve()),
});
test("renderWizard should return initial state for new user", () => {
const result = renderWizard(`new-${Date.now()}`);
expect(result.embeds).toHaveLength(1);
expect(result.embeds[0]?.data.title).toContain("New Item");
expect(result.components).toHaveLength(2);
});
test("handleItemWizardInteraction should handle details modal submit", async () => {
const uid = `user-details-${Date.now()}`;
renderWizard(uid); // Init session
const interaction = {
...createBaseInteraction(uid, "createitem_modal_details"),
isButton: () => false,
isStringSelectMenu: () => false,
isModalSubmit: () => true,
isMessageComponent: () => false,
fields: {
getTextInputValue: (key: string) => {
if (key === "name") return "Updated Name";
if (key === "desc") return "Updated Desc";
if (key === "rarity") return "Legendary";
return "";
}
},
} as unknown as ModalSubmitInteraction;
await handleItemWizardInteraction(interaction);
expect(interaction.deferUpdate).toHaveBeenCalled();
const result = renderWizard(uid);
expect(result.embeds[0]?.data.title).toContain("Updated Name");
});
test("handleItemWizardInteraction should handle economy modal submit", async () => {
const uid = `user-economy-${Date.now()}`;
renderWizard(uid);
const interaction = {
...createBaseInteraction(uid, "createitem_modal_economy"),
isButton: () => false,
isStringSelectMenu: () => false,
isModalSubmit: () => true,
isMessageComponent: () => false,
fields: {
getTextInputValue: (key: string) => (key === "price" ? "500" : "")
},
} as unknown as ModalSubmitInteraction;
await handleItemWizardInteraction(interaction);
const result = renderWizard(uid);
const economyField = result.embeds[0]?.data.fields?.find(f => f.name === "Economy");
expect(economyField?.value).toContain("500 🪙");
});
test("handleItemWizardInteraction should handle visuals modal submit", async () => {
const uid = `user-visuals-${Date.now()}`;
renderWizard(uid);
const interaction = {
...createBaseInteraction(uid, "createitem_modal_visuals"),
isButton: () => false,
isStringSelectMenu: () => false,
isModalSubmit: () => true,
isMessageComponent: () => false,
fields: {
getTextInputValue: (key: string) => {
if (key === "icon") return "http://icon.com";
if (key === "image") return "http://image.com";
return "";
}
},
} as unknown as ModalSubmitInteraction;
await handleItemWizardInteraction(interaction);
const result = renderWizard(uid);
expect(result.embeds[0]?.data.thumbnail?.url).toBe("http://icon.com");
expect(result.embeds[0]?.data.image?.url).toBe("http://image.com");
});
test("handleItemWizardInteraction should flow through adding an effect", async () => {
const uid = `user-effect-${Date.now()}`;
renderWizard(uid);
// 1. Start Add Effect
const startInteraction = {
...createBaseInteraction(uid, "createitem_addeffect_start"),
isButton: () => true,
isStringSelectMenu: () => false,
isModalSubmit: () => false,
isMessageComponent: () => true,
} as unknown as ButtonInteraction;
await handleItemWizardInteraction(startInteraction);
expect(startInteraction.update).toHaveBeenCalled(); // Should show select menu
// 2. Select Effect Type
const selectInteraction = {
...createBaseInteraction(uid, "createitem_select_effect_type"),
isButton: () => false,
isStringSelectMenu: () => true,
isModalSubmit: () => false,
isMessageComponent: () => true,
values: ["ADD_XP"]
} as unknown as StringSelectMenuInteraction;
await handleItemWizardInteraction(selectInteraction);
expect(selectInteraction.showModal).toHaveBeenCalled(); // Should show config modal
// 3. Submit Effect Config Modal
const modalInteraction = {
...createBaseInteraction(uid, "createitem_modal_effect"),
isButton: () => false,
isStringSelectMenu: () => false,
isModalSubmit: () => true,
isMessageComponent: () => false,
fields: {
getTextInputValue: (key: string) => (key === "amount" ? "1000" : "")
},
} as unknown as ModalSubmitInteraction;
await handleItemWizardInteraction(modalInteraction);
// Verify Effect Added
const result = renderWizard(uid);
const effectsField = result.embeds[0]?.data.fields?.find(f => f.name === "Usage Effects");
expect(effectsField?.value).toContain("ADD_XP");
expect(effectsField?.value).toContain("1000");
});
test("handleItemWizardInteraction should save item to database", async () => {
const uid = `user-save-${Date.now()}`;
renderWizard(uid);
// Set name first so we can check it
const nameInteraction = {
...createBaseInteraction(uid, "createitem_modal_details"),
isButton: () => false,
isStringSelectMenu: () => false,
isModalSubmit: () => true,
isMessageComponent: () => false,
fields: {
getTextInputValue: (key: string) => {
if (key === "name") return "Saved Item";
if (key === "desc") return "Desc";
if (key === "rarity") return "Common";
return "";
}
},
} as unknown as ModalSubmitInteraction;
await handleItemWizardInteraction(nameInteraction);
// Save
const saveInteraction = {
...createBaseInteraction(uid, "createitem_save"),
isButton: () => true,
isStringSelectMenu: () => false,
isModalSubmit: () => false,
isMessageComponent: () => true,
} as unknown as ButtonInteraction;
await handleItemWizardInteraction(saveInteraction);
expect(valuesMock).toHaveBeenCalled();
const calls = valuesMock.mock.calls as any[];
if (calls.length > 0) {
const callArgs = calls[0][0];
expect(callArgs).toMatchObject({
name: "Saved Item",
description: "Desc",
rarity: "Common",
// Add other fields as needed
});
}
expect(saveInteraction.editReply).toHaveBeenCalledWith(expect.objectContaining({
content: expect.stringContaining("successfully")
}));
});
test("handleItemWizardInteraction should cancel and clear session", async () => {
const uid = `user-cancel-${Date.now()}`;
renderWizard(uid);
const interaction = {
...createBaseInteraction(uid, "createitem_cancel"),
isButton: () => true, // Technically any component
isStringSelectMenu: () => false,
isModalSubmit: () => false,
isMessageComponent: () => true,
} as unknown as ButtonInteraction;
await handleItemWizardInteraction(interaction);
expect(interaction.update).toHaveBeenCalledWith(expect.objectContaining({
content: expect.stringContaining("cancelled")
}));
// Verify session is gone by checking if renderWizard returns default New Item
// Let's modify it first
const modInteraction = {
...createBaseInteraction(uid, "createitem_modal_details"),
isButton: () => false,
isStringSelectMenu: () => false,
isModalSubmit: () => true,
isMessageComponent: () => false,
fields: {
getTextInputValue: (key: string) => (key === "name" ? "Modified" : "x")
},
} as unknown as ModalSubmitInteraction;
await handleItemWizardInteraction(modInteraction);
// Now Cancel
await handleItemWizardInteraction(interaction);
// New render should be "New Item" not "Modified"
const result = renderWizard(uid);
expect(result.embeds[0]?.data.title).toContain("New Item");
expect(result.embeds[0]?.data.title).not.toContain("Modified");
});
});

View File

@@ -0,0 +1,248 @@
import { type Interaction } from "discord.js";
import { items } from "@db/schema";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import type { ItemUsageData, ItemEffect } from "@shared/lib/types";
import { getItemWizardEmbed, getItemTypeSelection, getEffectTypeSelection, getDetailsModal, getEconomyModal, getVisualsModal, getEffectConfigModal } from "./item_wizard.view";
import type { DraftItem } from "./item_wizard.types";
import { ItemType, EffectType } from "@shared/lib/constants";
// --- Types ---
// --- State ---
const draftSession = new Map<string, DraftItem>();
// --- Render ---
export const renderWizard = (userId: string, isDraft = true) => {
let draft = draftSession.get(userId);
// Initialize if new
if (!draft) {
draft = {
name: "New Item",
description: "No description",
rarity: "Common",
type: ItemType.MATERIAL,
price: null,
iconUrl: "",
imageUrl: "",
usageData: { consume: true, effects: [] } // Default Consume to true for now
};
draftSession.set(userId, draft);
}
const { embeds, components } = getItemWizardEmbed(draft);
return { embeds, components };
};
// --- Handler ---
export const handleItemWizardInteraction = async (interaction: Interaction) => {
// Only handle createitem interactions
if (!interaction.isButton() && !interaction.isStringSelectMenu() && !interaction.isModalSubmit()) return;
if (!interaction.customId.startsWith("createitem_")) return;
const userId = interaction.user.id;
let draft = draftSession.get(userId);
// Special case for Cancel - doesn't need draft checks usually, but we want to clear it
if (interaction.customId === "createitem_cancel") {
draftSession.delete(userId);
if (interaction.isMessageComponent()) {
await interaction.update({ content: "❌ Item creation cancelled.", embeds: [], components: [] });
}
return;
}
// Initialize draft if missing for other actions (edge case: bot restart)
if (!draft) {
if (interaction.isMessageComponent()) {
// Create one implicitly to prevent crashes, or warn user
if (interaction.customId === "createitem_start") {
// Allow start
} else {
await interaction.reply({ content: "⚠️ Session expired. Please run `/createitem` again.", ephemeral: true });
return;
}
}
}
// Re-get draft (guaranteed now if we handled the start/restart)
// Actually renderWizard initializes it, so if we call that we are safe.
// But for Modals we need it.
if (!draft) {
// Just init it
renderWizard(userId);
draft = draftSession.get(userId)!;
}
// --- Routing ---
// 1. Details Modal
if (interaction.customId === "createitem_details") {
if (!interaction.isButton()) return;
const modal = getDetailsModal(draft);
await interaction.showModal(modal);
return;
}
// 2. Economy Modal
if (interaction.customId === "createitem_economy") {
if (!interaction.isButton()) return;
const modal = getEconomyModal(draft);
await interaction.showModal(modal);
return;
}
// 3. Visuals Modal
if (interaction.customId === "createitem_visuals") {
if (!interaction.isButton()) return;
const modal = getVisualsModal(draft);
await interaction.showModal(modal);
return;
}
// 4. Type Toggle (Start Select Menu)
if (interaction.customId === "createitem_type_toggle") {
if (!interaction.isButton()) return;
const { components } = getItemTypeSelection();
await interaction.update({ components }); // Temporary view
return;
}
if (interaction.customId === "createitem_select_type") {
if (!interaction.isStringSelectMenu()) return;
const selected = interaction.values[0];
if (selected) {
draft.type = selected;
}
// Re-render
const payload = renderWizard(userId);
await interaction.update(payload);
return;
}
// 5. Add Effect Flow
if (interaction.customId === "createitem_addeffect_start") {
if (!interaction.isButton()) return;
const { components } = getEffectTypeSelection();
await interaction.update({ components });
return;
}
if (interaction.customId === "createitem_select_effect_type") {
if (!interaction.isStringSelectMenu()) return;
const effectType = interaction.values[0];
if (!effectType) return;
draft.pendingEffectType = effectType;
// Immediately show modal for data collection
// Note: You can't showModal from an update? You CAN showModal from a component interaction (SelectMenu).
// But we shouldn't update the message AND show modal. We must pick one.
// We will show modal. The message remains in "Select Effect" state until modal submit re-renders it.
const modal = getEffectConfigModal(effectType);
await interaction.showModal(modal);
return;
}
// Toggle Consume
if (interaction.customId === "createitem_toggle_consume") {
if (!interaction.isButton()) return;
draft.usageData.consume = !draft.usageData.consume;
const payload = renderWizard(userId);
await interaction.update(payload);
return;
}
// 6. Handle Modal Submits
if (interaction.isModalSubmit()) {
if (interaction.customId === "createitem_modal_details") {
draft.name = interaction.fields.getTextInputValue("name");
draft.description = interaction.fields.getTextInputValue("desc");
draft.rarity = interaction.fields.getTextInputValue("rarity");
}
else if (interaction.customId === "createitem_modal_economy") {
const price = parseInt(interaction.fields.getTextInputValue("price"));
draft.price = isNaN(price) || price === 0 ? null : price;
}
else if (interaction.customId === "createitem_modal_visuals") {
draft.iconUrl = interaction.fields.getTextInputValue("icon");
draft.imageUrl = interaction.fields.getTextInputValue("image");
}
else if (interaction.customId === "createitem_modal_effect") {
const type = draft.pendingEffectType;
if (type) {
let effect: ItemEffect | null = null;
if (type === EffectType.ADD_XP || type === EffectType.ADD_BALANCE) {
const amount = parseInt(interaction.fields.getTextInputValue("amount"));
if (!isNaN(amount)) effect = { type: type as any, amount };
}
else if (type === EffectType.REPLY_MESSAGE) {
effect = { type: EffectType.REPLY_MESSAGE, message: interaction.fields.getTextInputValue("message") };
}
else if (type === EffectType.XP_BOOST) {
const multiplier = parseFloat(interaction.fields.getTextInputValue("multiplier"));
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
if (!isNaN(multiplier) && !isNaN(duration)) effect = { type: EffectType.XP_BOOST, multiplier, durationSeconds: duration };
}
else if (type === EffectType.TEMP_ROLE) {
const roleId = interaction.fields.getTextInputValue("role_id");
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
if (roleId && !isNaN(duration)) effect = { type: EffectType.TEMP_ROLE, roleId: roleId, durationSeconds: duration };
}
else if (type === EffectType.COLOR_ROLE) {
const roleId = interaction.fields.getTextInputValue("role_id");
if (roleId) effect = { type: EffectType.COLOR_ROLE, roleId: roleId };
}
if (effect) {
draft.usageData.effects.push(effect);
}
draft.pendingEffectType = undefined;
}
}
// Re-render
const payload = renderWizard(userId);
await interaction.deferUpdate();
await interaction.editReply(payload);
return;
}
// 7. Save
if (interaction.customId === "createitem_save") {
if (!interaction.isButton()) return;
await interaction.deferUpdate(); // Prepare to save
try {
await DrizzleClient.insert(items).values({
name: draft.name,
description: draft.description,
type: draft.type,
rarity: draft.rarity,
price: draft.price ? BigInt(draft.price) : null,
iconUrl: draft.iconUrl,
imageUrl: draft.imageUrl,
usageData: draft.usageData
});
draftSession.delete(userId);
await interaction.editReply({ content: `✅ **${draft.name}** has been created successfully!`, embeds: [], components: [] });
} catch (error: any) {
console.error("Failed to create item:", error);
// Restore state
await interaction.followUp({ content: `❌ Failed to save item: ${error.message}`, ephemeral: true });
}
}
};
export const clearDraftSessions = () => {
draftSession.clear();
console.log("[ItemWizard] All draft item creation sessions cleared.");
};

View File

@@ -0,0 +1,14 @@
import type { ItemUsageData } from "@shared/lib/types";
export interface DraftItem {
name: string;
description: string;
rarity: string;
type: string;
price: number | null;
iconUrl: string;
imageUrl: string;
usageData: ItemUsageData;
// Temporary state for effect adding flow
pendingEffectType?: string;
}

View File

@@ -0,0 +1,135 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ModalBuilder,
StringSelectMenuBuilder,
TextInputBuilder,
TextInputStyle,
type MessageActionRowComponentBuilder
} from "discord.js";
import { createBaseEmbed } from "@lib/embeds";
import type { DraftItem } from "./item_wizard.types";
import { ItemType } from "@shared/lib/constants";
const getItemTypeOptions = () => [
{ label: "Material", value: ItemType.MATERIAL, description: "Used for crafting or trading" },
{ label: "Consumable", value: ItemType.CONSUMABLE, description: "Can be used to gain effects" },
{ label: "Equipment", value: ItemType.EQUIPMENT, description: "Can be equipped (Not yet implemented)" },
{ label: "Quest Item", value: ItemType.QUEST, description: "Required for quests" },
];
const getEffectTypeOptions = () => [
{ label: "Add XP", value: "ADD_XP", description: "Gives XP to the user" },
{ label: "Add Balance", value: "ADD_BALANCE", description: "Gives currency to the user" },
{ label: "Reply Message", value: "REPLY_MESSAGE", description: "Bot replies with a message" },
{ label: "XP Boost", value: "XP_BOOST", description: "Temporarily boosts XP gain" },
{ label: "Temp Role", value: "TEMP_ROLE", description: "Gives a temporary role" },
{ label: "Color Role", value: "COLOR_ROLE", description: "Equips a permanent color role (swaps)" },
];
export const getItemWizardEmbed = (draft: DraftItem) => {
const embed = createBaseEmbed(`🛠️ Item Creator: ${draft.name}`, undefined, "Blue")
.addFields(
{ name: "General", value: `**Type:** ${draft.type}\n**Rarity:** ${draft.rarity}\n**Desc:** ${draft.description}`, inline: true },
{ name: "Economy", value: `**Price:** ${draft.price ? `${draft.price} 🪙` : "Not for sale"}`, inline: true },
{ name: "Visuals", value: `**Icon:** ${draft.iconUrl ? "✅ Set" : "❌"}\n**Image:** ${draft.imageUrl ? "✅ Set" : "❌"}`, inline: true },
{ name: "Usage", value: `**Consume:** ${draft.usageData.consume ? "✅ Yes" : "❌ No"}`, inline: true },
);
// Effects Display
if (draft.usageData.effects.length > 0) {
const effecto = draft.usageData.effects.map((e, i) => `${i + 1}. **${e.type}**: ${JSON.stringify(e)}`).join("\n");
embed.addFields({ name: "Usage Effects", value: effecto.substring(0, 1024) });
} else {
embed.addFields({ name: "Usage Effects", value: "None" });
}
if (draft.imageUrl) embed.setImage(draft.imageUrl);
if (draft.iconUrl) embed.setThumbnail(draft.iconUrl);
// Components
const row1 = new ActionRowBuilder<MessageActionRowComponentBuilder>()
.addComponents(
new ButtonBuilder().setCustomId("createitem_details").setLabel("Edit Details").setStyle(ButtonStyle.Secondary).setEmoji("📝"),
new ButtonBuilder().setCustomId("createitem_economy").setLabel("Edit Economy").setStyle(ButtonStyle.Secondary).setEmoji("💰"),
new ButtonBuilder().setCustomId("createitem_visuals").setLabel("Edit Visuals").setStyle(ButtonStyle.Secondary).setEmoji("🖼️"),
new ButtonBuilder().setCustomId("createitem_type_toggle").setLabel("Change Type").setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
);
const row2 = new ActionRowBuilder<MessageActionRowComponentBuilder>()
.addComponents(
new ButtonBuilder().setCustomId("createitem_addeffect_start").setLabel("Add Effect").setStyle(ButtonStyle.Primary).setEmoji("✨"),
new ButtonBuilder().setCustomId("createitem_toggle_consume").setLabel(`Consume: ${draft.usageData.consume ? "ON" : "OFF"}`).setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
new ButtonBuilder().setCustomId("createitem_save").setLabel("Save Item").setStyle(ButtonStyle.Success).setEmoji("💾"),
new ButtonBuilder().setCustomId("createitem_cancel").setLabel("Cancel").setStyle(ButtonStyle.Danger).setEmoji("✖️")
);
return { embeds: [embed], components: [row1, row2] };
};
export const getItemTypeSelection = () => {
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
new StringSelectMenuBuilder().setCustomId("createitem_select_type").setPlaceholder("Select Item Type").addOptions(getItemTypeOptions())
);
return { components: [row] };
};
export const getEffectTypeSelection = () => {
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
new StringSelectMenuBuilder().setCustomId("createitem_select_effect_type").setPlaceholder("Select Effect Type").addOptions(getEffectTypeOptions())
);
return { components: [row] };
};
export const getDetailsModal = (current: DraftItem) => {
const modal = new ModalBuilder().setCustomId("createitem_modal_details").setTitle("Edit Details");
modal.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("name").setLabel("Name").setValue(current.name).setStyle(TextInputStyle.Short).setRequired(true)),
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("desc").setLabel("Description").setValue(current.description).setStyle(TextInputStyle.Paragraph).setRequired(false)),
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("rarity").setLabel("Rarity").setValue(current.rarity).setStyle(TextInputStyle.Short).setPlaceholder("Common, Rare, Legendary...").setRequired(true))
);
return modal;
};
export const getEconomyModal = (current: DraftItem) => {
const modal = new ModalBuilder().setCustomId("createitem_modal_economy").setTitle("Edit Economy");
modal.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("price").setLabel("Price (0 for not for sale)").setValue(current.price?.toString() || "0").setStyle(TextInputStyle.Short).setRequired(true))
);
return modal;
};
export const getVisualsModal = (current: DraftItem) => {
const modal = new ModalBuilder().setCustomId("createitem_modal_visuals").setTitle("Edit Visuals");
modal.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("icon").setLabel("Icon URL (Emoji or Link)").setValue(current.iconUrl).setStyle(TextInputStyle.Short).setRequired(false)),
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("image").setLabel("Image URL").setValue(current.imageUrl).setStyle(TextInputStyle.Short).setRequired(false))
);
return modal;
};
export const getEffectConfigModal = (effectType: string) => {
let modal = new ModalBuilder().setCustomId("createitem_modal_effect").setTitle(`Config ${effectType}`);
if (effectType === "ADD_XP" || effectType === "ADD_BALANCE") {
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("amount").setLabel("Amount").setStyle(TextInputStyle.Short).setRequired(true).setPlaceholder("100")));
} else if (effectType === "REPLY_MESSAGE") {
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("message").setLabel("Message").setStyle(TextInputStyle.Paragraph).setRequired(true)));
} else if (effectType === "XP_BOOST") {
modal.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("multiplier").setLabel("Multiplier (e.g. 1.5)").setStyle(TextInputStyle.Short).setRequired(true)),
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("duration").setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
);
} else if (effectType === "TEMP_ROLE") {
modal.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("role_id").setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true)),
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("duration").setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
);
} else if (effectType === "COLOR_ROLE") {
modal.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("role_id").setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true))
);
}
return modal;
};

View File

@@ -0,0 +1,33 @@
export interface RestartContext {
channelId: string;
userId: string;
timestamp: number;
runMigrations: boolean;
installDependencies: boolean;
previousCommit: string;
newCommit: string;
}
export interface UpdateCheckResult {
needsRootInstall: boolean;
needsWebInstall: boolean;
needsMigrations: boolean;
changedFiles: string[];
error?: Error;
}
export interface UpdateInfo {
hasUpdates: boolean;
branch: string;
currentCommit: string;
latestCommit: string;
commitCount: number;
commits: CommitInfo[];
}
export interface CommitInfo {
hash: string;
message: string;
author: string;
}

View File

@@ -0,0 +1,274 @@
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js";
import { createInfoEmbed, createSuccessEmbed, createWarningEmbed, createErrorEmbed } from "@lib/embeds";
import type { UpdateInfo, UpdateCheckResult } from "./update.types";
// Constants for UI
const LOG_TRUNCATE_LENGTH = 800;
const OUTPUT_TRUNCATE_LENGTH = 400;
function truncate(text: string, maxLength: number): string {
if (!text) return "";
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
}
// ============ Pre-Update Embeds ============
export function getCheckingEmbed() {
return createInfoEmbed("🔍 Fetching latest changes from remote...", "Checking for Updates");
}
export function getNoUpdatesEmbed(currentCommit: string) {
return createSuccessEmbed(
`You're running the latest version.\n\n**Current:** \`${currentCommit}\``,
"✅ Already Up to Date"
);
}
export function getUpdatesAvailableMessage(
updateInfo: UpdateInfo,
requirements: UpdateCheckResult,
changeCategories: Record<string, number>,
force: boolean
) {
const { branch, currentCommit, latestCommit, commitCount, commits } = updateInfo;
const { needsRootInstall, needsWebInstall, needsMigrations } = requirements;
// Build commit list (max 5)
const commitList = commits
.slice(0, 5)
.map(c => `\`${c.hash}\` ${truncate(c.message, 50)}`)
.join("\n");
const moreCommits = commitCount > 5 ? `\n*...and ${commitCount - 5} more*` : "";
// Build change categories
const categoryList = Object.entries(changeCategories)
.map(([cat, count]) => `${cat}: ${count} file${count > 1 ? "s" : ""}`)
.join("\n");
// Build requirements list
const reqs: string[] = [];
if (needsRootInstall) reqs.push("📦 Install root dependencies");
if (needsWebInstall) reqs.push("🌐 Install web dependencies");
if (needsMigrations) reqs.push("🗃️ Run database migrations");
if (reqs.length === 0) reqs.push("⚡ Quick update (no extra steps)");
const embed = new EmbedBuilder()
.setTitle("📥 Updates Available")
.setColor(force ? 0xFF6B6B : 0x5865F2)
.addFields(
{
name: "Version",
value: `\`${currentCommit}\`\`${latestCommit}\``,
inline: true
},
{
name: "Branch",
value: `\`${branch}\``,
inline: true
},
{
name: "Commits",
value: `${commitCount} new commit${commitCount > 1 ? "s" : ""}`,
inline: true
},
{
name: "Recent Changes",
value: commitList + moreCommits || "No commits",
inline: false
},
{
name: "Files Changed",
value: categoryList || "Unknown",
inline: true
},
{
name: "Update Actions",
value: reqs.join("\n"),
inline: true
}
)
.setFooter({ text: force ? "⚠️ Force mode enabled" : "This will restart the bot" })
.setTimestamp();
const confirmButton = new ButtonBuilder()
.setCustomId("confirm_update")
.setLabel(force ? "Force Update" : "Update Now")
.setEmoji(force ? "⚠️" : "🚀")
.setStyle(force ? ButtonStyle.Danger : ButtonStyle.Success);
const cancelButton = new ButtonBuilder()
.setCustomId("cancel_update")
.setLabel("Cancel")
.setStyle(ButtonStyle.Secondary);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(confirmButton, cancelButton);
return { embeds: [embed], components: [row] };
}
// ============ Update Progress Embeds ============
export function getPreparingEmbed() {
return createInfoEmbed(
"🔒 Saving rollback point...\n📥 Preparing to download updates...",
"⏳ Preparing Update"
);
}
export function getUpdatingEmbed(requirements: UpdateCheckResult) {
const steps: string[] = ["✅ Rollback point saved"];
steps.push("📥 Downloading updates...");
if (requirements.needsRootInstall || requirements.needsWebInstall) {
steps.push("📦 Dependencies will be installed after restart");
}
if (requirements.needsMigrations) {
steps.push("🗃️ Migrations will run after restart");
}
steps.push("\n🔄 **Restarting now...**");
return createWarningEmbed(steps.join("\n"), "🚀 Updating");
}
export function getCancelledEmbed() {
return createInfoEmbed("Update cancelled. No changes were made.", "❌ Cancelled");
}
export function getTimeoutEmbed() {
return createWarningEmbed(
"No response received within 30 seconds.\nRun `/update` again when ready.",
"⏰ Timed Out"
);
}
export function getErrorEmbed(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return createErrorEmbed(
`The update could not be completed:\n\`\`\`\n${truncate(message, 500)}\n\`\`\``,
"❌ Update Failed"
);
}
// ============ Post-Restart Embeds ============
export interface PostRestartResult {
installSuccess: boolean;
installOutput: string;
migrationSuccess: boolean;
migrationOutput: string;
ranInstall: boolean;
ranMigrations: boolean;
previousCommit?: string;
newCommit?: string;
}
export function getPostRestartEmbed(result: PostRestartResult, hasRollback: boolean) {
const isSuccess = result.installSuccess && result.migrationSuccess;
const embed = new EmbedBuilder()
.setTitle(isSuccess ? "✅ Update Complete" : "⚠️ Update Completed with Issues")
.setColor(isSuccess ? 0x57F287 : 0xFEE75C)
.setTimestamp();
// Version info
if (result.previousCommit && result.newCommit) {
embed.addFields({
name: "Version",
value: `\`${result.previousCommit}\`\`${result.newCommit}\``,
inline: false
});
}
// Results summary
const results: string[] = [];
if (result.ranInstall) {
results.push(result.installSuccess
? "✅ Dependencies installed"
: "❌ Dependency installation failed"
);
}
if (result.ranMigrations) {
results.push(result.migrationSuccess
? "✅ Migrations applied"
: "❌ Migration failed"
);
}
if (results.length > 0) {
embed.addFields({
name: "Actions Performed",
value: results.join("\n"),
inline: false
});
}
// Output details (collapsed if too long)
if (result.installOutput && !result.installSuccess) {
embed.addFields({
name: "Install Output",
value: `\`\`\`\n${truncate(result.installOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``,
inline: false
});
}
if (result.migrationOutput && !result.migrationSuccess) {
embed.addFields({
name: "Migration Output",
value: `\`\`\`\n${truncate(result.migrationOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``,
inline: false
});
}
// Footer with rollback hint
if (!isSuccess && hasRollback) {
embed.setFooter({ text: "💡 Use /update rollback to revert if needed" });
}
// Build components
const components: ActionRowBuilder<ButtonBuilder>[] = [];
if (!isSuccess && hasRollback) {
const rollbackButton = new ButtonBuilder()
.setCustomId("rollback_update")
.setLabel("Rollback")
.setEmoji("↩️")
.setStyle(ButtonStyle.Danger);
components.push(new ActionRowBuilder<ButtonBuilder>().addComponents(rollbackButton));
}
return { embeds: [embed], components };
}
export function getInstallingDependenciesEmbed() {
return createInfoEmbed(
"📦 Installing dependencies for root and web projects...\nThis may take a moment.",
"⏳ Installing Dependencies"
);
}
export function getRunningMigrationsEmbed() {
return createInfoEmbed(
"🗃️ Applying database migrations...",
"⏳ Running Migrations"
);
}
export function getRollbackSuccessEmbed(commit: string) {
return createSuccessEmbed(
`Successfully rolled back to commit \`${commit}\`.\nThe bot will restart now.`,
"↩️ Rollback Complete"
);
}
export function getRollbackFailedEmbed(error: string) {
return createErrorEmbed(
`Could not rollback:\n\`\`\`\n${error}\n\`\`\``,
"❌ Rollback Failed"
);
}

View File

@@ -0,0 +1,35 @@
import { ButtonInteraction } from "discord.js";
import { lootdropService } from "@shared/modules/economy/lootdrop.service";
import { UserError } from "@shared/lib/errors";
import { getLootdropClaimedMessage } from "./lootdrop.view";
export async function handleLootdropInteraction(interaction: ButtonInteraction) {
if (interaction.customId === "lootdrop_claim") {
await interaction.deferReply({ ephemeral: true });
const result = await lootdropService.tryClaim(interaction.message.id, interaction.user.id, interaction.user.username);
if (!result.success) {
throw new UserError(result.error || "Failed to claim.");
}
await interaction.editReply({
content: `🎉 You successfully claimed **${result.amount} ${result.currency}**!`
});
const { content, files, components } = await getLootdropClaimedMessage(
interaction.user.id,
interaction.user.username,
interaction.user.displayAvatarURL({ extension: "png" }),
result.amount || 0,
result.currency || "Coins"
);
await interaction.message.edit({
content,
embeds: [],
files,
components
});
}
}

View File

@@ -0,0 +1,43 @@
import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
import { generateLootdropCard, generateClaimedLootdropCard } from "@/graphics/lootdrop";
export async function getLootdropMessage(reward: number, currency: string) {
const cardBuffer = await generateLootdropCard(reward, currency);
const attachment = new AttachmentBuilder(cardBuffer, { name: "lootdrop.png" });
const claimButton = new ButtonBuilder()
.setCustomId("lootdrop_claim")
.setLabel("CLAIM REWARD")
.setStyle(ButtonStyle.Secondary) // Changed to Secondary to fit the darker theme better? Or keep Success? Let's try Secondary with custom emoji
.setEmoji("🌠");
const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(claimButton);
return {
content: "",
files: [attachment],
components: [row]
};
}
export async function getLootdropClaimedMessage(userId: string, username: string, avatarUrl: string, amount: number, currency: string) {
const cardBuffer = await generateClaimedLootdropCard(amount, currency, username, avatarUrl);
const attachment = new AttachmentBuilder(cardBuffer, { name: "lootdrop_claimed.png" });
const newRow = new ActionRowBuilder<ButtonBuilder>()
.addComponents(
new ButtonBuilder()
.setCustomId("lootdrop_claim_disabled")
.setLabel("CLAIMED")
.setStyle(ButtonStyle.Secondary)
.setEmoji("✅")
.setDisabled(true)
);
return {
content: ``, // Remove content as the image says it all
files: [attachment],
components: [newRow]
};
}

View File

@@ -0,0 +1,34 @@
import { ButtonInteraction, MessageFlags } from "discord.js";
import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { userService } from "@shared/modules/user/user.service";
import { UserError } from "@shared/lib/errors";
export async function handleShopInteraction(interaction: ButtonInteraction) {
if (!interaction.customId.startsWith("shop_buy_")) return;
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const itemId = parseInt(interaction.customId.replace("shop_buy_", ""));
if (isNaN(itemId)) {
throw new UserError("Invalid Item ID.");
}
const item = await inventoryService.getItem(itemId);
if (!item || !item.price) {
throw new UserError("Item not found or not for sale.");
}
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
if (!user) {
throw new UserError("User profiles could not be loaded. Please try again later.");
}
// Double check balance here too, although service handles it, we want a nice message
if ((user.balance ?? 0n) < item.price) {
throw new UserError(`You need ${item.price} 🪙 to buy this item. You have ${user.balance?.toString() ?? "0"} 🪙.`);
}
await inventoryService.buyItem(user.id.toString(), item.id, 1n);
await interaction.editReply({ content: `✅ **Success!** You bought **${item.name}** for ${item.price} 🪙.` });
}

View File

@@ -0,0 +1,20 @@
import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
import { createBaseEmbed } from "@/lib/embeds";
export function getShopListingMessage(item: { id: number; name: string; description: string | null; formattedPrice: string; iconUrl: string | null; imageUrl: string | null; price: number | bigint }) {
const embed = createBaseEmbed(`Shop: ${item.name}`, item.description || "No description available.", "Green")
.addFields({ name: "Price", value: item.formattedPrice, inline: true })
.setThumbnail(item.iconUrl || null)
.setImage(item.imageUrl || null)
.setFooter({ text: "Click the button below to purchase instantly." });
const buyButton = new ButtonBuilder()
.setCustomId(`shop_buy_${item.id}`)
.setLabel(`Buy for ${item.price} 🪙`)
.setStyle(ButtonStyle.Success)
.setEmoji("🛒");
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton);
return { embeds: [embed], components: [row] };
}

View File

@@ -0,0 +1,79 @@
import type { Interaction } from "discord.js";
import { TextChannel, MessageFlags } from "discord.js";
import { config } from "@shared/lib/config";
import { AuroraClient } from "@/lib/BotClient";
import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view";
import { FEEDBACK_CUSTOM_IDS, type FeedbackType, type FeedbackData } from "./feedback.types";
import { UserError } from "@shared/lib/errors";
export const handleFeedbackInteraction = async (interaction: Interaction) => {
// Handle select menu for choosing feedback type
if (interaction.isStringSelectMenu() && interaction.customId === "feedback_select_type") {
const feedbackType = interaction.values[0] as FeedbackType;
if (!feedbackType) {
throw new UserError("Invalid feedback type selected.");
}
const modal = getFeedbackModal(feedbackType);
await interaction.showModal(modal);
return;
}
// Handle modal submission
if (interaction.isModalSubmit() && interaction.customId.startsWith(FEEDBACK_CUSTOM_IDS.MODAL)) {
// Extract feedback type from customId (format: feedback_modal_FEATURE_REQUEST)
const parts = interaction.customId.split("_");
const feedbackType = parts.slice(2).join("_") as FeedbackType;
console.log(`Processing feedback modal. CustomId: ${interaction.customId}, Extracted type: ${feedbackType}`);
if (!feedbackType || !["FEATURE_REQUEST", "BUG_REPORT", "GENERAL"].includes(feedbackType)) {
console.error(`Invalid feedback type extracted: ${feedbackType} from customId: ${interaction.customId}`);
throw new UserError("An error occurred processing your feedback. Please try again.");
}
if (!config.feedbackChannelId) {
throw new UserError("Feedback channel is not configured. Please contact an administrator.");
}
// Parse modal inputs
const title = interaction.fields.getTextInputValue(FEEDBACK_CUSTOM_IDS.TITLE_FIELD);
const description = interaction.fields.getTextInputValue(FEEDBACK_CUSTOM_IDS.DESCRIPTION_FIELD);
// Build feedback data
const feedbackData: FeedbackData = {
type: feedbackType,
title,
description,
userId: interaction.user.id,
username: interaction.user.username,
timestamp: new Date()
};
// Get feedback channel
const channel = await AuroraClient.channels.fetch(config.feedbackChannelId).catch(() => null) as TextChannel | null;
if (!channel) {
throw new UserError("Feedback channel not found. Please contact an administrator.");
}
// Build and send beautiful message
const containers = buildFeedbackMessage(feedbackData);
const feedbackMessage = await channel.send({
components: containers as any,
flags: MessageFlags.IsComponentsV2
});
// Add reaction votes
await feedbackMessage.react("👍");
await feedbackMessage.react("👎");
// Confirm to user
await interaction.reply({
content: "✨ **Feedback Submitted**\nYour feedback has been submitted successfully! Thank you for helping improve Aurora.",
flags: MessageFlags.Ephemeral
});
}
};

View File

@@ -0,0 +1,23 @@
export type FeedbackType = "FEATURE_REQUEST" | "BUG_REPORT" | "GENERAL";
export interface FeedbackData {
type: FeedbackType;
title: string;
description: string;
userId: string;
username: string;
timestamp: Date;
}
export const FEEDBACK_TYPE_LABELS: Record<FeedbackType, string> = {
FEATURE_REQUEST: "💡 Feature Request",
BUG_REPORT: "🐛 Bug Report",
GENERAL: "💬 General Feedback"
};
export const FEEDBACK_CUSTOM_IDS = {
MODAL: "feedback_modal",
TYPE_FIELD: "feedback_type",
TITLE_FIELD: "feedback_title",
DESCRIPTION_FIELD: "feedback_description"
} as const;

View File

@@ -0,0 +1,123 @@
import {
ModalBuilder,
TextInputBuilder,
TextInputStyle,
ActionRowBuilder,
StringSelectMenuBuilder,
ActionRowBuilder as MessageActionRowBuilder,
ContainerBuilder,
TextDisplayBuilder,
ButtonBuilder,
ButtonStyle
} from "discord.js";
import { FEEDBACK_TYPE_LABELS, FEEDBACK_CUSTOM_IDS, type FeedbackData, type FeedbackType } from "./feedback.types";
export function getFeedbackTypeMenu() {
const select = new StringSelectMenuBuilder()
.setCustomId("feedback_select_type")
.setPlaceholder("Choose feedback type")
.addOptions([
{
label: "💡 Feature Request",
description: "Suggest a new feature or improvement",
value: "FEATURE_REQUEST"
},
{
label: "🐛 Bug Report",
description: "Report a bug or issue",
value: "BUG_REPORT"
},
{
label: "💬 General Feedback",
description: "Share your thoughts or suggestions",
value: "GENERAL"
}
]);
const row = new MessageActionRowBuilder<StringSelectMenuBuilder>().addComponents(select);
return { components: [row] };
}
export function getFeedbackModal(feedbackType: FeedbackType) {
const modal = new ModalBuilder()
.setCustomId(`${FEEDBACK_CUSTOM_IDS.MODAL}_${feedbackType}`)
.setTitle(FEEDBACK_TYPE_LABELS[feedbackType]);
// Title Input
const titleInput = new TextInputBuilder()
.setCustomId(FEEDBACK_CUSTOM_IDS.TITLE_FIELD)
.setLabel("Title")
.setStyle(TextInputStyle.Short)
.setPlaceholder("Brief summary of your feedback")
.setRequired(true)
.setMaxLength(100);
const titleRow = new ActionRowBuilder<TextInputBuilder>().addComponents(titleInput);
// Description Input
const descriptionInput = new TextInputBuilder()
.setCustomId(FEEDBACK_CUSTOM_IDS.DESCRIPTION_FIELD)
.setLabel("Description")
.setStyle(TextInputStyle.Paragraph)
.setPlaceholder("Provide detailed information about your feedback")
.setRequired(true)
.setMaxLength(1000);
const descriptionRow = new ActionRowBuilder<TextInputBuilder>().addComponents(descriptionInput);
modal.addComponents(titleRow, descriptionRow);
return modal;
}
export function buildFeedbackMessage(feedback: FeedbackData) {
// Define colors/themes for each feedback type
const themes = {
FEATURE_REQUEST: {
icon: "💡",
color: "Blue",
title: "FEATURE REQUEST",
description: "A new starlight suggestion has been received"
},
BUG_REPORT: {
icon: "🐛",
color: "Red",
title: "BUG REPORT",
description: "A cosmic anomaly has been detected"
},
GENERAL: {
icon: "💬",
color: "Gray",
title: "GENERAL FEEDBACK",
description: "A message from the cosmos"
}
};
const theme = themes[feedback.type];
if (!theme) {
console.error(`Unknown feedback type: ${feedback.type}`);
throw new Error(`Invalid feedback type: ${feedback.type}`);
}
const timestamp = Math.floor(feedback.timestamp.getTime() / 1000);
// Header Container
const headerContainer = new ContainerBuilder()
.addTextDisplayComponents(
new TextDisplayBuilder().setContent(`# ${theme.icon} ${theme.title}`),
new TextDisplayBuilder().setContent(`*${theme.description}*`)
);
// Content Container
const contentContainer = new ContainerBuilder()
.addTextDisplayComponents(
new TextDisplayBuilder().setContent(`## ${feedback.title}`),
new TextDisplayBuilder().setContent(`> ${feedback.description.split('\n').join('\n> ')}`),
new TextDisplayBuilder().setContent(
`**Submitted by:** <@${feedback.userId}>\n**Time:** <t:${timestamp}:F> (<t:${timestamp}:R>)`
)
);
return [headerContainer, contentContainer];
}

View File

@@ -0,0 +1,137 @@
import { levelingService } from "@shared/modules/leveling/leveling.service";
import { economyService } from "@shared/modules/economy/economy.service";
import { userTimers } from "@db/schema";
import type { EffectHandler } from "./types";
import type { LootTableItem } from "@shared/lib/types";
import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { inventory, items } from "@db/schema";
import { TimerType, TransactionType, LootType } from "@shared/lib/constants";
// Helper to extract duration in seconds
const getDuration = (effect: any): number => {
if (effect.durationHours) return effect.durationHours * 3600;
if (effect.durationMinutes) return effect.durationMinutes * 60;
return effect.durationSeconds || 60; // Default to 60s if nothing provided
};
export const handleAddXp: EffectHandler = async (userId, effect, txFn) => {
await levelingService.addXp(userId, BigInt(effect.amount), txFn);
return `Gained ${effect.amount} XP`;
};
export const handleAddBalance: EffectHandler = async (userId, effect, txFn) => {
await economyService.modifyUserBalance(userId, BigInt(effect.amount), TransactionType.ITEM_USE, `Used Item`, null, txFn);
return `Gained ${effect.amount} 🪙`;
};
export const handleReplyMessage: EffectHandler = async (_userId, effect, _txFn) => {
return effect.message;
};
export const handleXpBoost: EffectHandler = async (userId, effect, txFn) => {
const boostDuration = getDuration(effect);
const expiresAt = new Date(Date.now() + boostDuration * 1000);
await txFn.insert(userTimers).values({
userId: BigInt(userId),
type: TimerType.EFFECT,
key: 'xp_boost',
expiresAt: expiresAt,
metadata: { multiplier: effect.multiplier }
}).onConflictDoUpdate({
target: [userTimers.userId, userTimers.type, userTimers.key],
set: { expiresAt: expiresAt, metadata: { multiplier: effect.multiplier } }
});
return `XP Boost (${effect.multiplier}x) active for ${Math.floor(boostDuration / 60)}m`;
};
export const handleTempRole: EffectHandler = async (userId, effect, txFn) => {
const roleDuration = getDuration(effect);
const roleExpiresAt = new Date(Date.now() + roleDuration * 1000);
await txFn.insert(userTimers).values({
userId: BigInt(userId),
type: TimerType.ACCESS,
key: `role_${effect.roleId}`,
expiresAt: roleExpiresAt,
metadata: { roleId: effect.roleId }
}).onConflictDoUpdate({
target: [userTimers.userId, userTimers.type, userTimers.key],
set: { expiresAt: roleExpiresAt }
});
// Actual role assignment happens in the Command layer
return `Temporary Role granted for ${Math.floor(roleDuration / 60)}m`;
};
export const handleColorRole: EffectHandler = async (_userId, _effect, _txFn) => {
return "Color Role Equipped";
};
export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
const pool = effect.pool as LootTableItem[];
if (!pool || pool.length === 0) return "The box is empty...";
const totalWeight = pool.reduce((sum, item) => sum + item.weight, 0);
let random = Math.random() * totalWeight;
let winner: LootTableItem | null = null;
for (const item of pool) {
if (random < item.weight) {
winner = item;
break;
}
random -= item.weight;
}
if (!winner) return "The box is empty..."; // Should not happen
// Process Winner
if (winner.type === LootType.NOTHING) {
return winner.message || "You found nothing inside.";
}
if (winner.type === LootType.CURRENCY) {
let amount = winner.amount || 0;
if (winner.minAmount && winner.maxAmount) {
amount = Math.floor(Math.random() * (winner.maxAmount - winner.minAmount + 1)) + winner.minAmount;
}
if (amount > 0) {
await economyService.modifyUserBalance(userId, BigInt(amount), TransactionType.LOOTBOX, 'Lootbox Reward', null, txFn);
return winner.message || `You found ${amount} 🪙!`;
}
}
if (winner.type === LootType.XP) {
let amount = winner.amount || 0;
if (winner.minAmount && winner.maxAmount) {
amount = Math.floor(Math.random() * (winner.maxAmount - winner.minAmount + 1)) + winner.minAmount;
}
if (amount > 0) {
await levelingService.addXp(userId, BigInt(amount), txFn);
return winner.message || `You gained ${amount} XP!`;
}
}
if (winner.type === LootType.ITEM) {
if (winner.itemId) {
const quantity = BigInt(winner.amount || 1);
await inventoryService.addItem(userId, winner.itemId, quantity, txFn);
// Try to fetch item name for the message
try {
const item = await txFn.query.items.findFirst({
where: (items: any, { eq }: any) => eq(items.id, winner.itemId!)
});
if (item) {
return winner.message || `You found ${quantity > 1 ? quantity + 'x ' : ''}**${item.name}**!`;
}
} catch (e) {
console.error("Failed to fetch item name for lootbox message", e);
}
return winner.message || `You found an item! (ID: ${winner.itemId})`;
}
}
return "You found nothing suitable inside.";
};

View File

@@ -0,0 +1,20 @@
import {
handleAddXp,
handleAddBalance,
handleReplyMessage,
handleXpBoost,
handleTempRole,
handleColorRole,
handleLootbox
} from "./handlers";
import type { EffectHandler } from "./types";
export const effectHandlers: Record<string, EffectHandler> = {
'ADD_XP': handleAddXp,
'ADD_BALANCE': handleAddBalance,
'REPLY_MESSAGE': handleReplyMessage,
'XP_BOOST': handleXpBoost,
'TEMP_ROLE': handleTempRole,
'COLOR_ROLE': handleColorRole,
'LOOTBOX': handleLootbox
};

View File

@@ -0,0 +1,4 @@
import type { Transaction } from "@shared/lib/types";
export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<string>;

View File

@@ -0,0 +1,54 @@
import { EmbedBuilder } from "discord.js";
import type { ItemUsageData } from "@shared/lib/types";
import { EffectType } from "@shared/lib/constants";
/**
* Inventory entry with item details
*/
interface InventoryEntry {
quantity: bigint | null;
item: {
id: number;
name: string;
[key: string]: any;
};
}
/**
* Creates an embed displaying a user's inventory
*/
export function getInventoryEmbed(items: InventoryEntry[], username: string): EmbedBuilder {
const description = items.map(entry => {
return `**${entry.item.name}** x${entry.quantity}`;
}).join("\n");
return new EmbedBuilder()
.setTitle(`📦 ${username}'s Inventory`)
.setDescription(description)
.setColor(0x3498db); // Blue
}
/**
* Creates an embed showing the results of using an item
*/
export function getItemUseResultEmbed(results: string[], item?: { name: string, iconUrl: string | null, usageData: any }): EmbedBuilder {
const description = results.map(r => `${r}`).join("\n");
// Check if it was a lootbox
const isLootbox = item?.usageData?.effects?.some((e: any) => e.type === EffectType.LOOTBOX);
const embed = new EmbedBuilder()
.setDescription(description)
.setColor(isLootbox ? 0xFFD700 : 0x2ecc71); // Gold for lootbox, Green otherwise
if (isLootbox && item) {
embed.setTitle(`🎁 ${item.name} Opened!`);
if (item.iconUrl) {
embed.setThumbnail(item.iconUrl);
}
} else {
embed.setTitle(item ? `✅ Used ${item.name}` : "✅ Item Used!");
}
return embed;
}

View File

@@ -0,0 +1,71 @@
import { EmbedBuilder } from "discord.js";
/**
* User data for leaderboard display
*/
interface LeaderboardUser {
username: string;
level: number | null;
xp: bigint | null;
balance: bigint | null;
netWorth?: bigint | null;
}
/**
* Returns the appropriate medal emoji for a ranking position
*/
function getMedalEmoji(index: number): string {
if (index === 0) return "🥇";
if (index === 1) return "🥈";
if (index === 2) return "🥉";
return `${index + 1}.`;
}
/**
* Formats a single leaderboard entry based on type
*/
function formatLeaderEntry(user: LeaderboardUser, index: number, type: 'xp' | 'balance' | 'networth'): string {
const medal = getMedalEmoji(index);
let value = '';
switch (type) {
case 'xp':
value = `Lvl ${user.level ?? 1} (${user.xp ?? 0n} XP)`;
break;
case 'balance':
value = `${user.balance ?? 0n} 🪙`;
break;
case 'networth':
value = `${user.netWorth ?? 0n} 🪙 (Net Worth)`;
break;
}
return `${medal} **${user.username}** — ${value}`;
}
/**
* Creates a leaderboard embed for either XP, Balance or Net Worth rankings
*/
export function getLeaderboardEmbed(leaders: LeaderboardUser[], type: 'xp' | 'balance' | 'networth'): EmbedBuilder {
const description = leaders.map((user, index) =>
formatLeaderEntry(user, index, type)
).join("\n");
let title = '';
switch (type) {
case 'xp':
title = "🏆 XP Leaderboard";
break;
case 'balance':
title = "💰 Richest Players";
break;
case 'networth':
title = "💎 Net Worth Leaderboard";
break;
}
return new EmbedBuilder()
.setTitle(title)
.setDescription(description)
.setColor(0xFFD700); // Gold
}

View File

@@ -0,0 +1,46 @@
import { CaseType } from "@shared/lib/constants";
export { CaseType };
export interface CreateCaseOptions {
type: CaseType;
userId: string;
username: string;
moderatorId: string;
moderatorName: string;
reason: string;
metadata?: Record<string, any>;
}
export interface ClearCaseOptions {
caseId: string;
clearedBy: string;
clearedByName: string;
reason?: string;
}
export interface ModerationCase {
id: bigint;
caseId: string;
type: string;
userId: bigint;
username: string;
moderatorId: bigint;
moderatorName: string;
reason: string;
metadata: unknown;
active: boolean;
createdAt: Date;
resolvedAt: Date | null;
resolvedBy: bigint | null;
resolvedReason: string | null;
}
export interface SearchCasesFilter {
userId?: string;
moderatorId?: string;
type?: CaseType;
active?: boolean;
limit?: number;
offset?: number;
}

View File

@@ -0,0 +1,241 @@
import { EmbedBuilder, Colors, time, TimestampStyles } from "discord.js";
import type { ModerationCase } from "./moderation.types";
/**
* Get color based on case type
*/
function getCaseColor(type: string): number {
switch (type) {
case 'warn': return Colors.Yellow;
case 'timeout': return Colors.Orange;
case 'kick': return Colors.Red;
case 'ban': return Colors.DarkRed;
case 'note': return Colors.Blue;
case 'prune': return Colors.Grey;
default: return Colors.Grey;
}
}
/**
* Get emoji based on case type
*/
function getCaseEmoji(type: string): string {
switch (type) {
case 'warn': return '⚠️';
case 'timeout': return '🔇';
case 'kick': return '👢';
case 'ban': return '🔨';
case 'note': return '📝';
case 'prune': return '🧹';
default: return '📋';
}
}
/**
* Display a single case
*/
export function getCaseEmbed(moderationCase: ModerationCase): EmbedBuilder {
const emoji = getCaseEmoji(moderationCase.type);
const color = getCaseColor(moderationCase.type);
const embed = new EmbedBuilder()
.setTitle(`${emoji} Case ${moderationCase.caseId}`)
.setColor(color)
.addFields(
{ name: 'Type', value: moderationCase.type.toUpperCase(), inline: true },
{ name: 'Status', value: moderationCase.active ? '🟢 Active' : '⚫ Resolved', inline: true },
{ name: '\u200B', value: '\u200B', inline: true },
{ name: 'User', value: `${moderationCase.username} (${moderationCase.userId})`, inline: false },
{ name: 'Moderator', value: moderationCase.moderatorName, inline: true },
{ name: 'Date', value: time(moderationCase.createdAt, TimestampStyles.ShortDateTime), inline: true }
)
.addFields({ name: 'Reason', value: moderationCase.reason })
.setTimestamp(moderationCase.createdAt);
// Add resolution info if resolved
if (!moderationCase.active && moderationCase.resolvedAt) {
embed.addFields(
{ name: '\u200B', value: '**Resolution**' },
{ name: 'Resolved At', value: time(moderationCase.resolvedAt, TimestampStyles.ShortDateTime), inline: true }
);
if (moderationCase.resolvedReason) {
embed.addFields({ name: 'Resolution Reason', value: moderationCase.resolvedReason });
}
}
// Add metadata if present
if (moderationCase.metadata && Object.keys(moderationCase.metadata).length > 0) {
const metadataStr = JSON.stringify(moderationCase.metadata, null, 2);
if (metadataStr.length < 1024) {
embed.addFields({ name: 'Additional Info', value: `\`\`\`json\n${metadataStr}\n\`\`\`` });
}
}
return embed;
}
/**
* Display a list of cases
*/
export function getCasesListEmbed(
cases: ModerationCase[],
title: string,
description?: string
): EmbedBuilder {
const embed = new EmbedBuilder()
.setTitle(title)
.setColor(Colors.Blue)
.setTimestamp();
if (description) {
embed.setDescription(description);
}
if (cases.length === 0) {
embed.setDescription('No cases found.');
return embed;
}
// Group by type for better display
const casesByType: Record<string, ModerationCase[]> = {};
for (const c of cases) {
if (!casesByType[c.type]) {
casesByType[c.type] = [];
}
casesByType[c.type]!.push(c);
}
// Add fields for each type
for (const [type, typeCases] of Object.entries(casesByType)) {
const emoji = getCaseEmoji(type);
const caseList = typeCases.slice(0, 5).map(c => {
const status = c.active ? '🟢' : '⚫';
const date = time(c.createdAt, TimestampStyles.ShortDate);
return `${status} **${c.caseId}** - ${c.reason.substring(0, 50)}${c.reason.length > 50 ? '...' : ''} (${date})`;
}).join('\n');
embed.addFields({
name: `${emoji} ${type.toUpperCase()} (${typeCases.length})`,
value: caseList || 'None',
inline: false
});
if (typeCases.length > 5) {
embed.addFields({
name: '\u200B',
value: `_...and ${typeCases.length - 5} more_`,
inline: false
});
}
}
return embed;
}
/**
* Display user's active warnings
*/
export function getWarningsEmbed(warnings: ModerationCase[], username: string): EmbedBuilder {
const embed = new EmbedBuilder()
.setTitle(`⚠️ Active Warnings for ${username}`)
.setColor(Colors.Yellow)
.setTimestamp();
if (warnings.length === 0) {
embed.setDescription('No active warnings.');
return embed;
}
embed.setDescription(`**Total Active Warnings:** ${warnings.length}`);
for (const warning of warnings.slice(0, 10)) {
const date = time(warning.createdAt, TimestampStyles.ShortDateTime);
embed.addFields({
name: `${warning.caseId} - ${date}`,
value: `**Moderator:** ${warning.moderatorName}\n**Reason:** ${warning.reason}`,
inline: false
});
}
if (warnings.length > 10) {
embed.addFields({
name: '\u200B',
value: `_...and ${warnings.length - 10} more warnings. Use \`/cases\` to view all._`,
inline: false
});
}
return embed;
}
/**
* Success message after warning a user
*/
export function getWarnSuccessEmbed(caseId: string, username: string, reason: string): EmbedBuilder {
return new EmbedBuilder()
.setTitle('✅ Warning Issued')
.setDescription(`**${username}** has been warned.`)
.addFields(
{ name: 'Case ID', value: caseId, inline: true },
{ name: 'Reason', value: reason, inline: false }
)
.setColor(Colors.Green)
.setTimestamp();
}
/**
* Success message after adding a note
*/
export function getNoteSuccessEmbed(caseId: string, username: string): EmbedBuilder {
return new EmbedBuilder()
.setTitle('✅ Note Added')
.setDescription(`Staff note added for **${username}**.`)
.addFields({ name: 'Case ID', value: caseId, inline: true })
.setColor(Colors.Green)
.setTimestamp();
}
/**
* Success message after clearing a warning
*/
export function getClearSuccessEmbed(caseId: string): EmbedBuilder {
return new EmbedBuilder()
.setTitle('✅ Warning Cleared')
.setDescription(`Case **${caseId}** has been resolved.`)
.setColor(Colors.Green)
.setTimestamp();
}
/**
* Error embed for moderation operations
*/
export function getModerationErrorEmbed(message: string): EmbedBuilder {
return new EmbedBuilder()
.setTitle('❌ Error')
.setDescription(message)
.setColor(Colors.Red)
.setTimestamp();
}
/**
* Warning embed to send to user via DM
*/
export function getUserWarningEmbed(
serverName: string,
reason: string,
caseId: string,
warningCount: number
): EmbedBuilder {
return new EmbedBuilder()
.setTitle('⚠️ You have received a warning')
.setDescription(`You have been warned in **${serverName}**.`)
.addFields(
{ name: 'Reason', value: reason, inline: false },
{ name: 'Case ID', value: caseId, inline: true },
{ name: 'Total Warnings', value: warningCount.toString(), inline: true }
)
.setColor(Colors.Yellow)
.setTimestamp()
.setFooter({ text: 'Please review the server rules to avoid further action.' });
}

View File

@@ -0,0 +1,18 @@
export interface PruneOptions {
amount?: number;
userId?: string;
all?: boolean;
}
export interface PruneResult {
deletedCount: number;
requestedCount: number;
filtered: boolean;
username?: string;
skippedOld?: number;
}
export interface PruneProgress {
current: number;
total: number;
}

View File

@@ -0,0 +1,115 @@
import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, Colors } from "discord.js";
import type { PruneResult, PruneProgress } from "./prune.types";
/**
* Creates a confirmation message for prune operations
*/
export function getConfirmationMessage(
amount: number | 'all',
estimatedCount?: number
): { embeds: EmbedBuilder[], components: ActionRowBuilder<ButtonBuilder>[] } {
const isAll = amount === 'all';
const messageCount = isAll ? estimatedCount : amount;
const embed = new EmbedBuilder()
.setTitle("⚠️ Confirm Deletion")
.setDescription(
isAll
? `You are about to delete **ALL messages** in this channel.\n\n` +
`Estimated messages: **~${estimatedCount || 'Unknown'}**\n` +
`This action **cannot be undone**.`
: `You are about to delete **${amount} messages**.\n\n` +
`This action **cannot be undone**.`
)
.setColor(Colors.Orange)
.setTimestamp();
const confirmButton = new ButtonBuilder()
.setCustomId("confirm_prune")
.setLabel("Confirm")
.setStyle(ButtonStyle.Danger);
const cancelButton = new ButtonBuilder()
.setCustomId("cancel_prune")
.setLabel("Cancel")
.setStyle(ButtonStyle.Secondary);
const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(confirmButton, cancelButton);
return { embeds: [embed], components: [row] };
}
/**
* Creates a progress embed for ongoing deletions
*/
export function getProgressEmbed(progress: PruneProgress): EmbedBuilder {
const percentage = Math.round((progress.current / progress.total) * 100);
return new EmbedBuilder()
.setTitle("🔄 Deleting Messages")
.setDescription(
`Progress: **${progress.current}/${progress.total}** (${percentage}%)\n\n` +
`Please wait...`
)
.setColor(Colors.Blue)
.setTimestamp();
}
/**
* Creates a success embed after deletion
*/
export function getSuccessEmbed(result: PruneResult): EmbedBuilder {
let description = `Successfully deleted **${result.deletedCount} messages**.`;
if (result.filtered && result.username) {
description = `Successfully deleted **${result.deletedCount} messages** from **${result.username}**.`;
}
if (result.skippedOld && result.skippedOld > 0) {
description += `\n\n⚠ **${result.skippedOld} messages** were older than 14 days and could not be deleted.`;
}
if (result.deletedCount < result.requestedCount && !result.skippedOld) {
description += `\n\n Only **${result.deletedCount}** messages were available to delete.`;
}
return new EmbedBuilder()
.setTitle("✅ Messages Deleted")
.setDescription(description)
.setColor(Colors.Green)
.setTimestamp();
}
/**
* Creates an error embed
*/
export function getPruneErrorEmbed(message: string): EmbedBuilder {
return new EmbedBuilder()
.setTitle("❌ Prune Failed")
.setDescription(message)
.setColor(Colors.Red)
.setTimestamp();
}
/**
* Creates a warning embed
*/
export function getPruneWarningEmbed(message: string): EmbedBuilder {
return new EmbedBuilder()
.setTitle("⚠️ Warning")
.setDescription(message)
.setColor(Colors.Yellow)
.setTimestamp();
}
/**
* Creates a cancelled embed
*/
export function getCancelledEmbed(): EmbedBuilder {
return new EmbedBuilder()
.setTitle("🚫 Deletion Cancelled")
.setDescription("Message deletion has been cancelled.")
.setColor(Colors.Grey)
.setTimestamp();
}

View File

@@ -0,0 +1,54 @@
import { EmbedBuilder } from "discord.js";
/**
* Quest entry with quest details and progress
*/
interface QuestEntry {
progress: number | null;
completedAt: Date | null;
quest: {
name: string;
description: string | null;
rewards: any;
};
}
/**
* Formats quest rewards object into a human-readable string
*/
function formatQuestRewards(rewards: { xp?: number, balance?: number }): string {
const rewardStr: string[] = [];
if (rewards?.xp) rewardStr.push(`${rewards.xp} XP`);
if (rewards?.balance) rewardStr.push(`${rewards.balance} 🪙`);
return rewardStr.join(", ");
}
/**
* Returns the quest status display string
*/
function getQuestStatus(completedAt: Date | null): string {
return completedAt ? "✅ Completed" : "📝 In Progress";
}
/**
* Creates an embed displaying a user's quest log
*/
export function getQuestListEmbed(userQuests: QuestEntry[]): EmbedBuilder {
const embed = new EmbedBuilder()
.setTitle("📜 Quest Log")
.setColor(0x3498db); // Blue
userQuests.forEach(entry => {
const status = getQuestStatus(entry.completedAt);
const rewards = entry.quest.rewards as { xp?: number, balance?: number };
const rewardsText = formatQuestRewards(rewards);
embed.addFields({
name: `${entry.quest.name} (${status})`,
value: `${entry.quest.description}\n**Rewards:** ${rewardsText}\n**Progress:** ${entry.progress}%`,
inline: false
});
});
return embed;
}

View File

@@ -0,0 +1,21 @@
import { temporaryRoleService } from "@shared/modules/system/temp-role.service";
export const schedulerService = {
start: () => {
console.log("🕒 Scheduler started: Maintenance loops initialized.");
// 1. Temporary Role Revocation (every 60s)
setInterval(() => {
temporaryRoleService.processExpiredRoles();
}, 60 * 1000);
// 2. Terminal Update Loop (every 60s)
const { terminalService } = require("@shared/modules/terminal/terminal.service");
setInterval(() => {
terminalService.update();
}, 60 * 1000);
// Run an initial check on start
temporaryRoleService.processExpiredRoles();
}
};

View File

@@ -1,24 +1,19 @@
import {
type Interaction,
ButtonInteraction,
ModalSubmitInteraction,
StringSelectMenuInteraction,
type Interaction,
EmbedBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
StringSelectMenuBuilder,
ModalBuilder,
TextInputBuilder,
TextInputStyle,
ThreadChannel,
TextChannel,
EmbedBuilder
} from "discord.js";
import { TradeService } from "./trade.service";
import { inventoryService } from "@/modules/inventory/inventory.service";
import { tradeService } from "@shared/modules/trade/trade.service";
import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds";
import { UserError } from "@shared/lib/errors";
import { getTradeDashboard, getTradeMoneyModal, getItemSelectMenu, getTradeCompletedEmbed } from "./trade.view";
const EMBED_COLOR = 0xFFD700; // Gold
export async function handleTradeInteraction(interaction: Interaction) {
if (!interaction.isButton() && !interaction.isStringSelectMenu() && !interaction.isModalSubmit()) return;
@@ -28,43 +23,34 @@ export async function handleTradeInteraction(interaction: Interaction) {
if (!threadId) return;
try {
if (customId === 'trade_cancel') {
await handleCancel(interaction, threadId);
} else if (customId === 'trade_lock') {
await handleLock(interaction, threadId);
} else if (customId === 'trade_confirm') {
// Confirm logic is handled implicitly by both locking or explicitly if needed.
// For now, locking both triggers execution, so no separate confirm handler is actively used
// unless we re-introduce a specific button. keeping basic handler stub if needed.
} else if (customId === 'trade_add_money') {
await handleAddMoneyClick(interaction);
} else if (customId === 'trade_money_modal') {
await handleMoneySubmit(interaction as ModalSubmitInteraction, threadId);
} else if (customId === 'trade_add_item') {
await handleAddItemClick(interaction as ButtonInteraction, threadId);
} else if (customId === 'trade_select_item') {
await handleItemSelect(interaction as StringSelectMenuInteraction, threadId);
} else if (customId === 'trade_remove_item') {
await handleRemoveItemClick(interaction as ButtonInteraction, threadId);
} else if (customId === 'trade_remove_item_select') {
await handleRemoveItemSelect(interaction as StringSelectMenuInteraction, threadId);
}
} catch (error: any) {
const errorEmbed = createErrorEmbed(error.message);
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ embeds: [errorEmbed], ephemeral: true });
} else {
await interaction.reply({ embeds: [errorEmbed], ephemeral: true });
}
if (customId === 'trade_cancel') {
await handleCancel(interaction, threadId);
} else if (customId === 'trade_lock') {
await handleLock(interaction, threadId);
} else if (customId === 'trade_confirm') {
// Confirm logic is handled implicitly by both locking or explicitly if needed.
// For now, locking both triggers execution, so no separate confirm handler is actively used
// unless we re-introduce a specific button. keeping basic handler stub if needed.
} else if (customId === 'trade_add_money') {
await handleAddMoneyClick(interaction);
} else if (customId === 'trade_money_modal') {
await handleMoneySubmit(interaction as ModalSubmitInteraction, threadId);
} else if (customId === 'trade_add_item') {
await handleAddItemClick(interaction as ButtonInteraction, threadId);
} else if (customId === 'trade_select_item') {
await handleItemSelect(interaction as StringSelectMenuInteraction, threadId);
} else if (customId === 'trade_remove_item') {
await handleRemoveItemClick(interaction as ButtonInteraction, threadId);
} else if (customId === 'trade_remove_item_select') {
await handleRemoveItemSelect(interaction as StringSelectMenuInteraction, threadId);
}
}
async function handleCancel(interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, threadId: string) {
const session = TradeService.getSession(threadId);
const session = tradeService.getSession(threadId);
const user = interaction.user;
TradeService.endSession(threadId);
tradeService.endSession(threadId);
await interaction.deferUpdate();
@@ -76,11 +62,11 @@ async function handleCancel(interaction: ButtonInteraction | StringSelectMenuInt
async function handleLock(interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, threadId: string) {
await interaction.deferUpdate();
const isLocked = TradeService.toggleLock(threadId, interaction.user.id);
const isLocked = tradeService.toggleLock(threadId, interaction.user.id);
await updateTradeDashboard(interaction, threadId);
// Check if trade executed (both locked)
const session = TradeService.getSession(threadId);
const session = tradeService.getSession(threadId);
if (session && session.state === 'COMPLETED') {
// Trade executed during updateTradeDashboard
return;
@@ -91,20 +77,7 @@ async function handleLock(interaction: ButtonInteraction | StringSelectMenuInter
async function handleAddMoneyClick(interaction: Interaction) {
if (!interaction.isButton()) return;
const modal = new ModalBuilder()
.setCustomId('trade_money_modal')
.setTitle('Add Money');
const input = new TextInputBuilder()
.setCustomId('amount')
.setLabel("Amount to trade")
.setStyle(TextInputStyle.Short)
.setPlaceholder("100")
.setRequired(true);
const row = new ActionRowBuilder<TextInputBuilder>().addComponents(input);
modal.addComponents(row);
const modal = getTradeMoneyModal();
await interaction.showModal(modal);
}
@@ -112,9 +85,9 @@ async function handleMoneySubmit(interaction: ModalSubmitInteraction, threadId:
const amountStr = interaction.fields.getTextInputValue('amount');
const amount = BigInt(amountStr);
if (amount < 0n) throw new Error("Amount must be positive");
if (amount < 0n) throw new UserError("Amount must be positive");
TradeService.updateMoney(threadId, interaction.user.id, amount);
tradeService.updateMoney(threadId, interaction.user.id, amount);
await interaction.deferUpdate(); // Acknowledge modal
await updateTradeDashboard(interaction, threadId);
}
@@ -128,20 +101,14 @@ async function handleAddItemClick(interaction: ButtonInteraction, threadId: stri
}
// Slice top 25 for select menu
const options = inventory.slice(0, 25).map(entry => ({
const options = inventory.slice(0, 25).map((entry: any) => ({
label: `${entry.item.name} (${entry.quantity})`,
value: entry.item.id.toString(),
description: `Rarity: ${entry.item.rarity}`
description: `Rarity: ${entry.item.rarity} `
}));
const select = new StringSelectMenuBuilder()
.setCustomId('trade_select_item')
.setPlaceholder('Select an item to add')
.addOptions(options);
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select);
await interaction.reply({ content: "Select an item to add:", components: [row], ephemeral: true });
const { components } = getItemSelectMenu(options, 'trade_select_item', 'Select an item to add');
await interaction.reply({ content: "Select an item to add:", components, ephemeral: true });
}
async function handleItemSelect(interaction: StringSelectMenuInteraction, threadId: string) {
@@ -151,16 +118,16 @@ async function handleItemSelect(interaction: StringSelectMenuInteraction, thread
// Assuming implementation implies adding 1 item for now
const item = await inventoryService.getItem(itemId);
if (!item) throw new Error("Item not found");
if (!item) throw new UserError("Item not found");
TradeService.addItem(threadId, interaction.user.id, { id: item.id, name: item.name }, 1n);
tradeService.addItem(threadId, interaction.user.id, { id: item.id, name: item.name }, 1n);
await interaction.update({ content: `Added ${item.name} x1`, components: [] });
await updateTradeDashboard(interaction, threadId);
}
async function handleRemoveItemClick(interaction: ButtonInteraction, threadId: string) {
const session = TradeService.getSession(threadId);
const session = tradeService.getSession(threadId);
if (!session) return;
const participant = session.userA.id === interaction.user.id ? session.userA : session.userB;
@@ -175,21 +142,15 @@ async function handleRemoveItemClick(interaction: ButtonInteraction, threadId: s
value: i.id.toString(),
}));
const select = new StringSelectMenuBuilder()
.setCustomId('trade_remove_item_select')
.setPlaceholder('Select an item to remove')
.addOptions(options);
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select);
await interaction.reply({ content: "Select an item to remove:", components: [row], ephemeral: true });
const { components } = getItemSelectMenu(options, 'trade_remove_item_select', 'Select an item to remove');
await interaction.reply({ content: "Select an item to remove:", components, ephemeral: true });
}
async function handleRemoveItemSelect(interaction: StringSelectMenuInteraction, threadId: string) {
const value = interaction.values[0];
if (!value) return;
const itemId = parseInt(value);
TradeService.removeItem(threadId, interaction.user.id, itemId);
tradeService.removeItem(threadId, interaction.user.id, itemId);
await interaction.update({ content: `Removed item.`, components: [] });
await updateTradeDashboard(interaction, threadId);
@@ -199,23 +160,15 @@ async function handleRemoveItemSelect(interaction: StringSelectMenuInteraction,
// --- DASHBOARD UPDATER ---
export async function updateTradeDashboard(interaction: Interaction, threadId: string) {
const session = TradeService.getSession(threadId);
const session = tradeService.getSession(threadId);
if (!session) return;
// Check Auto-Execute (If both locked)
if (session.userA.locked && session.userB.locked) {
// Execute Trade
try {
await TradeService.executeTrade(threadId);
const embed = new EmbedBuilder()
.setTitle("✅ Trade Completed")
.setColor("Green")
.addFields(
{ name: session.userA.username, value: formatOffer(session.userA), inline: true },
{ name: session.userB.username, value: formatOffer(session.userB), inline: true }
)
.setTimestamp();
await tradeService.executeTrade(threadId);
const embed = getTradeCompletedEmbed(session);
await updateDashboardMessage(interaction, { embeds: [embed], components: [] });
// Notify and Schedule Cleanup
@@ -223,7 +176,7 @@ export async function updateTradeDashboard(interaction: Interaction, threadId: s
const successEmbed = createSuccessEmbed("Trade executed successfully. Items and funds have been transferred.", "Trade Complete");
await scheduleThreadCleanup(
interaction.channel,
`🎉 Trade successful! <@${session.userA.id}> <@${session.userB.id}>\nThis thread will be deleted in 10 seconds.`,
`🎉 Trade successful! < @${session.userA.id}> <@${session.userB.id}>\nThis thread will be deleted in 10 seconds.`,
10000,
successEmbed
);
@@ -246,33 +199,8 @@ export async function updateTradeDashboard(interaction: Interaction, threadId: s
}
// Build Status Embed
const embed = new EmbedBuilder()
.setTitle("🤝 Trading Session")
.setColor(EMBED_COLOR)
.addFields(
{
name: `${session.userA.username} ${session.userA.locked ? '✅ (Ready)' : '✏️ (Editing)'}`,
value: formatOffer(session.userA),
inline: true
},
{
name: `${session.userB.username} ${session.userB.locked ? '✅ (Ready)' : '✏️ (Editing)'}`,
value: formatOffer(session.userB),
inline: true
}
)
.setFooter({ text: "Both parties must click Lock to confirm trade." });
const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(
new ButtonBuilder().setCustomId('trade_add_item').setLabel('Add Item').setStyle(ButtonStyle.Secondary),
new ButtonBuilder().setCustomId('trade_add_money').setLabel('Add Money').setStyle(ButtonStyle.Success),
new ButtonBuilder().setCustomId('trade_remove_item').setLabel('Remove Item').setStyle(ButtonStyle.Secondary),
new ButtonBuilder().setCustomId('trade_lock').setLabel('Lock / Unlock').setStyle(ButtonStyle.Primary),
new ButtonBuilder().setCustomId('trade_cancel').setLabel('Cancel').setStyle(ButtonStyle.Danger),
);
await updateDashboardMessage(interaction, { embeds: [embed], components: [row] });
const { embeds, components } = getTradeDashboard(session);
await updateDashboardMessage(interaction, { embeds, components });
}
async function updateDashboardMessage(interaction: Interaction, payload: any) {
@@ -300,17 +228,7 @@ async function updateDashboardMessage(interaction: Interaction, payload: any) {
}
}
function formatOffer(participant: any) {
let text = "";
if (participant.offer.money > 0n) {
text += `💰 ${participant.offer.money} 🪙\n`;
}
if (participant.offer.items.length > 0) {
text += participant.offer.items.map((i: any) => `- ${i.name} (x${i.quantity})`).join("\n");
}
if (text === "") text = "*Empty Offer*";
return text;
}
async function scheduleThreadCleanup(channel: ThreadChannel | TextChannel, message: string, delayMs: number = 10000, embed?: EmbedBuilder) {
try {
@@ -322,7 +240,7 @@ async function scheduleThreadCleanup(channel: ThreadChannel | TextChannel, messa
setTimeout(async () => {
try {
if (channel.isThread()) {
console.log(`Deleting thread: ${channel.id}`);
console.log(`Deleting thread: ${channel.id} `);
await channel.delete("Trade Session Ended");
}
} catch (e) {

View File

@@ -0,0 +1,85 @@
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ModalBuilder, StringSelectMenuBuilder, TextInputBuilder, TextInputStyle } from "discord.js";
import { createBaseEmbed } from "@lib/embeds";
import type { TradeSession, TradeParticipant } from "./trade.types";
const EMBED_COLOR = 0xFFD700; // Gold
function formatOffer(participant: TradeParticipant) {
let text = "";
if (participant.offer.money > 0n) {
text += `💰 ${participant.offer.money} 🪙\n`;
}
if (participant.offer.items.length > 0) {
text += participant.offer.items.map((i) => `- ${i.name} (x${i.quantity})`).join("\n");
}
if (text === "") text = "*Empty Offer*";
return text;
}
export function getTradeDashboard(session: TradeSession) {
const embed = createBaseEmbed("🤝 Trading Session", undefined, EMBED_COLOR)
.addFields(
{
name: `${session.userA.username} ${session.userA.locked ? '✅ (Ready)' : '✏️ (Editing)'}`,
value: formatOffer(session.userA),
inline: true
},
{
name: `${session.userB.username} ${session.userB.locked ? '✅ (Ready)' : '✏️ (Editing)'}`,
value: formatOffer(session.userB),
inline: true
}
)
.setFooter({ text: "Both parties must click Lock to confirm trade." });
const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(
new ButtonBuilder().setCustomId('trade_add_item').setLabel('Add Item').setStyle(ButtonStyle.Secondary),
new ButtonBuilder().setCustomId('trade_add_money').setLabel('Add Money').setStyle(ButtonStyle.Success),
new ButtonBuilder().setCustomId('trade_remove_item').setLabel('Remove Item').setStyle(ButtonStyle.Secondary),
new ButtonBuilder().setCustomId('trade_lock').setLabel('Lock / Unlock').setStyle(ButtonStyle.Primary),
new ButtonBuilder().setCustomId('trade_cancel').setLabel('Cancel').setStyle(ButtonStyle.Danger),
);
return { embeds: [embed], components: [row] };
}
export function getTradeCompletedEmbed(session: TradeSession) {
const embed = createBaseEmbed("✅ Trade Completed", undefined, "Green")
.addFields(
{ name: session.userA.username, value: formatOffer(session.userA), inline: true },
{ name: session.userB.username, value: formatOffer(session.userB), inline: true }
)
.setTimestamp();
return embed;
}
export function getTradeMoneyModal() {
const modal = new ModalBuilder()
.setCustomId('trade_money_modal')
.setTitle('Add Money');
const input = new TextInputBuilder()
.setCustomId('amount')
.setLabel("Amount to trade")
.setStyle(TextInputStyle.Short)
.setPlaceholder("100")
.setRequired(true);
const row = new ActionRowBuilder<TextInputBuilder>().addComponents(input);
modal.addComponents(row);
return modal;
}
export function getItemSelectMenu(items: { label: string, value: string, description?: string }[], customId: string, placeholder: string) {
const select = new StringSelectMenuBuilder()
.setCustomId(customId)
.setPlaceholder(placeholder)
.addOptions(items);
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select);
return { components: [row] };
}

View File

@@ -0,0 +1,116 @@
# Trivia - Components v2 Implementation
This trivia feature uses **Discord Components v2** for a premium visual experience.
## 🎨 Visual Features
### **Container with Accent Colors**
Each trivia question is displayed in a Container with a colored accent bar that changes based on difficulty:
- **🟢 Easy**: Green accent bar (`0x57F287`)
- **🟡 Medium**: Yellow accent bar (`0xFEE75C`)
- **🔴 Hard**: Red accent bar (`0xED4245`)
### **Modern Layout Components**
- **TextDisplay** - Rich markdown formatting for question text
- **Separator** - Visual spacing between sections
- **Container** - Groups all content with difficulty-based styling
### **Interactive Features**
**Give Up Button** - Players can forfeit if they're unsure
**Disabled Answer Buttons** - After answering, buttons show:
- ✅ Green for correct answer
- ❌ Red for user's incorrect answer
- Gray for other options
**Time Display** - Shows both relative time (`in 30s`) and seconds remaining
**Stakes Preview** - Clear display: `50 AU ➜ 100 AU`
## 📁 File Structure
```
bot/modules/trivia/
├── trivia.view.ts # Components v2 view functions
├── trivia.interaction.ts # Button interaction handler
└── README.md # This file
bot/commands/economy/
└── trivia.ts # /trivia slash command
```
## 🔧 Technical Details
### Components v2 Requirements
- Uses `MessageFlags.IsComponentsV2` flag
- No `embeds` or `content` fields (uses TextDisplay instead)
- Numeric component types:
- `1` - Action Row
- `2` - Button
- `10` - Text Display
- `14` - Separator
- `17` - Container
- Max 40 components per message (vs 5 for legacy)
### Button Styles
- **Secondary (2)**: Gray - Used for answer buttons
- **Success (3)**: Green - Used for "True" and correct answers
- **Danger (4)**: Red - Used for "False", incorrect answers, and "Give Up"
## 🎮 User Experience Flow
1. User runs `/trivia`
2. Sees question in a Container with difficulty-based accent color
3. Can choose to:
- Select an answer (A/B/C/D or True/False)
- Give up using the 🏳️ button
4. After answering, sees result with:
- Disabled buttons showing correct/incorrect answers
- Container with result-based accent color (green/red/yellow)
- Reward or penalty information
## 🌟 Visual Examples
### Question Display
```
┌─[GREEN]─────────────────────────┐
│ # 🎯 Trivia Challenge │
│ 🟢 Easy • 📚 Geography │
│ ─────────────────────────── │
│ ### What is the capital of │
│ France? │
│ │
│ ⏱️ Time: in 30s (30s) │
│ 💰 Stakes: 50 AU ➜ 100 AU │
│ 👤 Player: Username │
└─────────────────────────────────┘
[🇦 A: Paris] [🇧 B: London]
[🇨 C: Berlin] [🇩 D: Madrid]
[🏳️ Give Up]
```
### Result Display (Correct)
```
┌─[GREEN]─────────────────────────┐
│ # 🎉 Correct Answer! │
│ ### What is the capital of │
│ France? │
│ ─────────────────────────── │
│ ✅ Your answer: Paris │
│ │
│ 💰 Reward: +100 AU │
│ │
│ 🏆 Great job! Keep it up! │
└─────────────────────────────────┘
[✅ A: Paris] [❌ B: London]
[❌ C: Berlin] [❌ D: Madrid]
(all buttons disabled)
```
## 🚀 Future Enhancements
Potential improvements:
- [ ] Thumbnail images based on trivia category
- [ ] Progress bar for time remaining
- [ ] Streak counter display
- [ ] Category-specific accent colors
- [ ] Media Gallery for image-based questions
- [ ] Leaderboard integration in results

View File

@@ -0,0 +1,129 @@
import { ButtonInteraction } from "discord.js";
import { triviaService } from "@shared/modules/trivia/trivia.service";
import { getTriviaResultView, getTriviaTimeoutView } from "./trivia.view";
import { UserError } from "@shared/lib/errors";
export async function handleTriviaInteraction(interaction: ButtonInteraction) {
const parts = interaction.customId.split('_');
// Check for "Give Up" button
if (parts.length >= 3 && parts[0] === 'trivia' && parts[1] === 'giveup') {
const sessionId = `${parts[2]}_${parts[3]}`;
const session = triviaService.getSession(sessionId);
if (!session) {
await interaction.reply({
content: '❌ This trivia question has expired or already been answered.',
ephemeral: true
});
return;
}
// Validate ownership
if (session.userId !== interaction.user.id) {
await interaction.reply({
content: '❌ This isn\'t your trivia question!',
ephemeral: true
});
return;
}
await interaction.deferUpdate();
// Process as incorrect (user gave up)
const result = await triviaService.submitAnswer(sessionId, interaction.user.id, false);
// Show timeout view (since they gave up)
const { components, flags } = getTriviaTimeoutView(
session.question.question,
session.question.correctAnswer,
session.allAnswers,
session.entryFee
);
await interaction.editReply({
components,
flags
});
return;
}
// Handle answer button
if (parts.length < 5 || parts[0] !== 'trivia' || parts[1] !== 'answer') {
return;
}
const sessionId = `${parts[2]}_${parts[3]}`;
const answerIndexStr = parts[4];
if (!answerIndexStr) {
throw new UserError('Invalid answer format.');
}
const answerIndex = parseInt(answerIndexStr);
// Get session BEFORE deferring to check ownership
const session = triviaService.getSession(sessionId);
if (!session) {
// Session doesn't exist or expired
await interaction.reply({
content: '❌ This trivia question has expired or already been answered.',
ephemeral: true
});
return;
}
// Validate ownership BEFORE deferring
if (session.userId !== interaction.user.id) {
// Wrong user trying to answer - send ephemeral error
await interaction.reply({
content: '❌ This isn\'t your trivia question! Use `/trivia` to start your own game.',
ephemeral: true
});
return;
}
// Only defer if ownership is valid
await interaction.deferUpdate();
// Check timeout
if (new Date() > session.expiresAt) {
const { components, flags } = getTriviaTimeoutView(
session.question.question,
session.question.correctAnswer,
session.allAnswers,
session.entryFee
);
await interaction.editReply({
components,
flags
});
// Clean up session
await triviaService.submitAnswer(sessionId, interaction.user.id, false);
return;
}
// Check if correct
const isCorrect = answerIndex === session.correctIndex;
const userAnswer = session.allAnswers[answerIndex];
// Process result
const result = await triviaService.submitAnswer(sessionId, interaction.user.id, isCorrect);
// Update message with enhanced visual feedback
const { components, flags } = getTriviaResultView(
result,
session.question.question,
userAnswer,
session.allAnswers,
session.entryFee
);
await interaction.editReply({
components,
flags
});
}

View File

@@ -0,0 +1,336 @@
import { MessageFlags } from "discord.js";
import type { TriviaSession, TriviaResult } from "@shared/modules/trivia/trivia.service";
/**
* Get color based on difficulty level
*/
function getDifficultyColor(difficulty: string): number {
switch (difficulty.toLowerCase()) {
case 'easy':
return 0x57F287; // Green
case 'medium':
return 0xFEE75C; // Yellow
case 'hard':
return 0xED4245; // Red
default:
return 0x5865F2; // Blurple
}
}
/**
* Get emoji for difficulty level
*/
function getDifficultyEmoji(difficulty: string): string {
switch (difficulty.toLowerCase()) {
case 'easy':
return '🟢';
case 'medium':
return '🟡';
case 'hard':
return '🔴';
default:
return '⭐';
}
}
/**
* Generate Components v2 message for a trivia question
*/
export function getTriviaQuestionView(session: TriviaSession, username: string): {
components: any[];
flags: number;
} {
const { question, allAnswers, entryFee, potentialReward, expiresAt, sessionId } = session;
// Calculate time remaining
const now = Date.now();
const timeLeft = Math.max(0, expiresAt.getTime() - now);
const secondsLeft = Math.floor(timeLeft / 1000);
const difficultyEmoji = getDifficultyEmoji(question.difficulty);
const difficultyText = question.difficulty.charAt(0).toUpperCase() + question.difficulty.slice(1);
const accentColor = getDifficultyColor(question.difficulty);
const components: any[] = [];
// Main Container with difficulty accent color
components.push({
type: 17, // Container
accent_color: accentColor,
components: [
// Title and metadata section
{
type: 10, // Text Display
content: `# 🎯 Trivia Challenge\n**${difficultyEmoji} ${difficultyText}** • 📚 ${question.category}`
},
// Separator
{
type: 14, // Separator
spacing: 1,
divider: true
},
// Question
{
type: 10, // Text Display
content: `### ${question.question}`
},
// Stats section
{
type: 14, // Separator
spacing: 1,
divider: false
},
{
type: 10, // Text Display
content: `⏱️ **Time:** <t:${Math.floor(expiresAt.getTime() / 1000)}:R> (${secondsLeft}s)\n💰 **Stakes:** ${entryFee} AU ➜ ${potentialReward} AU\n👤 **Player:** ${username}`
}
]
});
// Answer buttons
if (question.type === 'boolean') {
const trueIndex = allAnswers.indexOf('True');
const falseIndex = allAnswers.indexOf('False');
components.push({
type: 1, // Action Row
components: [
{
type: 2, // Button
custom_id: `trivia_answer_${sessionId}_${trueIndex}`,
label: 'True',
style: 3, // Success
emoji: { name: '✅' }
},
{
type: 2, // Button
custom_id: `trivia_answer_${sessionId}_${falseIndex}`,
label: 'False',
style: 4, // Danger
emoji: { name: '❌' }
}
]
});
} else {
const labels = ['A', 'B', 'C', 'D'];
const emojis = ['🇦', '🇧', '🇨', '🇩'];
const buttonRow: any = {
type: 1, // Action Row
components: []
};
for (let i = 0; i < allAnswers.length && i < 4; i++) {
const label = labels[i];
const emoji = emojis[i];
const answer = allAnswers[i];
if (!label || !emoji || !answer) continue;
buttonRow.components.push({
type: 2, // Button
custom_id: `trivia_answer_${sessionId}_${i}`,
label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`,
style: 2, // Secondary
emoji: { name: emoji }
});
}
components.push(buttonRow);
}
// Give Up button in separate row
components.push({
type: 1, // Action Row
components: [
{
type: 2, // Button
custom_id: `trivia_giveup_${sessionId}`,
label: 'Give Up',
style: 4, // Danger
emoji: { name: '🏳️' }
}
]
});
return {
components,
flags: MessageFlags.IsComponentsV2
};
}
/**
* Generate Components v2 result message
*/
export function getTriviaResultView(
result: TriviaResult,
question: string,
userAnswer?: string,
allAnswers?: string[],
entryFee: bigint = 0n
): {
components: any[];
flags: number;
} {
const { correct, reward, correctAnswer } = result;
const components: any[] = [];
if (correct) {
// Success container
components.push({
type: 17, // Container
accent_color: 0x57F287, // Green
components: [
{
type: 10, // Text Display
content: `# 🎉 Correct Answer!\n### ${question}`
},
{
type: 14, // Separator
spacing: 1,
divider: true
},
{
type: 10, // Text Display
content: `✅ **Your answer:** ${correctAnswer}\n\n💰 **Reward:** +${reward} AU\n\n🏆 Great job! Keep it up!`
}
]
});
} else {
const answerDisplay = userAnswer
? `❌ **Your answer:** ${userAnswer}\n✅ **Correct answer:** ${correctAnswer}`
: `✅ **Correct answer:** ${correctAnswer}`;
// Error container
components.push({
type: 17, // Container
accent_color: 0xED4245, // Red
components: [
{
type: 10, // Text Display
content: `# ❌ Incorrect Answer\n### ${question}`
},
{
type: 14, // Separator
spacing: 1,
divider: true
},
{
type: 10, // Text Display
content: `${answerDisplay}\n\n💸 **Entry fee lost:** ${entryFee} AU\n\n📚 Better luck next time!`
}
]
});
}
// Show disabled buttons with visual feedback
if (allAnswers && allAnswers.length > 0) {
const buttonRow: any = {
type: 1, // Action Row
components: []
};
const labels = ['A', 'B', 'C', 'D'];
const emojis = ['🇦', '🇧', '🇨', '🇩'];
for (let i = 0; i < allAnswers.length && i < 4; i++) {
const label = labels[i];
const emoji = emojis[i];
const answer = allAnswers[i];
if (!label || !emoji || !answer) continue;
const isCorrect = answer === correctAnswer;
const wasUserAnswer = answer === userAnswer;
buttonRow.components.push({
type: 2, // Button
custom_id: `trivia_result_${i}`,
label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`,
style: isCorrect ? 3 : wasUserAnswer ? 4 : 2, // Success : Danger : Secondary
emoji: { name: isCorrect ? '✅' : wasUserAnswer ? '❌' : emoji },
disabled: true
});
}
components.push(buttonRow);
}
return {
components,
flags: MessageFlags.IsComponentsV2
};
}
/**
* Generate Components v2 timeout message
*/
export function getTriviaTimeoutView(
question: string,
correctAnswer: string,
allAnswers?: string[],
entryFee: bigint = 0n
): {
components: any[];
flags: number;
} {
const components: any[] = [];
// Timeout container
components.push({
type: 17, // Container
accent_color: 0xFEE75C, // Yellow
components: [
{
type: 10, // Text Display
content: `# ⏱️ Time's Up!\n### ${question}`
},
{
type: 14, // Separator
spacing: 1,
divider: true
},
{
type: 10, // Text Display
content: `⏰ **You ran out of time!**\n✅ **Correct answer:** ${correctAnswer}\n\n💸 **Entry fee lost:** ${entryFee} AU\n\n⚡ Be faster next time!`
}
]
});
// Show disabled buttons with correct answer highlighted
if (allAnswers && allAnswers.length > 0) {
const buttonRow: any = {
type: 1, // Action Row
components: []
};
const labels = ['A', 'B', 'C', 'D'];
const emojis = ['🇦', '🇧', '🇨', '🇩'];
for (let i = 0; i < allAnswers.length && i < 4; i++) {
const label = labels[i];
const emoji = emojis[i];
const answer = allAnswers[i];
if (!label || !emoji || !answer) continue;
const isCorrect = answer === correctAnswer;
buttonRow.components.push({
type: 2, // Button
custom_id: `trivia_timeout_${i}`,
label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`,
style: isCorrect ? 3 : 2, // Success : Secondary
emoji: { name: isCorrect ? '✅' : emoji },
disabled: true
});
}
components.push(buttonRow);
}
return {
components,
flags: MessageFlags.IsComponentsV2
};
}

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