168 Commits

Author SHA1 Message Date
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
syntaxbullet
1eace32aa1 feat: Implement dynamic event loading and refactor event handlers into dedicated files. 2025-12-14 22:21:28 +01:00
syntaxbullet
32c614975e feat: automatically assign 'Visitor' role to new guild members on join 2025-12-14 21:33:32 +01:00
syntaxbullet
7dd9052c9b feat: add webhook command for sending messages via JSON payload 2025-12-14 15:48:27 +01:00
syntaxbullet
9d1d4aeaea feat: clarify daily reward streak display by renaming field and adding 'days' unit 2025-12-14 15:32:36 +01:00
syntaxbullet
bd59f01a41 chore: update currency symbol from 🪙 to AU in balance display 2025-12-14 14:28:11 +01:00
syntaxbullet
4639fecf45 feat: Implement gradual daily streak decay and rename currency from 'coins' to 'Astral Units'. 2025-12-14 14:16:16 +01:00
syntaxbullet
ee2fda83e5 feat: Add anonymous volume for /app/node_modules to the service definition. 2025-12-14 12:36:30 +01:00
syntaxbullet
7e9aa06556 feat: Introduce success and info embeds, add related user tracking to economy transactions, and refine trade interaction feedback and thread cleanup. 2025-12-13 16:02:07 +01:00
syntaxbullet
f96d81f8a3 chore: Remove unnecessary comments from profile command, trade interaction handler, inventory service, and scheduler. 2025-12-13 14:28:36 +01:00
syntaxbullet
d34e872133 feat: Implement a generic user timers system with a scheduler to manage cooldowns, effects, and access. 2025-12-13 14:18:46 +01:00
syntaxbullet
b33738aee3 refactor: remove unused imports and types across trade and economy modules 2025-12-13 12:50:00 +01:00
syntaxbullet
86cbe827a2 refactor: Remove interaction deferrals, use direct replies, and make error/warning messages ephemeral in economy commands. 2025-12-13 12:46:38 +01:00
syntaxbullet
421bb26ceb feat: add trading system with dedicated modules and centralize embed creation for commands 2025-12-13 12:43:27 +01:00
syntaxbullet
5f4efd372f feat: Introduce GameConfig to centralize constants for leveling, economy, and inventory, adding new transfer and inventory limits. 2025-12-13 12:20:30 +01:00
syntaxbullet
8818d6bb15 feat: Add type and usageData columns to the item schema to support item usage effects. 2025-12-13 12:02:30 +01:00
syntaxbullet
29899acc7f feat: update database schema to support item trading 2025-12-13 12:00:02 +01:00
syntaxbullet
8262eb8f02 feat: Add /sell command, enhance inventory service, and refactor student ID card generation with new constellation graphics and dynamic backgrounds. 2025-12-12 13:41:13 +01:00
syntaxbullet
209340c06e feat: implement chat XP processing on message create 2025-12-09 12:10:58 +01:00
syntaxbullet
9250057574 feat: Implement chat XP with cooldowns and display an XP progress bar on the student ID card. 2025-12-09 12:04:03 +01:00
syntaxbullet
90a1861416 docs: Remove outdated deployment and development features sections from README. 2025-12-08 22:39:31 +01:00
syntaxbullet
bcfd254071 feat: Implement graphical student ID card generation for user profiles. 2025-12-08 22:33:01 +01:00
syntaxbullet
4d553ddc91 feat: Add @napi-rs/canvas dependency and Orbitron font file. 2025-12-08 13:45:09 +01:00
syntaxbullet
049725c384 fix: Display target user mention in pay success message. 2025-12-08 10:36:25 +01:00
syntaxbullet
866cfab03e feat: Implement userService.getOrCreateUser and integrate it across commands, remove old utility scripts, and fix daily bonus calculation. 2025-12-08 10:29:40 +01:00
syntaxbullet
29c0a4752d feat: Introduce new modules for class, inventory, leveling, and quests with expanded schema, refactor user service, and add verification scripts. 2025-12-07 23:03:33 +01:00
162 changed files with 15033 additions and 403 deletions

View File

@@ -0,0 +1,57 @@
---
description: Create a new Ticket
---
### Role
You are a Senior Technical Product Manager and Lead Engineer. Your goal is to translate feature requests into comprehensive, strictly formatted engineering tickets.
### Task
When I ask you to "scope a feature" or "create a ticket" for a specific functionality:
1. Analyze the request for technical implications, edge cases, and architectural fit.
2. Generate a new Markdown file.
3. Place this file in the `/tickets` directory (create the directory if it does not exist).
### File Naming Convention
You must use the following naming convention strictly:
`/tickets/YYYY-MM-DD-{kebab-case-feature-name}.md`
*Example:* `/tickets/2024-10-12-user-authentication-flow.md`
### File Content Structure
The markdown file must adhere to the following template exactly. Do not skip sections. If a section is not applicable, write "N/A" but explain why.
```markdown
# [Ticket ID]: [Feature Title]
**Status:** Draft
**Created:** [YYYY-MM-DD]
**Tags:** [comma, separated, tags]
## 1. Context & User Story
* **As a:** [Role]
* **I want to:** [Action]
* **So that:** [Benefit/Value]
## 2. Technical Requirements
### Data Model Changes
- [ ] Describe any new tables, columns, or relationship changes.
- [ ] SQL migration required? (Yes/No)
### API / Interface
- [ ] Define endpoints (method, path) or function signatures.
- [ ] Payload definition (JSON structure or Types).
## 3. Constraints & Validations (CRITICAL)
*This section must be exhaustive. Do not be vague.*
- **Input Validation:** (e.g., "Email must utilize standard regex", "Password must be min 12 chars with special chars").
- **System Constraints:** (e.g., "Image upload max size 5MB", "Request timeout 30s").
- **Business Logic Guardrails:** (e.g., "User cannot upgrade if balance < $0").
## 4. Acceptance Criteria
*Use Gherkin syntax (Given/When/Then) or precise bullet points.*
1. [ ] Criteria 1
2. [ ] Criteria 2
## 5. Implementation Plan
- [ ] Step 1: ...
- [ ] Step 2: ...

View File

@@ -0,0 +1,53 @@
---
description: Review the most recent changes critically.
---
### Role
You are a Lead Security Engineer and Senior QA Automator. Your persona is **"The Hostile Reviewer."**
* **Mindset:** You do not trust the code. You assume it contains bugs, security flaws, and logic gaps.
* **Goal:** Your objective is to reject the most recent git changes by finding legitimate issues. If you cannot find issues, only then do you approve.
### Phase 1: The Security & Logic Audit
Analyze the code changes for specific vulnerabilities. Do not summarize what the code does; look for what it *does wrong*.
1. **TypeScript Strictness:**
* Flag any usage of `any`.
* Flag any use of non-null assertions (`!`) unless strictly guarded.
* Flag forced type casting (`as UnknownType`) without validation.
2. **Bun/Runtime Specifics:**
* Check for unhandled Promises (floating promises).
* Ensure environment variables are not hardcoded.
3. **Security Vectors:**
* **Injection:** Check SQL/NoSQL queries for concatenation.
* **Sanitization:** Are inputs from the generic request body validated against the schema defined in the Ticket?
* **Auth:** Are sensitive routes actually protected by middleware?
### Phase 2: Test Quality Verification
Do not just check if tests pass. Check if the tests are **valid**.
1. **The "Happy Path" Trap:** If the tests only check for success (status 200), **FAIL** the review.
2. **Edge Case Coverage:**
* Did the code handle the *Constraints & Validations* listed in the original ticket?
* *Example:* If the ticket says "Max 5MB upload", is there a test case for a 5.1MB file?
3. **Mocking Integrity:** Are mocks too permissive? (e.g., Mocking a function to always return `true` regardless of input).
### Phase 3: The Verdict
Output your review in the following strict format:
---
# 🛡️ Code Review Report
**Ticket ID:** [Ticket Name]
**Verdict:** [🔴 REJECT / 🟢 APPROVE]
## 🚨 Critical Issues (Must Fix)
*List logic bugs, security risks, or failing tests.*
1. ...
2. ...
## ⚠️ Suggestions (Refactoring)
*List code style improvements, variable naming, or DRY opportunities.*
1. ...
## 🧪 Test Coverage Gap Analysis
*List specific scenarios that are NOT currently tested but should be.*
- [ ] Scenario: ...

View File

@@ -0,0 +1,50 @@
---
description: Pick a Ticket and work on it.
---
### Role
You are an Autonomous Senior Software Engineer specializing in TypeScript and Bun. You are responsible for the full lifecycle of feature implementation: selection, coding, testing, verification, and closure.
### Phase 1: Triage & Selection
1. **Scan:** Read all files in the `/tickets` directory.
2. **Filter:** Ignore tickets marked `Status: Done` or `Status: Archived`.
3. **Prioritize:** Select a single ticket based on the following hierarchy:
* **Tags:** `Critical` > `High Priority` > `Bug` > `Feature`.
* **Age:** Oldest created date first (FIFO).
4. **Announce:** Explicitly state: "I am picking ticket: [Ticket ID/Name] because [Reason]."
### Phase 2: Setup (Non-Destructive)
1. **Branching:** Create a new git branch based on the ticket name.
* *Format:* `feat/{ticket-kebab-name}` or `fix/{ticket-kebab-name}`.
* *Command:* `git checkout -b feat/user-auth-flow`.
2. **Context:** Read the selected ticket markdown file thoroughly, paying special attention to "Constraints & Validations."
### Phase 3: Implementation & Testing (The Loop)
*Iterate until the requirements are met.*
1. **Write Code:** Implement the feature or fix using TypeScript.
2. **Tightened Testing:**
* You must create or update test files (`*.test.ts` or `*.spec.ts`).
* **Requirement:** Tests must cover happy paths AND the edge cases defined in the ticket's "Constraints" section.
* *Mocking:* Mock external dependencies where appropriate to ensure isolation.
3. **Type Safety Check:**
* Run: `bun x tsc --noEmit`
* **CRITICAL:** If there are ANY TypeScript errors, you must fix them immediately. Do not proceed.
4. **Runtime Verification:**
* Run: `bun test`
* Ensure all tests pass. If a test fails, analyze the stack trace, fix the implementation, and rerun.
### Phase 4: Self-Review & Clean Up
Before declaring the task finished, perform a self-review:
1. **Linting:** Check for unused variables, any types, or console logs.
2. **Refactor:** Ensure code is DRY (Don't Repeat Yourself) and strictly typed.
3. **Ticket Update:**
* Modify the Markdown ticket file.
* Change `Status: Draft` to `Status: In Review` or `Status: Done`.
* Add a new section at the bottom: `## Implementation Notes` listing the specific files changed.
### Phase 5: Handover
Only when `bun x tsc` and `bun test` pass with 0 errors:
1. Commit the changes with a semantic message (e.g., `feat: implement user auth logic`).
2. Present a summary of the work done and ask for a human code review.

View File

@@ -1,9 +1,12 @@
DB_USER=kyoko DB_USER=aurora
DB_PASSWORD=kyoko DB_PASSWORD=aurora
DB_NAME=kyoko DB_NAME=aurora
DB_PORT=5432 DB_PORT=5432
DB_HOST=db DB_HOST=db
DISCORD_BOT_TOKEN=your-discord-bot-token DISCORD_BOT_TOKEN=your-discord-bot-token
DISCORD_CLIENT_ID=your-discord-client-id DISCORD_CLIENT_ID=your-discord-client-id
DISCORD_GUILD_ID=your-discord-guild-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

5
.gitignore vendored
View File

@@ -4,8 +4,11 @@ db-logs
db-data db-data
.cursor .cursor
# dependencies (bun install) # dependencies (bun install)
node_modules node_modules
config/
# output # output
out out
dist dist
@@ -40,3 +43,5 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
src/db/data src/db/data
src/db/log src/db/log
scratchpad/

View File

@@ -1,6 +1,9 @@
FROM oven/bun:latest AS base FROM oven/bun:latest AS base
WORKDIR /app WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y git
# Install dependencies # Install dependencies
COPY package.json bun.lock ./ COPY package.json bun.lock ./
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile

134
README.md
View File

@@ -1,65 +1,119 @@
# 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)
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. ## ✨ Features
- **`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.
## Setup * **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.
1. **Install Dependencies**: ## 🛠️ Tech Stack
* **Runtime**: [Bun](https://bun.sh/)
* **Framework**: [Discord.js](https://discord.js.org/)
* **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 ```bash
bun install bun install
``` ```
2. **Environment Variables**: 3. **Environment Setup**
Copy `.env.example` to `.env` (create one if it doesn't exist) and fill in the required values: Copy the example environment file and configure it:
```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 ```bash
bun run dev cp .env.example .env
``` ```
Edit `.env` with your Discord bot token, Client ID, and database credentials.
4. **Database Migrations**: > **Note**: The `DATABASE_URL` in `.env.example` is pre-configured for Docker.
4. **Start the Database**
Run the database service using Docker Compose:
```bash ```bash
bun run db:push # Apply schema changes docker compose up -d db
bun run generate # Generate migrations
``` ```
## Deployment 5. **Run Migrations**
```bash
bun run migrate
```
OR
```bash
bun run db:push
```
### Manual Command Registration ### Running the Bot
Since command registration is decoupled from startup, you must run this manually when you add or change commands.
**Option 1: Using Docker (Recommended)** **Development Mode** (with hot reload):
Uses the credentials configured in `docker-compose.yml`.
```bash ```bash
docker compose run --rm app bun run deploy bun run dev
``` ```
**Option 2: Running Locally** **Production Mode**:
Requires valid `.env` file with `DISCORD_CLIENT_ID`. Build and run with Docker (recommended):
```bash ```bash
bun run deploy docker compose up -d app
``` ```
## Development Features ## 📜 Scripts
- **Type Safety**: Full TypeScript support. * `bun run dev`: Start the bot in watch mode.
- **Env Validation**: `zod` ensures all required env vars are present. * `bun run generate`: Generate Drizzle migrations.
- **Hot Reloading**: `bun --watch` for fast development. * `bun run migrate`: Apply migrations (via Docker).
* `bun run db:push`: Push, schema to DB (via Docker).
* `bun run db:studio`: Open Drizzle Studio to inspect the database.
* `bun test`: Run tests.
## 📂 Project Structure
```
├── src
│ ├── commands # Slash commands
│ ├── events # Discord event handlers
│ ├── modules # Feature modules (Economy, Inventory, etc.)
│ ├── db # Database schema and connection
│ └── lib # Shared utilities
├── drizzle # Drizzle migration files
├── config # Configuration files
└── scripts # Utility scripts
```
## 🤝 Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## 📄 License
This project is licensed under the MIT License.

View File

@@ -5,6 +5,7 @@
"": { "": {
"name": "app", "name": "app",
"dependencies": { "dependencies": {
"@napi-rs/canvas": "^0.1.84",
"discord.js": "^14.25.1", "discord.js": "^14.25.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"drizzle-orm": "^0.44.7", "drizzle-orm": "^0.44.7",
@@ -91,6 +92,28 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@napi-rs/canvas": ["@napi-rs/canvas@0.1.84", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.84", "@napi-rs/canvas-darwin-arm64": "0.1.84", "@napi-rs/canvas-darwin-x64": "0.1.84", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.84", "@napi-rs/canvas-linux-arm64-gnu": "0.1.84", "@napi-rs/canvas-linux-arm64-musl": "0.1.84", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.84", "@napi-rs/canvas-linux-x64-gnu": "0.1.84", "@napi-rs/canvas-linux-x64-musl": "0.1.84", "@napi-rs/canvas-win32-x64-msvc": "0.1.84" } }, "sha512-88FTNFs4uuiFKP0tUrPsEXhpe9dg7za9ILZJE08pGdUveMIDeana1zwfVkqRHJDPJFAmGY3dXmJ99dzsy57YnA=="],
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.84", "", { "os": "android", "cpu": "arm64" }, "sha512-pdvuqvj3qtwVryqgpAGornJLV6Ezpk39V6wT4JCnRVGy8I3Tk1au8qOalFGrx/r0Ig87hWslysPpHBxVpBMIww=="],
"@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@0.1.84", "", { "os": "darwin", "cpu": "arm64" }, "sha512-A8IND3Hnv0R6abc6qCcCaOCujTLMmGxtucMTZ5vbQUrEN/scxi378MyTLtyWg+MRr6bwQJ6v/orqMS9datIcww=="],
"@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@0.1.84", "", { "os": "darwin", "cpu": "x64" }, "sha512-AUW45lJhYWwnA74LaNeqhvqYKK/2hNnBBBl03KRdqeCD4tKneUSrxUqIv8d22CBweOvrAASyKN3W87WO2zEr/A=="],
"@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@0.1.84", "", { "os": "linux", "cpu": "arm" }, "sha512-8zs5ZqOrdgs4FioTxSBrkl/wHZB56bJNBqaIsfPL4ZkEQCinOkrFF7xIcXiHiKp93J3wUtbIzeVrhTIaWwqk+A=="],
"@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@0.1.84", "", { "os": "linux", "cpu": "arm64" }, "sha512-i204vtowOglJUpbAFWU5mqsJgH0lVpNk/Ml4mQtB4Lndd86oF+Otr6Mr5KQnZHqYGhlSIKiU2SYnUbhO28zGQA=="],
"@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@0.1.84", "", { "os": "linux", "cpu": "arm64" }, "sha512-VyZq0EEw+OILnWk7G3ZgLLPaz1ERaPP++jLjeyLMbFOF+Tr4zHzWKiKDsEV/cT7btLPZbVoR3VX+T9/QubnURQ=="],
"@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@0.1.84", "", { "os": "linux", "cpu": "none" }, "sha512-PSMTh8DiThvLRsbtc/a065I/ceZk17EXAATv9uNvHgkgo7wdEfTh2C3aveNkBMGByVO3tvnvD5v/YFtZL07cIg=="],
"@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@0.1.84", "", { "os": "linux", "cpu": "x64" }, "sha512-N1GY3noO1oqgEo3rYQIwY44kfM11vA0lDbN0orTOHfCSUZTUyiYCY0nZ197QMahZBm1aR/vYgsWpV74MMMDuNA=="],
"@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@0.1.84", "", { "os": "linux", "cpu": "x64" }, "sha512-vUZmua6ADqTWyHyei81aXIt9wp0yjeNwTH0KdhdeoBb6azHmFR8uKTukZMXfLCC3bnsW0t4lW7K78KNMknmtjg=="],
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.84", "", { "os": "win32", "cpu": "x64" }, "sha512-YSs8ncurc1xzegUMNnQUTYrdrAuaXdPMOa+iYYyAxydOtg0ppV386hyYMsy00Yip1NlTgLCseRG4sHSnjQx6og=="],
"@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="], "@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
"@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="], "@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="],

View File

@@ -1,19 +1,20 @@
services: services:
db: db:
image: postgres:17-alpine image: postgres:17-alpine
container_name: kyoko_db container_name: aurora_db
environment: environment:
- POSTGRES_USER=${DB_USER} - POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASSWORD} - POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=${DB_NAME} - POSTGRES_DB=${DB_NAME}
ports: ports:
- "${DB_PORT}:5432" - "127.0.0.1:${DB_PORT}:5432"
volumes: volumes:
- ./src/db/data:/var/lib/postgresql/data - ./src/db/data:/var/lib/postgresql/data
- ./src/db/log:/var/log/postgresql - ./src/db/log:/var/log/postgresql
app: app:
container_name: kyoko_app container_name: aurora_app
image: kyoko-app restart: unless-stopped
image: aurora-app
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -38,16 +39,17 @@ services:
command: bun run dev command: bun run dev
studio: studio:
container_name: kyoko_studio container_name: aurora_studio
image: kyoko-app image: aurora-app
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
working_dir: /app working_dir: /app
ports: ports:
- "4983:4983" - "127.0.0.1:4983:4983"
volumes: volumes:
- .:/app - .:/app
- /app/node_modules
environment: environment:
- DB_USER=${DB_USER} - DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD} - DB_PASSWORD=${DB_PASSWORD}

63
docs/COMMANDS.md Normal file
View File

@@ -0,0 +1,63 @@
# Command Reference
This document lists all available slash commands in Aurora, categorized by their function.
## Economy
| Command | Description | Options | Permissions |
|---|---|---|---|
| `/balance` | View your or another user's balance. | `user` (Optional): The user to check. | Everyone |
| `/daily` | Claim your daily currency reward and streak bonus. | None | Everyone |
| `/pay` | Transfer currency to another user. | `user` (Required): Recipient.<br>`amount` (Required): Amount to send. | Everyone |
| `/trade` | Start a trade session with another user. | `user` (Required): The user to trade with. | Everyone |
| `/exam` | Take your weekly exam to earn rewards based on XP gain. | None | Everyone |
## Inventory & Items
| Command | Description | Options | Permissions |
|---|---|---|---|
| `/inventory` | View your or another user's inventory. | `user` (Optional): The user to check. | Everyone |
| `/use` | Use an item from your inventory. | `item` (Required): The item to use (Autocomplete). | Everyone |
## User & Social
| Command | Description | Options | Permissions |
|---|---|---|---|
| `/profile` | View your or another user's Student ID card. | `user` (Optional): The user to view. | Everyone |
| `/leaderboard` | View top players. | `type` (Required): 'Level / XP' or 'Balance'. | Everyone |
| `/feedback` | Submit feedback, bug reports, or suggestions. | None | Everyone |
| `/quests` | View your active quests. | None | Everyone |
## Admin
> [!IMPORTANT]
> These commands require Administrator permissions or specific roles as configured.
### General Management
| Command | Description | Options |
|---|---|---|
| `/config` | Manage bot configuration. | `group` (Req): Section.<br>`key` (Req): Setting.<br>`value` (Req): New value. |
| `/refresh` | Refresh commands or configuration cache. | `type`: 'Commands' or 'Config'. |
| `/update` | Update the bot from the repository. | None |
| `/features` | Enable/Disable system features. | `feature` (Req): Feature name.<br>`enabled` (Req): True/False. |
| `/webhook` | Send a message via webhook. | `payload` (Req): JSON payload. |
### Moderation
| Command | Description | Options |
|---|---|---|
| `/warn` | Warn a user. | `user` (Req): Target.<br>`reason` (Req): Reason. |
| `/warnings` | View active warnings for a user. | `user` (Req): Target. |
| `/clearwarning`| Clear a specific warning. | `case_id` (Req): Case ID. |
| `/case` | View details of a specific moderation case. | `case_id` (Req): Case ID. |
| `/cases` | View moderation history for a user. | `user` (Req): Target. |
| `/note` | Add a note to a user. | `user` (Req): Target.<br>`note` (Req): Content. |
| `/notes` | View notes for a user. | `user` (Req): Target. |
| `/prune` | Bulk delete messages. | `amount` (Req): Number (1-100). |
### Game Admin
| Command | Description | Options |
|---|---|---|
| `/create_item` | Create a new item in the database. | (Modal based interaction) |
| `/create_color`| Create a new color role. | `name` (Req): Role name.<br>`hex` (Req): Hex color code. |
| `/listing` | Manage shop listings (Admin view). | None (Context sensitive?) |
| `/terminal` | Control the terminal display channel. | `action`: 'setup', 'update', 'clear'. |

160
docs/CONFIGURATION.md Normal file
View File

@@ -0,0 +1,160 @@
# Configuration Guide
This document outlines the structure and available options for the `config/config.json` file. The configuration is validated using Zod schemas at runtime (see `src/lib/config.ts`).
## Core Structure
### Leveling
Configuration for the XP and leveling system.
| Field | Type | Description |
|-------|------|-------------|
| `base` | `number` | The base XP required for the first level. |
| `exponent` | `number` | The exponent used to calculate XP curves. |
| `chat.cooldownMs` | `number` | Time in milliseconds between XP gains from chat. |
| `chat.minXp` | `number` | Minimum XP awarded per message. |
| `chat.maxXp` | `number` | Maximum XP awarded per message. |
### Economy
Settings for currency, rewards, and transfers.
#### Daily
| Field | Type | Description |
|-------|------|-------------|
| `amount` | `integer` | Base amount granted by `/daily`. |
| `streakBonus` | `integer` | Bonus amount per streak day. |
| `weeklyBonus` | `integer` | Bonus amount for a 7-day streak. |
| `cooldownMs` | `number` | Cooldown period for the command (usually 24h). |
#### Transfers
| Field | Type | Description |
|-------|------|-------------|
| `allowSelfTransfer` | `boolean` | Whether users can transfer money to themselves. |
| `minAmount` | `integer` | Minimum amount required for a transfer. |
#### Exam
| Field | Type | Description |
|-------|------|-------------|
| `multMin` | `number` | Minimum multiplier for exam rewards. |
| `multMax` | `number` | Maximum multiplier for exam rewards. |
### Inventory
| Field | Type | Description |
|-------|------|-------------|
| `maxStackSize` | `integer` | Maximum count of a single item in one slot. |
| `maxSlots` | `number` | Total number of inventory slots available. |
### Lootdrop
Settings for the random chat loot drop events.
| Field | Type | Description |
|-------|------|-------------|
| `activityWindowMs` | `number` | Time window to track activity for spawning drops. |
| `minMessages` | `number` | Minimum messages required in window to trigger drop. |
| `spawnChance` | `number` | Probability (0-1) of a drop spawning when conditions met. |
| `cooldownMs` | `number` | Minimum time between loot drops. |
| `reward.min` | `number` | Minimum currency reward. |
| `reward.max` | `number` | Maximum currency reward. |
| `reward.currency` | `string` | The currency ID/Symbol used for rewards. |
### Roles
| Field | Type | Description |
|-------|------|-------------|
| `studentRole` | `string` | Discord Role ID for students. |
| `visitorRole` | `string` | Discord Role ID for visitors. |
| `colorRoles` | `string[]` | List of Discord Role IDs available as color roles. |
### Moderation
Automated moderation settings.
#### Prune
| Field | Type | Description |
|-------|------|-------------|
| `maxAmount` | `number` | Maximum messages to delete in one go. |
| `confirmThreshold` | `number` | Amount above which confirmation is required. |
| `batchSize` | `number` | Size of delete batches. |
| `batchDelayMs` | `number` | Delay between batches. |
#### Cases
| Field | Type | Description |
|-------|------|-------------|
| `dmOnWarn` | `boolean` | Whether to DM users when they are warned. |
| `logChannelId` | `string` | (Optional) Channel ID for moderation logs. |
| `autoTimeoutThreshold` | `number` | (Optional) Warn count to trigger auto-timeout. |
### System & Misc
| Field | Type | Description |
|-------|------|-------------|
| `commands` | `Object` | Map of command names (keys) to boolean (values) to enable/disable them. |
| `welcomeChannelId` | `string` | (Optional) Channel ID for welcome messages. |
| `welcomeMessage` | `string` | (Optional) Custom welcome message text. |
| `feedbackChannelId` | `string` | (Optional) Channel ID where feedback is posted. |
| `terminal.channelId` | `string` | (Optional) Channel ID for terminal display. |
| `terminal.messageId` | `string` | (Optional) Message ID for terminal display. |
## Example Config
```json
{
"leveling": {
"base": 100,
"exponent": 1.5,
"chat": {
"cooldownMs": 60000,
"minXp": 15,
"maxXp": 25
}
},
"economy": {
"daily": {
"amount": "100",
"streakBonus": "10",
"weeklyBonus": "500",
"cooldownMs": 86400000
},
"transfers": {
"allowSelfTransfer": false,
"minAmount": "10"
},
"exam": {
"multMin": 1.0,
"multMax": 2.0
}
},
"inventory": {
"maxStackSize": "99",
"maxSlots": 20
},
"lootdrop": {
"activityWindowMs": 300000,
"minMessages": 10,
"spawnChance": 0.05,
"cooldownMs": 3600000,
"reward": {
"min": 50,
"max": 150,
"currency": "CREDITS"
}
},
"commands": {
"example": true
},
"studentRole": "123456789012345678",
"visitorRole": "123456789012345678",
"colorRoles": [],
"moderation": {
"prune": {
"maxAmount": 100,
"confirmThreshold": 50,
"batchSize": 100,
"batchDelayMs": 1000
},
"cases": {
"dmOnWarn": true
}
}
}
```
> [!NOTE]
> Fields marked as `integer` or `bigint` in the types can often be provided as strings in the JSON to ensure precision, but the system handles parsing them.

149
docs/DATABASE.md Normal file
View File

@@ -0,0 +1,149 @@
# Database Schema
This document outlines the database schema for the Aurora project. The database is PostgreSQL, managed via Drizzle ORM.
## Tables
### Users (`users`)
Stores user data, economy, and progression.
| Column | Type | Description |
|---|---|---|
| `id` | `bigint` | Primary Key. Discord User ID. |
| `class_id` | `bigint` | Foreign Key -> `classes.id`. |
| `username` | `varchar(255)` | User's Discord username. |
| `is_active` | `boolean` | Whether the user is active (default: true). |
| `balance` | `bigint` | User's currency balance. |
| `xp` | `bigint` | User's experience points. |
| `level` | `integer` | User's level. |
| `daily_streak` | `integer` | Current streak of daily command usage. |
| `settings` | `jsonb` | User-specific settings. |
| `created_at` | `timestamp` | Record creation time. |
| `updated_at` | `timestamp` | Last update time. |
### Classes (`classes`)
Available character classes.
| Column | Type | Description |
|---|---|---|
| `id` | `bigint` | Primary Key. Custom ID. |
| `name` | `varchar(255)` | Class name (Unique). |
| `balance` | `bigint` | Class bank balance (shared/flavor). |
| `role_id` | `varchar(255)` | Discord Role ID associated with the class. |
### Items (`items`)
Definitions of items available in the game.
| Column | Type | Description |
|---|---|---|
| `id` | `serial` | Primary Key. Auto-incrementing ID. |
| `name` | `varchar(255)` | Item name (Unique). |
| `description` | `text` | Item description. |
| `rarity` | `varchar(20)` | Common, Rare, etc. Default: 'Common'. |
| `type` | `varchar(50)` | MATERIAL, CONSUMABLE, EQUIPMENT, etc. |
| `usage_data` | `jsonb` | Effect data for consumables/usables. |
| `price` | `bigint` | Base value of the item. |
| `icon_url` | `text` | URL for the item's icon. |
| `image_url` | `text` | URL for the item's large image. |
### Inventory (`inventory`)
Items held by users.
| Column | Type | Description |
|---|---|---|
| `user_id` | `bigint` | PK/FK -> `users.id`. |
| `item_id` | `integer` | PK/FK -> `items.id`. |
| `quantity` | `bigint` | Amount held. Must be > 0. |
### Transactions (`transactions`)
Currency transaction history.
| Column | Type | Description |
|---|---|---|
| `id` | `bigserial` | Primary Key. |
| `user_id` | `bigint` | FK -> `users.id`. The user affecting the balance. |
| `related_user_id` | `bigint` | FK -> `users.id`. The other party (if any). |
| `amount` | `bigint` | Amount transferred. |
| `type` | `varchar(50)` | Transaction type identifier. |
| `description` | `text` | Human-readable description. |
| `created_at` | `timestamp` | Time of transaction. |
### Item Transactions (`item_transactions`)
Item flow history.
| Column | Type | Description |
|---|---|---|
| `id` | `bigserial` | Primary Key. |
| `user_id` | `bigint` | FK -> `users.id`. |
| `related_user_id` | `bigint` | FK -> `users.id`. |
| `item_id` | `integer` | FK -> `items.id`. |
| `quantity` | `bigint` | Amount gained (+) or lost (-). |
| `type` | `varchar(50)` | TRADE, SHOP_BUY, DROP, etc. |
| `description` | `text` | Description. |
| `created_at` | `timestamp` | Time of transaction. |
### Quests (`quests`)
Quest definitions.
| Column | Type | Description |
|---|---|---|
| `id` | `serial` | Primary Key. |
| `name` | `varchar(255)` | Quest title. |
| `description` | `text` | Quest text. |
| `trigger_event` | `varchar(50)` | Event that triggers progress checks. |
| `requirements` | `jsonb` | Completion criteria. |
| `rewards` | `jsonb` | Rewards for completion. |
### User Quests (`user_quests`)
User progress on quests.
| Column | Type | Description |
|---|---|---|
| `user_id` | `bigint` | PK/FK -> `users.id`. |
| `quest_id` | `integer` | PK/FK -> `quests.id`. |
| `progress` | `integer` | Current progress value. |
| `completed_at` | `timestamp` | Completion time (null if active). |
### User Timers (`user_timers`)
Generic timers for cooldowns, temporary effects, etc.
| Column | Type | Description |
|---|---|---|
| `user_id` | `bigint` | PK/FK -> `users.id`. |
| `type` | `varchar(50)` | PK. Timer type (COOLDOWN, EFFECT, ACCESS). |
| `key` | `varchar(100)` | PK. specific ID (e.g. 'daily'). |
| `expires_at` | `timestamp` | When the timer expires. |
| `metadata` | `jsonb` | Extra data. |
### Lootdrops (`lootdrops`)
Active chat loot drop events.
| Column | Type | Description |
|---|---|---|
| `message_id` | `varchar(255)` | Primary Key. Discord Message ID. |
| `channel_id` | `varchar(255)` | Discord Channel ID. |
| `reward_amount` | `integer` | Currency amount. |
| `currency` | `varchar(50)` | Currency type constant. |
| `claimed_by` | `bigint` | FK -> `users.id`. Null if unclaimed. |
| `created_at` | `timestamp` | Spawn time. |
| `expires_at` | `timestamp` | Despawn time. |
### Moderation Cases (`moderation_cases`)
History of moderation actions.
| Column | Type | Description |
|---|---|---|
| `id` | `bigserial` | Primary Key. |
| `case_id` | `varchar(50)` | Unique friendly ID. |
| `type` | `varchar(20)` | warn, timeout, kick, ban, etc. |
| `user_id` | `bigint` | Target user ID. |
| `username` | `varchar(255)` | Target username snapshot. |
| `moderator_id` | `bigint` | Acting moderator ID. |
| `moderator_name` | `varchar(255)` | Moderator username snapshot. |
| `reason` | `text` | Reason for action. |
| `metadata` | `jsonb` | Extra data. |
| `active` | `boolean` | Is this case active? |
| `created_at` | `timestamp` | Creation time. |
| `resolved_at` | `timestamp` | Resolution/Expiration time. |
| `resolved_by` | `bigint` | User ID who resolved it. |
| `resolved_reason` | `text` | Reason for resolution. |

127
docs/LOOTBOX_GUIDE.md Normal file
View File

@@ -0,0 +1,127 @@
# Lootbox Creation Guide
Currently, the Item Wizard does not support creating **Lootbox** items directly. Instead, they must be inserted manually into the database. This guide details the required JSON structure for the `LOOTBOX` effect.
## Item Structure
To create a lootbox, you need to insert a row into the `items` table. The critical part is the `usageData` JSON column.
```json
{
"consume": true,
"effects": [
{
"type": "LOOTBOX",
"pool": [ ... ]
}
]
}
```
## Loot Table Structure
The `pool` property is an array of `LootTableItem` objects. A random item is selected based on the total `weight` of all items in the pool.
| Field | Type | Description |
|-------|------|-------------|
| `type` | `string` | One of: `CURRENCY`, `ITEM`, `XP`, `NOTHING`. |
| `weight` | `number` | The relative probability weight of this outcome. |
| `message` | `string` | (Optional) Custom message to display when this outcome is selected. |
### Outcome Types
#### 1. Currency
Gives the user coins.
```json
{
"type": "CURRENCY",
"weight": 50,
"amount": 100, // Fixed amount OR
"minAmount": 50, // Minimum random amount
"maxAmount": 150 // Maximum random amount
}
```
#### 2. XP
Gives the user Experience Points.
```json
{
"type": "XP",
"weight": 30,
"amount": 500 // Fixed amount OR range (minAmount/maxAmount)
}
```
#### 3. Item
Gives the user another item (by ID).
```json
{
"type": "ITEM",
"weight": 10,
"itemId": 42, // The ID of the item to give
"amount": 1 // (Optional) Quantity to give, default 1
}
```
#### 4. Nothing
An empty roll.
```json
{
"type": "NOTHING",
"weight": 10,
"message": "The box was empty! Better luck next time."
}
```
## Complete Example
Here is a full SQL insert example (using a hypothetical SQL client or Drizzle studio) for a "Basic Lootbox":
**Name**: Basic Lootbox
**Type**: CONSUMABLE
**Effect**:
- 50% chance for 100-200 Coins
- 30% chance for 500 XP
- 10% chance for Item ID 5 (e.g. Rare Gem)
- 10% chance for Nothing
**JSON for `usageData`**:
```json
{
"consume": true,
"effects": [
{
"type": "LOOTBOX",
"pool": [
{
"type": "CURRENCY",
"weight": 50,
"minAmount": 100,
"maxAmount": 200
},
{
"type": "XP",
"weight": 30,
"amount": 500
},
{
"type": "ITEM",
"weight": 10,
"itemId": 5,
"amount": 1,
"message": "Startstruck! You found a Rare Gem!"
},
{
"type": "NOTHING",
"weight": 10,
"message": "It's empty..."
}
]
}
]
}
```

72
docs/MODULE_STRUCTURE.md Normal file
View File

@@ -0,0 +1,72 @@
# Aurora Module Structure Guide
This guide documents the standard module organization patterns used in the Aurora codebase. Following these patterns ensures consistency, maintainability, and clear separation of concerns.
## Module Anatomy
A typical module in `@modules/` is organized into several files, each with a specific responsibility.
Example: `trade` module
- `trade.service.ts`: Business logic and data access.
- `trade.view.ts`: Discord UI components (embeds, modals, select menus).
- `trade.interaction.ts`: Handler for interaction events (buttons, modals, etc.).
- `trade.types.ts`: TypeScript interfaces and types.
- `trade.service.test.ts`: Unit tests for the service logic.
## File Responsibilities
### 1. Service (`*.service.ts`)
The core of the module. It contains the business logic, database interactions (using Drizzle), and state management.
- **Rules**:
- Export a singleton instance: `export const tradeService = new TradeService();`
- Should not contain Discord-specific rendering logic (return data, not embeds).
- Throw `UserError` for validation issues that should be shown to the user.
### 2. View (`*.view.ts`)
Handles the creation of Discord-specific UI elements like `EmbedBuilder`, `ActionRowBuilder`, and `ModalBuilder`.
- **Rules**:
- Focus on formatting and presentation.
- Takes raw data (from services) and returns Discord components.
### 3. Interaction Handler (`*.interaction.ts`)
The entry point for Discord component interactions (buttons, select menus, modals).
- **Rules**:
- Export a single handler function: `export async function handleTradeInteraction(interaction: Interaction) { ... }`
- Routes internal `customId` patterns to specific logic.
- Relies on `ComponentInteractionHandler` for centralized error handling.
- **No local try-catch** for standard validation errors; let them bubble up as `UserError`.
### 4. Types (`*.types.ts`)
Central location for module-specific TypeScript types and constants.
- **Rules**:
- Define interfaces for complex data structures.
- Use enums or literal types for states and custom IDs.
## Interaction Routing
All interaction handlers must be registered in `src/lib/interaction.routes.ts`.
```typescript
{
predicate: (i) => i.customId.startsWith("module_"),
handler: () => import("@/modules/module/module.interaction"),
method: 'handleModuleInteraction'
}
```
## Error Handling Standards
Aurora uses a centralized error handling pattern in `ComponentInteractionHandler`.
1. **UserError**: Use this for validation errors or issues the user can fix (e.g., "Insufficient funds").
- `throw new UserError("You need more coins!");`
2. **SystemError / Generic Error**: Use this for unexpected system failures.
- These are logged to the console/logger and show a generic "Unexpected error" message to the user.
## Naming Conventions
- **Directory Name**: Lowercase, singular (e.g., `trade`, `inventory`).
- **File Names**: `moduleName.type.ts` (e.g., `trade.service.ts`).
- **Class Names**: PascalCase (e.g., `TradeService`).
- **Service Instances**: camelCase (e.g., `tradeService`).
- **Interaction Method**: `handle[ModuleName]Interaction`.

View File

@@ -1,6 +1,11 @@
import { defineConfig } from "drizzle-kit"; import { defineConfig } from "drizzle-kit";
import { env } from "./src/lib/env"; import { env } from "./src/lib/env";
// @ts-expect-error - Polyfill for BigInt serialization
BigInt.prototype.toJSON = function () {
return this.toString();
};
export default defineConfig({ export default defineConfig({
schema: "./src/db/schema.ts", schema: "./src/db/schema.ts",
out: "./drizzle", out: "./drizzle",

View File

@@ -0,0 +1,113 @@
CREATE TABLE "classes" (
"id" bigint PRIMARY KEY NOT NULL,
"name" varchar(255) NOT NULL,
"balance" bigint DEFAULT 0,
"role_id" varchar(255),
CONSTRAINT "classes_name_unique" UNIQUE("name")
);
--> statement-breakpoint
CREATE TABLE "inventory" (
"user_id" bigint NOT NULL,
"item_id" integer NOT NULL,
"quantity" bigint DEFAULT 1,
CONSTRAINT "inventory_user_id_item_id_pk" PRIMARY KEY("user_id","item_id"),
CONSTRAINT "quantity_check" CHECK ("inventory"."quantity" > 0)
);
--> statement-breakpoint
CREATE TABLE "item_transactions" (
"id" bigserial PRIMARY KEY NOT NULL,
"user_id" bigint NOT NULL,
"related_user_id" bigint,
"item_id" integer NOT NULL,
"quantity" bigint NOT NULL,
"type" varchar(50) NOT NULL,
"description" text,
"created_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "items" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar(255) NOT NULL,
"description" text,
"rarity" varchar(20) DEFAULT 'Common',
"type" varchar(50) DEFAULT 'MATERIAL' NOT NULL,
"usage_data" jsonb DEFAULT '{}'::jsonb,
"price" bigint,
"icon_url" text NOT NULL,
"image_url" text NOT NULL,
CONSTRAINT "items_name_unique" UNIQUE("name")
);
--> statement-breakpoint
CREATE TABLE "lootdrops" (
"message_id" varchar(255) PRIMARY KEY NOT NULL,
"channel_id" varchar(255) NOT NULL,
"reward_amount" integer NOT NULL,
"currency" varchar(50) NOT NULL,
"claimed_by" bigint,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"expires_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "quests" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar(255) NOT NULL,
"description" text,
"trigger_event" varchar(50) NOT NULL,
"requirements" jsonb DEFAULT '{}'::jsonb NOT NULL,
"rewards" jsonb DEFAULT '{}'::jsonb NOT NULL
);
--> statement-breakpoint
CREATE TABLE "transactions" (
"id" bigserial PRIMARY KEY NOT NULL,
"user_id" bigint,
"related_user_id" bigint,
"amount" bigint NOT NULL,
"type" varchar(50) NOT NULL,
"description" text,
"created_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "user_quests" (
"user_id" bigint NOT NULL,
"quest_id" integer NOT NULL,
"progress" integer DEFAULT 0,
"completed_at" timestamp with time zone,
CONSTRAINT "user_quests_user_id_quest_id_pk" PRIMARY KEY("user_id","quest_id")
);
--> statement-breakpoint
CREATE TABLE "user_timers" (
"user_id" bigint NOT NULL,
"type" varchar(50) NOT NULL,
"key" varchar(100) NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb,
CONSTRAINT "user_timers_user_id_type_key_pk" PRIMARY KEY("user_id","type","key")
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" bigint PRIMARY KEY NOT NULL,
"class_id" bigint,
"username" varchar(255) NOT NULL,
"is_active" boolean DEFAULT true,
"balance" bigint DEFAULT 0,
"xp" bigint DEFAULT 0,
"level" integer DEFAULT 1,
"daily_streak" integer DEFAULT 0,
"settings" jsonb DEFAULT '{}'::jsonb,
"created_at" timestamp with time zone DEFAULT now(),
"updated_at" timestamp with time zone DEFAULT now(),
CONSTRAINT "users_username_unique" UNIQUE("username")
);
--> statement-breakpoint
ALTER TABLE "inventory" ADD CONSTRAINT "inventory_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "inventory" ADD CONSTRAINT "inventory_item_id_items_id_fk" FOREIGN KEY ("item_id") REFERENCES "public"."items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "item_transactions" ADD CONSTRAINT "item_transactions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "item_transactions" ADD CONSTRAINT "item_transactions_related_user_id_users_id_fk" FOREIGN KEY ("related_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "item_transactions" ADD CONSTRAINT "item_transactions_item_id_items_id_fk" FOREIGN KEY ("item_id") REFERENCES "public"."items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lootdrops" ADD CONSTRAINT "lootdrops_claimed_by_users_id_fk" FOREIGN KEY ("claimed_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_related_user_id_users_id_fk" FOREIGN KEY ("related_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_quests" ADD CONSTRAINT "user_quests_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_quests" ADD CONSTRAINT "user_quests_quest_id_quests_id_fk" FOREIGN KEY ("quest_id") REFERENCES "public"."quests"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_timers" ADD CONSTRAINT "user_timers_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "users" ADD CONSTRAINT "users_class_id_classes_id_fk" FOREIGN KEY ("class_id") REFERENCES "public"."classes"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,17 @@
CREATE TABLE "moderation_cases" (
"id" bigserial PRIMARY KEY NOT NULL,
"case_id" varchar(50) NOT NULL,
"type" varchar(20) NOT NULL,
"user_id" bigint NOT NULL,
"username" varchar(255) NOT NULL,
"moderator_id" bigint NOT NULL,
"moderator_name" varchar(255) NOT NULL,
"reason" text NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb,
"active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"resolved_at" timestamp with time zone,
"resolved_by" bigint,
"resolved_reason" text,
CONSTRAINT "moderation_cases_case_id_unique" UNIQUE("case_id")
);

View File

@@ -0,0 +1,8 @@
CREATE INDEX "moderation_cases_user_id_idx" ON "moderation_cases" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "moderation_cases_case_id_idx" ON "moderation_cases" USING btree ("case_id");--> statement-breakpoint
CREATE INDEX "transactions_created_at_idx" ON "transactions" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "user_timers_expires_at_idx" ON "user_timers" USING btree ("expires_at");--> statement-breakpoint
CREATE INDEX "user_timers_lookup_idx" ON "user_timers" USING btree ("user_id","type","key");--> statement-breakpoint
CREATE INDEX "users_username_idx" ON "users" USING btree ("username");--> statement-breakpoint
CREATE INDEX "users_balance_idx" ON "users" USING btree ("balance");--> statement-breakpoint
CREATE INDEX "users_level_xp_idx" ON "users" USING btree ("level","xp");

View File

@@ -0,0 +1,770 @@
{
"id": "d43c3f7b-afe5-4974-ab67-fcd69256f3d8",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.classes": {
"name": "classes",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "bigint",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"balance": {
"name": "balance",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"default": "0"
},
"role_id": {
"name": "role_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"classes_name_unique": {
"name": "classes_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.inventory": {
"name": "inventory",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"item_id": {
"name": "item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"quantity": {
"name": "quantity",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"default": "1"
}
},
"indexes": {},
"foreignKeys": {
"inventory_user_id_users_id_fk": {
"name": "inventory_user_id_users_id_fk",
"tableFrom": "inventory",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"inventory_item_id_items_id_fk": {
"name": "inventory_item_id_items_id_fk",
"tableFrom": "inventory",
"tableTo": "items",
"columnsFrom": [
"item_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"inventory_user_id_item_id_pk": {
"name": "inventory_user_id_item_id_pk",
"columns": [
"user_id",
"item_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"quantity_check": {
"name": "quantity_check",
"value": "\"inventory\".\"quantity\" > 0"
}
},
"isRLSEnabled": false
},
"public.item_transactions": {
"name": "item_transactions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "bigserial",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"related_user_id": {
"name": "related_user_id",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"item_id": {
"name": "item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"quantity": {
"name": "quantity",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"item_transactions_user_id_users_id_fk": {
"name": "item_transactions_user_id_users_id_fk",
"tableFrom": "item_transactions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"item_transactions_related_user_id_users_id_fk": {
"name": "item_transactions_related_user_id_users_id_fk",
"tableFrom": "item_transactions",
"tableTo": "users",
"columnsFrom": [
"related_user_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"item_transactions_item_id_items_id_fk": {
"name": "item_transactions_item_id_items_id_fk",
"tableFrom": "item_transactions",
"tableTo": "items",
"columnsFrom": [
"item_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.items": {
"name": "items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"rarity": {
"name": "rarity",
"type": "varchar(20)",
"primaryKey": false,
"notNull": false,
"default": "'Common'"
},
"type": {
"name": "type",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"default": "'MATERIAL'"
},
"usage_data": {
"name": "usage_data",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'{}'::jsonb"
},
"price": {
"name": "price",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"icon_url": {
"name": "icon_url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"items_name_unique": {
"name": "items_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.lootdrops": {
"name": "lootdrops",
"schema": "",
"columns": {
"message_id": {
"name": "message_id",
"type": "varchar(255)",
"primaryKey": true,
"notNull": true
},
"channel_id": {
"name": "channel_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"reward_amount": {
"name": "reward_amount",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"currency": {
"name": "currency",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true
},
"claimed_by": {
"name": "claimed_by",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"lootdrops_claimed_by_users_id_fk": {
"name": "lootdrops_claimed_by_users_id_fk",
"tableFrom": "lootdrops",
"tableTo": "users",
"columnsFrom": [
"claimed_by"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.quests": {
"name": "quests",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"trigger_event": {
"name": "trigger_event",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true
},
"requirements": {
"name": "requirements",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"rewards": {
"name": "rewards",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.transactions": {
"name": "transactions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "bigserial",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"related_user_id": {
"name": "related_user_id",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"amount": {
"name": "amount",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"transactions_user_id_users_id_fk": {
"name": "transactions_user_id_users_id_fk",
"tableFrom": "transactions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"transactions_related_user_id_users_id_fk": {
"name": "transactions_related_user_id_users_id_fk",
"tableFrom": "transactions",
"tableTo": "users",
"columnsFrom": [
"related_user_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_quests": {
"name": "user_quests",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"quest_id": {
"name": "quest_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"progress": {
"name": "progress",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"completed_at": {
"name": "completed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"user_quests_user_id_users_id_fk": {
"name": "user_quests_user_id_users_id_fk",
"tableFrom": "user_quests",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_quests_quest_id_quests_id_fk": {
"name": "user_quests_quest_id_quests_id_fk",
"tableFrom": "user_quests",
"tableTo": "quests",
"columnsFrom": [
"quest_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"user_quests_user_id_quest_id_pk": {
"name": "user_quests_user_id_quest_id_pk",
"columns": [
"user_id",
"quest_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_timers": {
"name": "user_timers",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true
},
"key": {
"name": "key",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'{}'::jsonb"
}
},
"indexes": {},
"foreignKeys": {
"user_timers_user_id_users_id_fk": {
"name": "user_timers_user_id_users_id_fk",
"tableFrom": "user_timers",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"user_timers_user_id_type_key_pk": {
"name": "user_timers_user_id_type_key_pk",
"columns": [
"user_id",
"type",
"key"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "bigint",
"primaryKey": true,
"notNull": true
},
"class_id": {
"name": "class_id",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"username": {
"name": "username",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"balance": {
"name": "balance",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"default": "0"
},
"xp": {
"name": "xp",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"default": "0"
},
"level": {
"name": "level",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 1
},
"daily_streak": {
"name": "daily_streak",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"settings": {
"name": "settings",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'{}'::jsonb"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"users_class_id_classes_id_fk": {
"name": "users_class_id_classes_id_fk",
"tableFrom": "users",
"tableTo": "classes",
"columnsFrom": [
"class_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_username_unique": {
"name": "users_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,878 @@
{
"id": "72cb5e22-fb44-4db8-9527-020dbec017d0",
"prevId": "d43c3f7b-afe5-4974-ab67-fcd69256f3d8",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.classes": {
"name": "classes",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "bigint",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"balance": {
"name": "balance",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"default": "0"
},
"role_id": {
"name": "role_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"classes_name_unique": {
"name": "classes_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.inventory": {
"name": "inventory",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"item_id": {
"name": "item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"quantity": {
"name": "quantity",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"default": "1"
}
},
"indexes": {},
"foreignKeys": {
"inventory_user_id_users_id_fk": {
"name": "inventory_user_id_users_id_fk",
"tableFrom": "inventory",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"inventory_item_id_items_id_fk": {
"name": "inventory_item_id_items_id_fk",
"tableFrom": "inventory",
"tableTo": "items",
"columnsFrom": [
"item_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"inventory_user_id_item_id_pk": {
"name": "inventory_user_id_item_id_pk",
"columns": [
"user_id",
"item_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"quantity_check": {
"name": "quantity_check",
"value": "\"inventory\".\"quantity\" > 0"
}
},
"isRLSEnabled": false
},
"public.item_transactions": {
"name": "item_transactions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "bigserial",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"related_user_id": {
"name": "related_user_id",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"item_id": {
"name": "item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"quantity": {
"name": "quantity",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"item_transactions_user_id_users_id_fk": {
"name": "item_transactions_user_id_users_id_fk",
"tableFrom": "item_transactions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"item_transactions_related_user_id_users_id_fk": {
"name": "item_transactions_related_user_id_users_id_fk",
"tableFrom": "item_transactions",
"tableTo": "users",
"columnsFrom": [
"related_user_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"item_transactions_item_id_items_id_fk": {
"name": "item_transactions_item_id_items_id_fk",
"tableFrom": "item_transactions",
"tableTo": "items",
"columnsFrom": [
"item_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.items": {
"name": "items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"rarity": {
"name": "rarity",
"type": "varchar(20)",
"primaryKey": false,
"notNull": false,
"default": "'Common'"
},
"type": {
"name": "type",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"default": "'MATERIAL'"
},
"usage_data": {
"name": "usage_data",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'{}'::jsonb"
},
"price": {
"name": "price",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"icon_url": {
"name": "icon_url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"items_name_unique": {
"name": "items_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.lootdrops": {
"name": "lootdrops",
"schema": "",
"columns": {
"message_id": {
"name": "message_id",
"type": "varchar(255)",
"primaryKey": true,
"notNull": true
},
"channel_id": {
"name": "channel_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"reward_amount": {
"name": "reward_amount",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"currency": {
"name": "currency",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true
},
"claimed_by": {
"name": "claimed_by",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"lootdrops_claimed_by_users_id_fk": {
"name": "lootdrops_claimed_by_users_id_fk",
"tableFrom": "lootdrops",
"tableTo": "users",
"columnsFrom": [
"claimed_by"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.moderation_cases": {
"name": "moderation_cases",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "bigserial",
"primaryKey": true,
"notNull": true
},
"case_id": {
"name": "case_id",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "varchar(20)",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"username": {
"name": "username",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"moderator_id": {
"name": "moderator_id",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"moderator_name": {
"name": "moderator_name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": true
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'{}'::jsonb"
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"resolved_at": {
"name": "resolved_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"resolved_by": {
"name": "resolved_by",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"resolved_reason": {
"name": "resolved_reason",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"moderation_cases_case_id_unique": {
"name": "moderation_cases_case_id_unique",
"nullsNotDistinct": false,
"columns": [
"case_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.quests": {
"name": "quests",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"trigger_event": {
"name": "trigger_event",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true
},
"requirements": {
"name": "requirements",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"rewards": {
"name": "rewards",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.transactions": {
"name": "transactions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "bigserial",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"related_user_id": {
"name": "related_user_id",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"amount": {
"name": "amount",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"transactions_user_id_users_id_fk": {
"name": "transactions_user_id_users_id_fk",
"tableFrom": "transactions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"transactions_related_user_id_users_id_fk": {
"name": "transactions_related_user_id_users_id_fk",
"tableFrom": "transactions",
"tableTo": "users",
"columnsFrom": [
"related_user_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_quests": {
"name": "user_quests",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"quest_id": {
"name": "quest_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"progress": {
"name": "progress",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"completed_at": {
"name": "completed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"user_quests_user_id_users_id_fk": {
"name": "user_quests_user_id_users_id_fk",
"tableFrom": "user_quests",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_quests_quest_id_quests_id_fk": {
"name": "user_quests_quest_id_quests_id_fk",
"tableFrom": "user_quests",
"tableTo": "quests",
"columnsFrom": [
"quest_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"user_quests_user_id_quest_id_pk": {
"name": "user_quests_user_id_quest_id_pk",
"columns": [
"user_id",
"quest_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_timers": {
"name": "user_timers",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true
},
"key": {
"name": "key",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'{}'::jsonb"
}
},
"indexes": {},
"foreignKeys": {
"user_timers_user_id_users_id_fk": {
"name": "user_timers_user_id_users_id_fk",
"tableFrom": "user_timers",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"user_timers_user_id_type_key_pk": {
"name": "user_timers_user_id_type_key_pk",
"columns": [
"user_id",
"type",
"key"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "bigint",
"primaryKey": true,
"notNull": true
},
"class_id": {
"name": "class_id",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"username": {
"name": "username",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"balance": {
"name": "balance",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"default": "0"
},
"xp": {
"name": "xp",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"default": "0"
},
"level": {
"name": "level",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 1
},
"daily_streak": {
"name": "daily_streak",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"settings": {
"name": "settings",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'{}'::jsonb"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"users_class_id_classes_id_fk": {
"name": "users_class_id_classes_id_fk",
"tableFrom": "users",
"tableTo": "classes",
"columnsFrom": [
"class_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_username_unique": {
"name": "users_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1766137924760,
"tag": "0000_fixed_tomas",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1766606046050,
"tag": "0001_heavy_thundra",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1767716705797,
"tag": "0002_fancy_forge",
"breakpoints": true
}
]
}

View File

@@ -5,8 +5,7 @@
"private": true, "private": true,
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"drizzle-kit": "^0.31.7", "drizzle-kit": "^0.31.7"
"postgres": "^3.4.7"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5" "typescript": "^5"
@@ -15,13 +14,18 @@
"generate": "docker compose run --rm app drizzle-kit generate", "generate": "docker compose run --rm app drizzle-kit generate",
"migrate": "docker compose run --rm app drizzle-kit migrate", "migrate": "docker compose run --rm app drizzle-kit migrate",
"db:push": "docker compose run --rm app drizzle-kit push", "db:push": "docker compose run --rm app drizzle-kit push",
"db:push:local": "drizzle-kit push",
"dev": "bun --watch src/index.ts", "dev": "bun --watch src/index.ts",
"db:studio": "drizzle-kit studio --host 0.0.0.0" "db:studio": "drizzle-kit studio --host 0.0.0.0",
"studio:remote": "bash scripts/remote-studio.sh",
"test": "bun test"
}, },
"dependencies": { "dependencies": {
"@napi-rs/canvas": "^0.1.84",
"discord.js": "^14.25.1", "discord.js": "^14.25.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"drizzle-orm": "^0.44.7", "drizzle-orm": "^0.44.7",
"postgres": "^3.4.7",
"zod": "^4.1.13" "zod": "^4.1.13"
} }
} }

25
scripts/remote-studio.sh Executable file
View File

@@ -0,0 +1,25 @@
#!/bin/bash
# Load environment variables
if [ -f .env ]; then
# export $(grep -v '^#' .env | xargs) # Use a safer way if possible, but for simple .env this often works.
# Better way to source .env without exporting everything to shell if we just want to use them in script:
set -a
source .env
set +a
fi
if [ -z "$VPS_HOST" ] || [ -z "$VPS_USER" ]; then
echo "Error: VPS_HOST and VPS_USER must be set in .env"
echo "Please add them to your .env file:"
echo "VPS_USER=your-username"
echo "VPS_HOST=your-ip-address"
exit 1
fi
echo "🔮 Establishing secure tunnel to Drizzle Studio..."
echo "📚 Studio will be accessible at: https://local.drizzle.studio"
echo "Press Ctrl+C to stop the connection."
# -N means "Do not execute a remote command". -L is for local port forwarding.
ssh -N -L 4983:127.0.0.1:4983 $VPS_USER@$VPS_HOST

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@@ -0,0 +1,54 @@
import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@/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 "@/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@/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 "@/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@/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 "@lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, ModalSubmitInteraction } from "discord.js";
import { config, saveConfig } from "@lib/config";
import type { GameConfigType } from "@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 "@/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, AttachmentBuilder } from "discord.js";
import { config, saveConfig } from "@/lib/config";
import { DrizzleClient } from "@/lib/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 "@/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,95 @@
import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { createBaseEmbed } from "@lib/embeds";
import { configManager } from "@/lib/configManager";
import { config, reloadConfig } from "@/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 });
configManager.toggleCommand(commandName, enabled);
await interaction.editReply({ content: `✅ Command **${commandName}** has been ${enabled ? "enabled" : "disabled"}. Reloading configuration...` });
// Reload config from disk (which was updated by configManager)
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("@/lib/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 "@lib/utils";
import { AuroraClient } from "@/lib/BotClient";
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, Colors } from "discord.js";
import { DrizzleClient } from "@/lib/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 "@/lib/utils";
import {
SlashCommandBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
type BaseGuildTextChannel,
PermissionFlagsBits,
MessageFlags
} from "discord.js";
import { inventoryService } from "@/modules/inventory/inventory.service";
import { createSuccessEmbed, createErrorEmbed, createBaseEmbed } from "@lib/embeds";
import { UserError } from "@/lib/errors";
import { items } from "@/db/schema";
import { ilike, isNotNull, and } from "drizzle-orm";
import { DrizzleClient } from "@/lib/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.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
} else {
console.error("Error creating listing:", error);
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
}
}
},
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 "@/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@/modules/moderation/moderation.service";
import { CaseType } from "@/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 "@/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@/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
src/commands/admin/prune.ts Normal file
View File

@@ -0,0 +1,179 @@
import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
import { config } from "@/lib/config";
import { PruneService } from "@/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 "@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 "@/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js";
import { terminalService } from "@/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,99 @@
import { createCommand } from "@lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
import { UpdateService } from "@/modules/admin/update.service";
import {
getCheckingEmbed,
getNoUpdatesEmbed,
getUpdatesAvailableMessage,
getPreparingEmbed,
getUpdatingEmbed,
getCancelledEmbed,
getTimeoutEmbed,
getErrorEmbed
} from "@/modules/admin/update.view";
export const update = createCommand({
data: new SlashCommandBuilder()
.setName("update")
.setDescription("Check for updates and restart the bot")
.addBooleanOption(option =>
option.setName("force")
.setDescription("Force update even if checks fail (not recommended)")
.setRequired(false)
)
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const force = interaction.options.getBoolean("force") || false;
try {
await interaction.editReply({ embeds: [getCheckingEmbed()] });
const { hasUpdates, log, branch } = await UpdateService.checkForUpdates();
if (!hasUpdates && !force) {
await interaction.editReply({ embeds: [getNoUpdatesEmbed()] });
return;
}
const { embeds, components } = getUpdatesAvailableMessage(branch, log, force);
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 === "confirm_update") {
await confirmation.update({
embeds: [getPreparingEmbed()],
components: []
});
// 1. Check what the update requires
const { needsInstall, needsMigrations } = await UpdateService.checkUpdateRequirements(branch);
// 2. Prepare context BEFORE update
await UpdateService.prepareRestartContext({
channelId: interaction.channelId,
userId: interaction.user.id,
timestamp: Date.now(),
runMigrations: needsMigrations,
installDependencies: needsInstall
});
// 3. Update UI to "Restarting" state
await interaction.editReply({ embeds: [getUpdatingEmbed(needsInstall)] });
// 4. Perform Update (Danger Zone)
await UpdateService.performUpdate(branch);
// 5. Trigger Restart (if we are still alive)
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)] });
}
}
});

View File

@@ -0,0 +1,87 @@
import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@/modules/moderation/moderation.service";
import {
getWarnSuccessEmbed,
getModerationErrorEmbed,
getUserWarningEmbed
} from "@/modules/moderation/moderation.view";
import { config } from "@/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 "@/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@/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

@@ -0,0 +1,56 @@
import { createCommand } from "@/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()
.setName("webhook")
.setDescription("Send a message via webhook using a JSON payload")
.setDefaultMemberPermissions(PermissionFlagsBits.ManageWebhooks)
.addStringOption(option =>
option.setName("payload")
.setDescription("The JSON payload for the webhook message")
.setRequired(true)
),
execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const payloadString = interaction.options.getString("payload", true);
let payload;
try {
payload = JSON.parse(payloadString);
} catch (error) {
await interaction.editReply({
embeds: [createErrorEmbed("The provided payload is not valid JSON.", "Invalid JSON")]
});
return;
}
const channel = interaction.channel;
if (!channel || !('createWebhook' in channel)) {
await interaction.editReply({
embeds: [createErrorEmbed("This channel does not support webhooks.", "Unsupported Channel")]
});
return;
}
try {
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);
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,29 +1,33 @@
import { createCommand } from "@lib/utils"; import { createCommand } from "@/lib/utils";
import { getUserBalance } from "@/modules/economy/economy.service"; import { SlashCommandBuilder } from "discord.js";
import { createUser, getUserById } from "@/modules/users/users.service"; import { userService } from "@/modules/user/user.service";
import { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } from "discord.js"; import { createBaseEmbed } from "@lib/embeds";
export const balance = createCommand({ export const balance = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("balance") .setName("balance")
.setDescription("Check your balance") .setDescription("Check your or another user's balance")
.addUserOption(option =>
option.setName("user")
.setDescription("The user to check")
.setRequired(false)
),
execute: async (interaction) => {
await interaction.deferReply();
const targetUser = interaction.options.getUser("user") || interaction.user;
, execute: async (interaction) => { if (targetUser.bot) {
const user = interaction.user; return;
// Ensure user exists in DB
let dbUser = await getUserById(user.id);
if (!dbUser) {
await createUser(user.id);
} }
const balance = await getUserBalance(user.id); const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
const embed = new EmbedBuilder() if (!user) throw new Error("Failed to retrieve user data.");
.setTitle(`${user.username}'s Balance`)
.setDescription(`💰 **${balance} coins**`)
.setColor("Green");
await interaction.reply({ embeds: [embed] }); const embed = createBaseEmbed(undefined, `**Balance**: ${user.balance || 0n} AU`, "Yellow")
.setAuthor({ name: targetUser.username, iconURL: targetUser.displayAvatarURL() });
await interaction.editReply({ embeds: [embed] });
} }
}); });

View File

@@ -1,68 +1,35 @@
import { createCommand } from "@lib/utils";
import { addUserBalance } from "@/modules/economy/economy.service"; import { createCommand } from "@/lib/utils";
import { createUser, getUserById, updateUserDaily } from "@/modules/users/users.service"; import { SlashCommandBuilder } from "discord.js";
import { SlashCommandBuilder, EmbedBuilder } from "discord.js"; import { economyService } from "@/modules/economy/economy.service";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { UserError } from "@/lib/errors";
export const daily = createCommand({ export const daily = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("daily") .setName("daily")
.setDescription("Get rewarded with daily coins"), .setDescription("Claim your daily reward"),
execute: async (interaction) => { execute: async (interaction) => {
const user = interaction.user; try {
// Ensure user exists in DB const result = await economyService.claimDaily(interaction.user.id);
let dbUser = await getUserById(user.id);
if (!dbUser) {
dbUser = await createUser(user.id);
}
const now = new Date(); const embed = createSuccessEmbed(`You claimed ** ${result.amount}** Astral Units!${result.isWeekly ? `\n🎉 **Weekly Bonus!** +${result.weeklyBonus} extra!` : ''}`, "💰 Daily Reward Claimed!")
const lastDaily = dbUser.lastDaily; .addFields(
{ name: "Streak", value: `🔥 ${result.streak} days`, inline: true },
if (lastDaily) { { name: "Weekly Progress", value: `${"🟩".repeat(result.streak % 7 || 7)}${"⬜".repeat(7 - (result.streak % 7 || 7))} (${result.streak % 7 || 7}/7)`, inline: true },
const diff = now.getTime() - lastDaily.getTime(); { name: "Next Reward", value: `<t:${Math.floor(result.nextReadyAt.getTime() / 1000)}:R> `, inline: true }
const oneDay = 24 * 60 * 60 * 1000; )
.setColor("Gold");
if (diff < oneDay) {
const remaining = oneDay - diff;
const hours = Math.floor(remaining / (1000 * 60 * 60));
const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60));
const embed = new EmbedBuilder()
.setTitle("Daily Reward")
.setDescription(`You have already claimed your daily reward.\nCome back in **${hours}h ${minutes}m**.`)
.setColor("Red");
await interaction.reply({ embeds: [embed], ephemeral: true });
return;
}
}
// Calculate streak
let streak = dbUser.dailyStreak;
if (lastDaily) {
const diff = now.getTime() - lastDaily.getTime();
const twoDays = 48 * 60 * 60 * 1000;
if (diff < twoDays) {
streak += 1;
} else {
streak = 1;
}
} else {
streak = 1;
}
const baseReward = 100;
const streakBonus = (streak - 1) * 10;
const totalReward = baseReward + streakBonus;
await updateUserDaily(user.id, now, streak);
await addUserBalance(user.id, totalReward);
const embed = new EmbedBuilder()
.setTitle("Daily Reward Claimed!")
.setDescription(`You received **${totalReward} coins**! 💰\n\n**Streak:** ${streak} days 🔥`)
.setColor("Green");
await interaction.reply({ embeds: [embed] }); await interaction.reply({ embeds: [embed] });
} catch (error: any) {
if (error instanceof UserError) {
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
} else {
console.error("Error claiming daily:", error);
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
}
}
} }
}); });

View File

@@ -0,0 +1,205 @@
import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder } from "discord.js";
import { userService } from "@/modules/user/user.service";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { UserError } from "@/lib/errors";
import { userTimers, users } from "@/db/schema";
import { eq, and, sql } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient";
import { config } from "@lib/config";
import { TimerType } from "@/lib/constants";
const EXAM_TIMER_TYPE = TimerType.EXAM_SYSTEM;
const EXAM_TIMER_KEY = 'default';
interface ExamMetadata {
examDay: number;
lastXp: string;
}
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();
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
if (!user) {
await interaction.editReply({ embeds: [createErrorEmbed("Failed to retrieve user data.")] });
return;
}
const now = new Date();
const currentDay = now.getDay();
try {
// 1. Fetch existing timer/exam data
const timer = await DrizzleClient.query.userTimers.findFirst({
where: and(
eq(userTimers.userId, user.id),
eq(userTimers.type, EXAM_TIMER_TYPE),
eq(userTimers.key, EXAM_TIMER_KEY)
)
});
// 2. First Run Logic
if (!timer) {
// Set exam day to today
const nextExamDate = new Date(now);
nextExamDate.setDate(now.getDate() + 7);
nextExamDate.setHours(0, 0, 0, 0);
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
const metadata: ExamMetadata = {
examDay: currentDay,
lastXp: (user.xp ?? 0n).toString()
};
await DrizzleClient.insert(userTimers).values({
userId: user.id,
type: EXAM_TIMER_TYPE,
key: EXAM_TIMER_KEY,
expiresAt: nextExamDate,
metadata: metadata
});
await interaction.editReply({
embeds: [createSuccessEmbed(
`You have registered for the exam! Your exam day is **${DAYS[currentDay]}** (Server Time).\n` +
`Come back on <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>) to take your first exam!`,
"Exam Registration Successful"
)]
});
return;
}
const metadata = timer.metadata as unknown as ExamMetadata;
const examDay = metadata.examDay;
// 3. Cooldown Check
const expiresAt = new Date(timer.expiresAt);
expiresAt.setHours(0, 0, 0, 0);
if (now < expiresAt) {
// Calculate time remaining
const timestamp = Math.floor(expiresAt.getTime() / 1000);
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:${timestamp}:D> (<t:${timestamp}:R>)`
)]
});
return;
}
// 4. Day Check
if (currentDay !== examDay) {
// Calculate next correct exam day to correct the schedule
let daysUntil = (examDay - currentDay + 7) % 7;
if (daysUntil === 0) daysUntil = 7;
const nextExamDate = new Date(now);
nextExamDate.setDate(now.getDate() + daysUntil);
nextExamDate.setHours(0, 0, 0, 0);
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
const newMetadata: ExamMetadata = {
examDay: examDay,
lastXp: (user.xp ?? 0n).toString()
};
await DrizzleClient.update(userTimers)
.set({
expiresAt: nextExamDate,
metadata: newMetadata
})
.where(and(
eq(userTimers.userId, user.id),
eq(userTimers.type, EXAM_TIMER_TYPE),
eq(userTimers.key, EXAM_TIMER_KEY)
));
await interaction.editReply({
embeds: [createErrorEmbed(
`You missed your exam day! Your exam day is **${DAYS[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;
}
// 5. Reward Calculation
const lastXp = BigInt(metadata.lastXp || "0"); // Fallback just in case
const currentXp = user.xp ?? 0n;
const diff = currentXp - lastXp;
// Calculate Reward
const multMin = config.economy.exam.multMin;
const multMax = config.economy.exam.multMax;
const multiplier = Math.random() * (multMax - multMin) + multMin;
// Allow negative reward? existing description implies "difference", usually gain.
// If diff is negative (lost XP?), reward might be 0.
let reward = 0n;
if (diff > 0n) {
reward = BigInt(Math.floor(Number(diff) * multiplier));
}
// 6. Update State
const nextExamDate = new Date(now);
nextExamDate.setDate(now.getDate() + 7);
nextExamDate.setHours(0, 0, 0, 0);
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
const newMetadata: ExamMetadata = {
examDay: examDay,
lastXp: currentXp.toString()
};
await DrizzleClient.transaction(async (tx) => {
// Update Timer
await tx.update(userTimers)
.set({
expiresAt: nextExamDate,
metadata: newMetadata
})
.where(and(
eq(userTimers.userId, user.id),
eq(userTimers.type, EXAM_TIMER_TYPE),
eq(userTimers.key, EXAM_TIMER_KEY)
));
// Add Currency
if (reward > 0n) {
await tx.update(users)
.set({
balance: sql`${users.balance} + ${reward}`
})
.where(eq(users.id, user.id));
}
});
await interaction.editReply({
embeds: [createSuccessEmbed(
`**XP Gained:** ${diff.toString()}\n` +
`**Multiplier:** x${multiplier.toFixed(2)}\n` +
`**Reward:** ${reward.toString()} Currency\n\n` +
`See you next week: <t:${nextExamTimestamp}:D>`,
"Exam Passed!"
)]
});
} catch (error: any) {
if (error instanceof UserError) {
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
} else {
console.error("Error in exam command:", error);
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
}
}
}
});

View File

@@ -1,61 +1,69 @@
import { createCommand } from "@lib/utils";
import { getUserBalance, setUserBalance } from "@/modules/economy/economy.service"; import { createCommand } from "@/lib/utils";
import { createUser, getUserById } from "@/modules/users/users.service"; import { SlashCommandBuilder, MessageFlags } from "discord.js";
import { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } from "discord.js"; import { economyService } from "@/modules/economy/economy.service";
import { userService } from "@/modules/user/user.service";
import { config } from "@/lib/config";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { UserError } from "@/lib/errors";
export const pay = createCommand({ export const pay = createCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("pay") .setName("pay")
.setDescription("Send balance to another user") .setDescription("Transfer Astral Units to another user")
.addUserOption(option => .addUserOption(option =>
option.setName('recipient') option.setName("user")
.setDescription('The user to send balance to') .setDescription("The user to pay")
.setRequired(true)) .setRequired(true)
)
.addIntegerOption(option => .addIntegerOption(option =>
option.setName('amount') option.setName("amount")
.setDescription('The amount of balance to send') .setDescription("Amount to transfer")
.setRequired(true)) .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) {
, execute: async (interaction) => { await interaction.reply({ embeds: [createErrorEmbed("You cannot send money to bots.")], flags: MessageFlags.Ephemeral });
const user = interaction.user;
// Ensure if your user exists in DB
let dbUser = await getUserById(user.id);
if (!dbUser) {
await createUser(user.id);
}
const balance = await getUserBalance(user.id);
const recipient = interaction.options.getUser('recipient');
const amount = interaction.options.getInteger('amount');
if (amount! <= 0) {
await interaction.reply({ content: "❌ Amount must be greater than zero.", ephemeral: true });
return;
}
if (amount! > balance) {
await interaction.reply({ content: "❌ You do not have enough coins to complete this transaction.", ephemeral: true });
return; return;
} }
if (recipient!.id === user.id) { const amount = BigInt(interaction.options.getInteger("amount", true));
await interaction.reply({ content: "❌ You cannot send coins to yourself.", ephemeral: true }); const senderId = interaction.user.id;
if (!targetUser) {
await interaction.reply({ embeds: [createErrorEmbed("User not found.")], flags: MessageFlags.Ephemeral });
return; return;
} }
// Ensure recipient exists in DB const receiverId = targetUser.id;
let dbRecipient = await getUserById(recipient!.id);
if (!dbRecipient) { if (amount < config.economy.transfers.minAmount) {
dbRecipient = await createUser(recipient!.id); await interaction.reply({ embeds: [createErrorEmbed(`Amount must be at least ${config.economy.transfers.minAmount}.`)], flags: MessageFlags.Ephemeral });
return;
} }
await setUserBalance(user.id, balance - amount!); // Deduct from sender if (senderId === receiverId.toString()) {
await setUserBalance(recipient!.id, (await getUserBalance(recipient!.id)) + amount!); // Add to recipient await interaction.reply({ embeds: [createErrorEmbed("You cannot pay yourself.")], flags: MessageFlags.Ephemeral });
return;
}
const embed = new EmbedBuilder() try {
.setDescription(`sent **${amount} coins** to ${recipient!.username}`) await interaction.deferReply();
.setColor("Green"); await economyService.transfer(senderId, receiverId.toString(), amount);
await interaction.reply({ embeds: [embed] }); 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

@@ -0,0 +1,75 @@
import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags } from "discord.js";
import { tradeService } from "@/modules/trade/trade.service";
import { getTradeDashboard } from "@/modules/trade/trade.view";
import { createErrorEmbed, createWarningEmbed } from "@lib/embeds";
export const trade = createCommand({
data: new SlashCommandBuilder()
.setName("trade")
.setDescription("Start a trade with another player")
.addUserOption(option =>
option.setName("user")
.setDescription("The user to trade with")
.setRequired(true)
),
execute: async (interaction) => {
const targetUser = interaction.options.getUser("user", true);
if (targetUser.id === interaction.user.id) {
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.")], 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.")], flags: MessageFlags.Ephemeral });
return;
}
// Check if we can create threads
// Assuming permissions are fine.
await interaction.reply({ content: `🔄 Setting up trade with ${targetUser}...` });
const message = await interaction.fetchReply();
let thread;
try {
thread = await message.startThread({
name: `trade-${interaction.user.username}-${targetUser.username}`,
autoArchiveDuration: ThreadAutoArchiveDuration.OneHour,
reason: "Trading Session"
});
} catch (e) {
// Fallback if message threads fail, try channel threads (private preferred)
// But startThread on message is usually easiest.
try {
await message.delete();
} catch (err) {
console.error("Failed to delete setup message", err);
}
await interaction.followUp({ embeds: [createErrorEmbed("Failed to create trade thread. Check permissions.")], flags: MessageFlags.Ephemeral });
return;
}
// Setup Session
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 dashboard = getTradeDashboard(session);
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,29 @@
import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder } from "discord.js";
import { config } from "@/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

@@ -0,0 +1,44 @@
import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder } from "discord.js";
import { inventoryService } from "@/modules/inventory/inventory.service";
import { userService } from "@/modules/user/user.service";
import { createWarningEmbed } from "@lib/embeds";
import { getInventoryEmbed } from "@/modules/inventory/inventory.view";
export const inventory = createCommand({
data: new SlashCommandBuilder()
.setName("inventory")
.setDescription("View your or another user's inventory")
.addUserOption(option =>
option.setName("user")
.setDescription("User to view")
.setRequired(false)
),
execute: async (interaction) => {
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);
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 embed = getInventoryEmbed(items, user.username);
await interaction.editReply({ embeds: [embed] });
}
});

View File

@@ -0,0 +1,79 @@
import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder } from "discord.js";
import { inventoryService } from "@/modules/inventory/inventory.service";
import { userService } from "@/modules/user/user.service";
import { createErrorEmbed } from "@lib/embeds";
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
import type { ItemUsageData } from "@/lib/types";
import { UserError } from "@/lib/errors";
import { config } from "@/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 "@/lib/utils";
import { SlashCommandBuilder } from "discord.js";
import { DrizzleClient } from "@/lib/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 "@/lib/utils";
import { SlashCommandBuilder, MessageFlags } from "discord.js";
import { questService } from "@/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,34 +0,0 @@
import { createCommand } from "@lib/utils";
import { KyokoClient } from "@lib/KyokoClient";
import { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } from "discord.js";
export const reload = createCommand({
data: new SlashCommandBuilder()
.setName("reload")
.setDescription("Reloads all commands")
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
await interaction.deferReply({ ephemeral: true });
try {
await KyokoClient.loadCommands(true);
const embed = new EmbedBuilder()
.setTitle("✅ System Reloaded")
.setDescription(`Successfully reloaded ${KyokoClient.commands.size} commands.`)
.setColor("Green");
// Deploy commands
await KyokoClient.deployCommands();
await interaction.editReply({ embeds: [embed] });
} catch (error) {
console.error(error);
const embed = new EmbedBuilder()
.setTitle("❌ Reload Failed")
.setDescription("An error occurred while reloading commands. Check console for details.")
.setColor("Red");
await interaction.editReply({ embeds: [embed] });
}
}
});

View File

@@ -0,0 +1,43 @@
import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder, AttachmentBuilder } from "discord.js";
import { userService } from "@/modules/user/user.service";
import { generateStudentIdCard } from "@/graphics/studentID";
import { createWarningEmbed } from "@/lib/embeds";
export const profile = createCommand({
data: new SlashCommandBuilder()
.setName("profile")
.setDescription("View your or another user's profile")
.addUserOption(option =>
option.setName("user")
.setDescription("The user to view")
.setRequired(false)
),
execute: async (interaction) => {
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({
username: targetUser.username,
avatarUrl: targetUser.displayAvatarURL({ extension: 'png', size: 256 }),
id: targetUser.id,
level: user!.level || 1,
xp: user!.xp || 0n,
au: user!.balance || 0n,
className: user!.class?.name || "D"
});
const attachment = new AttachmentBuilder(cardBuffer, { name: 'student-id.png' });
await interaction.editReply({ files: [attachment] });
}
});

43
src/db/indexes.test.ts Normal file
View File

@@ -0,0 +1,43 @@
import { expect, test, describe } from "bun:test";
import { postgres } from "../lib/DrizzleClient";
describe("Database Indexes", () => {
test("should have indexes on users table", async () => {
const result = await postgres`
SELECT indexname FROM pg_indexes
WHERE tablename = 'users'
`;
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
expect(indexNames).toContain("users_balance_idx");
expect(indexNames).toContain("users_level_xp_idx");
});
test("should have index on transactions table", async () => {
const result = await postgres`
SELECT indexname FROM pg_indexes
WHERE tablename = 'transactions'
`;
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
expect(indexNames).toContain("transactions_created_at_idx");
});
test("should have indexes on moderation_cases table", async () => {
const result = await postgres`
SELECT indexname FROM pg_indexes
WHERE tablename = 'moderation_cases'
`;
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
expect(indexNames).toContain("moderation_cases_user_id_idx");
expect(indexNames).toContain("moderation_cases_case_id_idx");
});
test("should have indexes on user_timers table", async () => {
const result = await postgres`
SELECT indexname FROM pg_indexes
WHERE tablename = 'user_timers'
`;
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
expect(indexNames).toContain("user_timers_expires_at_idx");
expect(indexNames).toContain("user_timers_lookup_idx");
});
});

View File

@@ -1,19 +1,264 @@
import { pgTable, integer, text, timestamp, serial } from "drizzle-orm/pg-core"; import {
pgTable,
bigint,
varchar,
boolean,
jsonb,
timestamp,
serial,
text,
integer,
primaryKey,
index,
bigserial,
check
} from 'drizzle-orm/pg-core';
import { relations, sql } from 'drizzle-orm';
export const users = pgTable("users", { // --- TABLES ---
userId: text("user_id").primaryKey().notNull(),
balance: integer("balance").notNull().default(0), // 1. Classes
lastDaily: timestamp("last_daily"), export const classes = pgTable('classes', {
dailyStreak: integer("daily_streak").notNull().default(0), id: bigint('id', { mode: 'bigint' }).primaryKey(),
createdAt: timestamp("created_at").defaultNow(), name: varchar('name', { length: 255 }).unique().notNull(),
balance: bigint('balance', { mode: 'bigint' }).default(0n),
roleId: varchar('role_id', { length: 255 }),
}); });
export const transactions = pgTable("transactions", { // 2. Users
transactionId: serial("transaction_id").primaryKey().notNull(), export const users = pgTable('users', {
fromUserId: text("from_user_id").references(() => users.userId), id: bigint('id', { mode: 'bigint' }).primaryKey(),
toUserId: text("to_user_id").references(() => users.userId), classId: bigint('class_id', { mode: 'bigint' }).references(() => classes.id),
amount: integer("amount").notNull(), username: varchar('username', { length: 255 }).unique().notNull(),
occuredAt: timestamp("occured_at").defaultNow(), isActive: boolean('is_active').default(true),
type: text("type").notNull(),
description: text("description"), // Economy
balance: bigint('balance', { mode: 'bigint' }).default(0n),
xp: bigint('xp', { mode: 'bigint' }).default(0n),
level: integer('level').default(1),
dailyStreak: integer('daily_streak').default(0),
// Metadata
settings: jsonb('settings').default({}),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
}, (table) => [
index('users_username_idx').on(table.username),
index('users_balance_idx').on(table.balance),
index('users_level_xp_idx').on(table.level, table.xp),
]);
// 3. Items
export const items = pgTable('items', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 255 }).unique().notNull(),
description: text('description'),
rarity: varchar('rarity', { length: 20 }).default('Common'),
// Economy & Visuals
type: varchar('type', { length: 50 }).notNull().default('MATERIAL'),
// Examples: 'CONSUMABLE', 'EQUIPMENT', 'MATERIAL'
usageData: jsonb('usage_data').default({}),
price: bigint('price', { mode: 'bigint' }),
iconUrl: text('icon_url').notNull(),
imageUrl: text('image_url').notNull(),
}); });
// 4. Inventory (Join Table)
export const inventory = pgTable('inventory', {
userId: bigint('user_id', { mode: 'bigint' })
.references(() => users.id, { onDelete: 'cascade' }).notNull(),
itemId: integer('item_id')
.references(() => items.id, { onDelete: 'cascade' }).notNull(),
quantity: bigint('quantity', { mode: 'bigint' }).default(1n),
}, (table) => [
primaryKey({ columns: [table.userId, table.itemId] }),
check('quantity_check', sql`${table.quantity} > 0`)
]);
// 5. Transactions
export const transactions = pgTable('transactions', {
id: bigserial('id', { mode: 'bigint' }).primaryKey(),
userId: bigint('user_id', { mode: 'bigint' })
.references(() => users.id, { onDelete: 'cascade' }),
relatedUserId: bigint('related_user_id', { mode: 'bigint' })
.references(() => users.id, { onDelete: 'set null' }),
amount: bigint('amount', { mode: 'bigint' }).notNull(),
type: varchar('type', { length: 50 }).notNull(),
description: text('description'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
}, (table) => [
index('transactions_created_at_idx').on(table.createdAt),
]);
export const itemTransactions = pgTable('item_transactions', {
id: bigserial('id', { mode: 'bigint' }).primaryKey(),
userId: bigint('user_id', { mode: 'bigint' })
.references(() => users.id, { onDelete: 'cascade' }).notNull(),
relatedUserId: bigint('related_user_id', { mode: 'bigint' })
.references(() => users.id, { onDelete: 'set null' }), // who they got it from/gave it to
itemId: integer('item_id')
.references(() => items.id, { onDelete: 'cascade' }).notNull(),
quantity: bigint('quantity', { mode: 'bigint' }).notNull(), // positive = gain, negative = loss
type: varchar('type', { length: 50 }).notNull(), // e.g., 'TRADE', 'SHOP_BUY', 'DROP'
description: text('description'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
});
// 6. Quests
export const quests = pgTable('quests', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
description: text('description'),
triggerEvent: varchar('trigger_event', { length: 50 }).notNull(),
requirements: jsonb('requirements').notNull().default({}),
rewards: jsonb('rewards').notNull().default({}),
});
// 7. User Quests (Join Table)
export const userQuests = pgTable('user_quests', {
userId: bigint('user_id', { mode: 'bigint' })
.references(() => users.id, { onDelete: 'cascade' }).notNull(),
questId: integer('quest_id')
.references(() => quests.id, { onDelete: 'cascade' }).notNull(),
progress: integer('progress').default(0),
completedAt: timestamp('completed_at', { withTimezone: true }),
}, (table) => [
primaryKey({ columns: [table.userId, table.questId] })
]);
// 8. User Timers (Generic: Cooldowns, Effects, Access)
export const userTimers = pgTable('user_timers', {
userId: bigint('user_id', { mode: 'bigint' })
.references(() => users.id, { onDelete: 'cascade' }).notNull(),
type: varchar('type', { length: 50 }).notNull(), // 'COOLDOWN', 'EFFECT', 'ACCESS'
key: varchar('key', { length: 100 }).notNull(), // 'daily', 'chn_12345', 'xp_boost'
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
metadata: jsonb('metadata').default({}), // Store channelId, specific buff amounts, etc.
}, (table) => [
primaryKey({ columns: [table.userId, table.type, table.key] }),
index('user_timers_expires_at_idx').on(table.expiresAt),
index('user_timers_lookup_idx').on(table.userId, table.type, table.key),
]);
// 9. Lootdrops
export const lootdrops = pgTable('lootdrops', {
messageId: varchar('message_id', { length: 255 }).primaryKey(),
channelId: varchar('channel_id', { length: 255 }).notNull(),
rewardAmount: integer('reward_amount').notNull(),
currency: varchar('currency', { length: 50 }).notNull(),
claimedBy: bigint('claimed_by', { mode: 'bigint' }).references(() => users.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }),
});
// 10. Moderation Cases
export const moderationCases = pgTable('moderation_cases', {
id: bigserial('id', { mode: 'bigint' }).primaryKey(),
caseId: varchar('case_id', { length: 50 }).unique().notNull(),
type: varchar('type', { length: 20 }).notNull(), // 'warn', 'timeout', 'kick', 'ban', 'note', 'prune'
userId: bigint('user_id', { mode: 'bigint' }).notNull(),
username: varchar('username', { length: 255 }).notNull(),
moderatorId: bigint('moderator_id', { mode: 'bigint' }).notNull(),
moderatorName: varchar('moderator_name', { length: 255 }).notNull(),
reason: text('reason').notNull(),
metadata: jsonb('metadata').default({}),
active: boolean('active').default(true).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
resolvedAt: timestamp('resolved_at', { withTimezone: true }),
resolvedBy: bigint('resolved_by', { mode: 'bigint' }),
resolvedReason: text('resolved_reason'),
}, (table) => [
index('moderation_cases_user_id_idx').on(table.userId),
index('moderation_cases_case_id_idx').on(table.caseId),
]);
export const classesRelations = relations(classes, ({ many }) => ({
users: many(users),
}));
export const usersRelations = relations(users, ({ one, many }) => ({
class: one(classes, {
fields: [users.classId],
references: [classes.id],
}),
inventory: many(inventory),
transactions: many(transactions),
quests: many(userQuests),
timers: many(userTimers),
}));
export const itemsRelations = relations(items, ({ many }) => ({
inventoryEntries: many(inventory),
}));
export const inventoryRelations = relations(inventory, ({ one }) => ({
user: one(users, {
fields: [inventory.userId],
references: [users.id],
}),
item: one(items, {
fields: [inventory.itemId],
references: [items.id],
}),
}));
export const transactionsRelations = relations(transactions, ({ one }) => ({
user: one(users, {
fields: [transactions.userId],
references: [users.id],
}),
}));
export const questsRelations = relations(quests, ({ many }) => ({
userEntries: many(userQuests),
}));
export const userQuestsRelations = relations(userQuests, ({ one }) => ({
user: one(users, {
fields: [userQuests.userId],
references: [users.id],
}),
quest: one(quests, {
fields: [userQuests.questId],
references: [quests.id],
}),
}));
export const userTimersRelations = relations(userTimers, ({ one }) => ({
user: one(users, {
fields: [userTimers.userId],
references: [users.id],
}),
}));
export const itemTransactionsRelations = relations(itemTransactions, ({ one }) => ({
user: one(users, {
fields: [itemTransactions.userId],
references: [users.id],
}),
relatedUser: one(users, {
fields: [itemTransactions.relatedUserId],
references: [users.id],
}),
item: one(items, {
fields: [itemTransactions.itemId],
references: [items.id],
}),
}));
export const moderationCasesRelations = relations(moderationCases, ({ one }) => ({
user: one(users, {
fields: [moderationCases.userId],
references: [users.id],
}),
moderator: one(users, {
fields: [moderationCases.moderatorId],
references: [users.id],
}),
resolver: one(users, {
fields: [moderationCases.resolvedBy],
references: [users.id],
}),
}));

View File

@@ -0,0 +1,35 @@
import { Events } from "discord.js";
import type { Event } from "@lib/types";
import { config } from "@lib/config";
import { userService } from "@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 "@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

@@ -0,0 +1,22 @@
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";
const event: Event<Events.MessageCreate> = {
name: Events.MessageCreate,
execute: async (message) => {
if (message.author.bot) return;
if (!message.guild) return;
const user = await userService.getUserById(message.author.id);
if (!user) return;
levelingService.processChatXp(message.author.id);
// Activity Tracking for Lootdrops
import("@/modules/economy/lootdrop.service").then(m => m.lootdropService.processMessage(message));
},
};
export default event;

18
src/events/ready.ts Normal file
View File

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

135
src/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(), 'src', '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(), 'src', '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(), 'src', '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');
}

113
src/graphics/studentID.ts Normal file
View File

@@ -0,0 +1,113 @@
import { GlobalFonts, createCanvas, loadImage } from '@napi-rs/canvas';
import { levelingService } from '@/modules/leveling/leveling.service';
import path from 'path';
// Register Fonts
const fontDir = path.join(process.cwd(), 'src', 'assets', 'fonts');
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexSansCondensed-SemiBold.ttf'), 'IBMPlexSansCondensed-SemiBold');
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexMono-Bold.ttf'), 'IBMPlexMono-Bold');
interface StudentCardData {
username: string;
avatarUrl: string;
id: string;
level: number;
au: bigint; // Astral Units
xp: bigint;
className?: string | null;
}
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 template = await loadImage(templatePath);
const classTemplate = await loadImage(classTemplatePath);
const canvas = createCanvas(template.width, template.height);
const ctx = canvas.getContext('2d');
// Draw Background Gradient with random hue
const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height);
const saturation = 40 + Math.random() * 20; // 40-60%
const lightness = 20 + Math.random() * 20; // 20-40%
const color2 = `hsl(${Math.random() * 360}, ${saturation}%, ${lightness}%)`;
const color = `hsl(${Math.random() * 360}, ${saturation}%, ${lightness - 20}%)`;
gradient.addColorStop(0, color);
gradient.addColorStop(1, color2);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw Template
ctx.drawImage(template, 0, 0);
// Draw Class Template
ctx.drawImage(classTemplate, 0, 0);
// Draw Avatar
const avatarSize = 140;
const avatarX = 19;
const avatarY = 76;
try {
const avatar = await loadImage(data.avatarUrl);
ctx.save();
ctx.beginPath();
// Square avatar
ctx.rect(avatarX, avatarY, avatarSize, avatarSize);
ctx.clip();
ctx.drawImage(avatar, avatarX, avatarY, avatarSize, avatarSize);
ctx.restore();
} catch (e) {
console.error("Failed to load avatar", e);
}
// Draw ID
ctx.save();
ctx.font = '12px IBMPlexMono-Bold';
ctx.fillStyle = '#DAC7A1';
ctx.textAlign = 'left';
ctx.fillText(`ID: ${data.id}`, 314, 30);
ctx.restore();
// Draw Username
ctx.save();
ctx.font = '24px IBMPlexSansCondensed-SemiBold';
ctx.fillStyle = '#FFFFFF';
ctx.textAlign = 'left';
ctx.fillText(data.username, 181, 122);
ctx.restore();
// Draw AU
ctx.save();
ctx.font = '24px IBMPlexMono-Bold';
ctx.fillStyle = '#FFFFFF';
ctx.textAlign = 'right';
ctx.fillText(`${data.au}`, 270, 183);
ctx.restore();
// Draw Level
ctx.save();
ctx.font = '24px IBMPlexMono-Bold';
ctx.fillStyle = '#FFFFFF';
ctx.textAlign = 'center';
ctx.fillText(`${data.level}`, 445, 255);
ctx.restore();
// Draw XP Bar
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 = Math.max(0, Math.min(xpBarMaxWidth, xpBarMaxWidth * currentLevelProgress / xpForThisLevel));
const xpBarHeight = 3;
ctx.save();
ctx.fillStyle = '#B3AD93';
ctx.fillRect(32, 244, xpBarWidth, xpBarHeight);
ctx.restore();
return canvas.toBuffer('image/png');
}

View File

@@ -1,39 +1,26 @@
import { Events } from "discord.js"; import { AuroraClient } from "@/lib/BotClient";
import { KyokoClient } from "@lib/KyokoClient";
import { env } from "@lib/env"; import { env } from "@lib/env";
// Load commands import { WebServer } from "@/web/server";
await KyokoClient.loadCommands();
await KyokoClient.deployCommands();
KyokoClient.once(Events.ClientReady, async c => { // Load commands & events
console.log(`Ready! Logged in as ${c.user.tag}`); await AuroraClient.loadCommands();
}); await AuroraClient.loadEvents();
await AuroraClient.deployCommands();
KyokoClient.on(Events.InteractionCreate, async interaction => { WebServer.start();
if (!interaction.isChatInputCommand()) return;
const command = KyokoClient.commands.get(interaction.commandName);
if (!command) {
console.error(`No command matching ${interaction.commandName} was found.`);
return;
}
try {
await command.execute(interaction);
} catch (error) {
console.error(error);
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ content: 'There was an error while executing this command!', ephemeral: true });
} else {
await interaction.reply({ content: 'There was an error while executing this command!', ephemeral: true });
}
}
});
// login with the token from .env // login with the token from .env
if (!env.DISCORD_BOT_TOKEN) { if (!env.DISCORD_BOT_TOKEN) {
throw new Error("❌ DISCORD_BOT_TOKEN is not set in environment variables."); throw new Error("❌ DISCORD_BOT_TOKEN is not set in environment variables.");
} }
KyokoClient.login(env.DISCORD_BOT_TOKEN); AuroraClient.login(env.DISCORD_BOT_TOKEN);
// Handle graceful shutdown
const shutdownHandler = () => {
WebServer.stop();
AuroraClient.shutdown();
};
process.on("SIGINT", shutdownHandler);
process.on("SIGTERM", shutdownHandler);

122
src/lib/BotClient.ts Normal file
View File

@@ -0,0 +1,122 @@
import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes } from "discord.js";
import { join } from "node:path";
import type { Command } from "@lib/types";
import { env } from "@lib/env";
import { CommandLoader } from "@lib/loaders/CommandLoader";
import { EventLoader } from "@lib/loaders/EventLoader";
import { logger } from "@lib/logger";
export class Client extends DiscordClient {
commands: Collection<string, Command>;
lastCommandTimestamp: number | null = null;
private commandLoader: CommandLoader;
private eventLoader: EventLoader;
constructor({ intents }: { intents: number[] }) {
super({ intents });
this.commands = new Collection<string, Command>();
this.commandLoader = new CommandLoader(this);
this.eventLoader = new EventLoader(this);
}
async loadCommands(reload: boolean = false) {
if (reload) {
this.commands.clear();
logger.info("♻️ Reloading commands...");
}
const commandsPath = join(import.meta.dir, '../commands');
const result = await this.commandLoader.loadFromDirectory(commandsPath, reload);
logger.info(`📦 Command loading complete: ${result.loaded} loaded, ${result.skipped} skipped, ${result.errors.length} errors`);
}
async loadEvents(reload: boolean = false) {
if (reload) {
this.removeAllListeners();
logger.info("♻️ Reloading events...");
}
const eventsPath = join(import.meta.dir, '../events');
const result = await this.eventLoader.loadFromDirectory(eventsPath, reload);
logger.info(`📦 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) {
logger.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) {
logger.error("DISCORD_CLIENT_ID is not set.");
return;
}
try {
logger.info(`Started refreshing ${commandsData.length} application (/) commands.`);
let data;
if (guildId) {
logger.info(`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 {
logger.info('Registering commands globally');
data = await rest.put(
Routes.applicationCommands(clientId),
{ body: commandsData },
);
}
logger.success(`Successfully reloaded ${(data as any).length} application (/) commands.`);
} catch (error: any) {
if (error.code === 50001) {
logger.warn("Missing Access: The bot is not in the guild or lacks 'applications.commands' scope.");
logger.warn(" If you are testing locally, make sure you invited the bot with scope 'bot applications.commands'.");
} else {
logger.error(error);
}
}
}
async shutdown() {
const { setShuttingDown, waitForTransactions } = await import("./shutdown");
const { closeDatabase } = await import("./DrizzleClient");
logger.info("🛑 Shutdown signal received. Starting graceful shutdown...");
setShuttingDown(true);
// Wait for transactions to complete
logger.info("⏳ Waiting for active transactions to complete...");
await waitForTransactions(10000);
// Destroy Discord client
logger.info("🔌 Disconnecting from Discord...");
this.destroy();
// Close database
logger.info("🗄️ Closing database connection...");
await closeDatabase();
logger.success("👋 Graceful shutdown complete. Exiting.");
process.exit(0);
}
}
export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers] });

View File

@@ -4,6 +4,10 @@ import * as schema from "@db/schema";
import { env } from "@lib/env"; import { env } from "@lib/env";
const connectionString = env.DATABASE_URL; const connectionString = env.DATABASE_URL;
const postgres = new SQL(connectionString); export const postgres = new SQL(connectionString);
export const DrizzleClient = drizzle(postgres, { schema }); export const DrizzleClient = drizzle(postgres, { schema });
export const closeDatabase = async () => {
await postgres.close();
};

View File

@@ -1,120 +0,0 @@
import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes } from "discord.js";
import { readdir } from "node:fs/promises";
import { join } from "node:path";
import type { Command } from "@lib/types";
import { env } from "@lib/env";
class Client extends DiscordClient {
commands: Collection<string, Command>;
constructor({ intents }: { intents: number[] }) {
super({ intents });
this.commands = new Collection<string, Command>();
}
async loadCommands(reload: boolean = false) {
if (reload) {
this.commands.clear();
console.log("♻️ Reloading commands...");
}
const commandsPath = join(import.meta.dir, '../commands');
await this.readCommandsRecursively(commandsPath, reload);
}
private async readCommandsRecursively(dir: string, reload: boolean = false) {
try {
const files = await readdir(dir, { withFileTypes: true });
for (const file of files) {
const filePath = join(dir, file.name);
if (file.isDirectory()) {
await this.readCommandsRecursively(filePath, reload);
continue;
}
if (!file.name.endsWith('.ts') && !file.name.endsWith('.js')) continue;
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 ${file.name}`);
continue;
}
for (const command of commands) {
if (this.isValidCommand(command)) {
this.commands.set(command.data.name, command);
console.log(`✅ Loaded command: ${command.data.name}`);
} else {
console.warn(`⚠️ Skipping invalid command in ${file.name}`);
}
}
} catch (error) {
console.error(`❌ Failed to load command from ${filePath}:`, error);
}
}
} catch (error) {
console.error(`Error reading directory ${dir}:`, error);
}
}
private isValidCommand(command: any): command is Command {
return command && typeof command === 'object' && 'data' in command && 'execute' in command;
}
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);
}
}
}
}
export const KyokoClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages] });

204
src/lib/config.ts Normal file
View File

@@ -0,0 +1,204 @@
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { z } from 'zod';
const configPath = join(import.meta.dir, '..', '..', 'config', 'config.json');
export interface GameConfigType {
leveling: {
base: number;
exponent: number;
chat: {
cooldownMs: number;
minXp: number;
maxXp: number;
}
},
economy: {
daily: {
amount: bigint;
streakBonus: bigint;
weeklyBonus: bigint;
cooldownMs: number;
},
transfers: {
allowSelfTransfer: boolean;
minAmount: bigint;
},
exam: {
multMin: number;
multMax: number;
}
},
inventory: {
maxStackSize: bigint;
maxSlots: number;
},
commands: Record<string, boolean>;
lootdrop: {
activityWindowMs: number;
minMessages: number;
spawnChance: number;
cooldownMs: number;
reward: {
min: number;
max: number;
currency: string;
}
};
studentRole: string;
visitorRole: string;
colorRoles: string[];
welcomeChannelId?: string;
welcomeMessage?: string;
feedbackChannelId?: string;
terminal?: {
channelId: string;
messageId: string;
};
moderation: {
prune: {
maxAmount: number;
confirmThreshold: number;
batchSize: number;
batchDelayMs: number;
};
cases: {
dmOnWarn: boolean;
logChannelId?: string;
autoTimeoutThreshold?: number;
};
};
system: Record<string, any>;
}
// Initial default config state
export const config: GameConfigType = {} as GameConfigType;
const bigIntSchema = z.union([z.string(), z.number(), z.bigint()])
.refine((val) => {
try {
BigInt(val);
return true;
} catch {
return false;
}
}, { message: "Must be a valid integer" })
.transform((val) => BigInt(val));
const configSchema = z.object({
leveling: z.object({
base: z.number(),
exponent: z.number(),
chat: z.object({
cooldownMs: z.number(),
minXp: z.number(),
maxXp: z.number(),
})
}),
economy: z.object({
daily: z.object({
amount: bigIntSchema,
streakBonus: bigIntSchema,
weeklyBonus: bigIntSchema.default(50n),
cooldownMs: z.number(),
}),
transfers: z.object({
allowSelfTransfer: z.boolean(),
minAmount: bigIntSchema,
}),
exam: z.object({
multMin: z.number(),
multMax: z.number(),
})
}),
inventory: z.object({
maxStackSize: bigIntSchema,
maxSlots: z.number(),
}),
commands: z.record(z.string(), z.boolean()),
lootdrop: z.object({
activityWindowMs: z.number(),
minMessages: z.number(),
spawnChance: z.number(),
cooldownMs: z.number(),
reward: z.object({
min: z.number(),
max: z.number(),
currency: z.string(),
})
}),
studentRole: z.string(),
visitorRole: z.string(),
colorRoles: z.array(z.string()).default([]),
welcomeChannelId: z.string().optional(),
welcomeMessage: z.string().optional(),
feedbackChannelId: z.string().optional(),
terminal: z.object({
channelId: z.string(),
messageId: z.string()
}).optional(),
moderation: z.object({
prune: z.object({
maxAmount: z.number().default(100),
confirmThreshold: z.number().default(50),
batchSize: z.number().default(100),
batchDelayMs: z.number().default(1000)
}),
cases: z.object({
dmOnWarn: z.boolean().default(true),
logChannelId: z.string().optional(),
autoTimeoutThreshold: z.number().optional()
})
}).default({
prune: {
maxAmount: 100,
confirmThreshold: 50,
batchSize: 100,
batchDelayMs: 1000
},
cases: {
dmOnWarn: true
}
}),
system: z.record(z.string(), z.any()).default({}),
});
export function reloadConfig() {
if (!existsSync(configPath)) {
throw new Error(`Config file not found at ${configPath}`);
}
const raw = readFileSync(configPath, 'utf-8');
const rawConfig = JSON.parse(raw);
// Update config object in place
// We use Object.assign to keep the reference to the exported 'config' object same
const validatedConfig = configSchema.parse(rawConfig);
Object.assign(config, validatedConfig);
console.log("🔄 Config reloaded from disk.");
}
// Initial load
reloadConfig();
// Backwards compatibility alias
export const GameConfig = config;
export function saveConfig(newConfig: unknown) {
// Validate and transform input
const validatedConfig = configSchema.parse(newConfig);
const replacer = (key: string, value: any) => {
if (typeof value === 'bigint') {
return value.toString();
}
return value;
};
const jsonString = JSON.stringify(validatedConfig, replacer, 4);
writeFileSync(configPath, jsonString, 'utf-8');
reloadConfig();
}

19
src/lib/configManager.ts Normal file
View File

@@ -0,0 +1,19 @@
import { readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
const configPath = join(process.cwd(), 'config', 'config.json');
export const configManager = {
toggleCommand: (commandName: string, enabled: boolean) => {
const raw = readFileSync(configPath, 'utf-8');
const data = JSON.parse(raw);
if (!data.commands) {
data.commands = {};
}
data.commands[commandName] = enabled;
writeFileSync(configPath, JSON.stringify(data, null, 4));
}
};

65
src/lib/constants.ts Normal file
View File

@@ -0,0 +1,65 @@
/**
* Global Constants and Enums
*/
export enum TimerType {
COOLDOWN = 'COOLDOWN',
EFFECT = 'EFFECT',
ACCESS = 'ACCESS',
EXAM_SYSTEM = 'EXAM_SYSTEM',
}
export enum EffectType {
ADD_XP = 'ADD_XP',
ADD_BALANCE = 'ADD_BALANCE',
REPLY_MESSAGE = 'REPLY_MESSAGE',
XP_BOOST = 'XP_BOOST',
TEMP_ROLE = 'TEMP_ROLE',
COLOR_ROLE = 'COLOR_ROLE',
LOOTBOX = 'LOOTBOX',
}
export enum TransactionType {
TRANSFER_IN = 'TRANSFER_IN',
TRANSFER_OUT = 'TRANSFER_OUT',
DAILY_REWARD = 'DAILY_REWARD',
ITEM_USE = 'ITEM_USE',
LOOTBOX = 'LOOTBOX',
EXAM_REWARD = 'EXAM_REWARD',
PURCHASE = 'PURCHASE',
TRADE_IN = 'TRADE_IN',
TRADE_OUT = 'TRADE_OUT',
QUEST_REWARD = 'QUEST_REWARD',
}
export enum ItemTransactionType {
TRADE_IN = 'TRADE_IN',
TRADE_OUT = 'TRADE_OUT',
SHOP_BUY = 'SHOP_BUY',
DROP = 'DROP',
GIVE = 'GIVE',
USE = 'USE',
}
export enum ItemType {
MATERIAL = 'MATERIAL',
CONSUMABLE = 'CONSUMABLE',
EQUIPMENT = 'EQUIPMENT',
QUEST = 'QUEST',
}
export enum CaseType {
WARN = 'warn',
TIMEOUT = 'timeout',
KICK = 'kick',
BAN = 'ban',
NOTE = 'note',
PRUNE = 'prune',
}
export enum LootType {
NOTHING = 'NOTHING',
CURRENCY = 'CURRENCY',
XP = 'XP',
ITEM = 'ITEM',
}

47
src/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
src/lib/db.ts Normal file
View File

@@ -0,0 +1,25 @@
import { DrizzleClient } from "./DrizzleClient";
import type { Transaction } from "./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();
}
}
};

75
src/lib/embeds.ts Normal file
View File

@@ -0,0 +1,75 @@
import { Colors, type ColorResolvable, EmbedBuilder } from "discord.js";
/**
* Creates a standardized error embed.
* @param message The error message to display.
* @param title Optional title for the embed. Defaults to "Error".
* @returns An EmbedBuilder instance configured as an error.
*/
export function createErrorEmbed(message: string, title: string = "Error"): EmbedBuilder {
return new EmbedBuilder()
.setTitle(`${title}`)
.setDescription(message)
.setColor(Colors.Red)
.setTimestamp();
}
/**
* Creates a standardized warning embed.
* @param message The warning message to display.
* @param title Optional title for the embed. Defaults to "Warning".
* @returns An EmbedBuilder instance configured as a warning.
*/
export function createWarningEmbed(message: string, title: string = "Warning"): EmbedBuilder {
return new EmbedBuilder()
.setTitle(`⚠️ ${title}`)
.setDescription(message)
.setColor(Colors.Yellow)
.setTimestamp();
}
/**
* Creates a standardized success embed.
* @param message The success message to display.
* @param title Optional title for the embed. Defaults to "Success".
* @returns An EmbedBuilder instance configured as a success.
*/
export function createSuccessEmbed(message: string, title: string = "Success"): EmbedBuilder {
return new EmbedBuilder()
.setTitle(`${title}`)
.setDescription(message)
.setColor(Colors.Green)
.setTimestamp();
}
/**
* Creates a standardized info embed.
* @param message The info message to display.
* @param title Optional title for the embed. Defaults to "Info".
* @returns An EmbedBuilder instance configured as info.
*/
export function createInfoEmbed(message: string, title: string = "Info"): EmbedBuilder {
return new EmbedBuilder()
.setTitle(` ${title}`)
.setDescription(message)
.setColor(Colors.Blue)
.setTimestamp();
}
/**
* 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();
if (title) embed.setTitle(title);
if (description) embed.setDescription(description);
if (color) embed.setColor(color);
return embed;
}

View File

@@ -5,6 +5,7 @@ const envSchema = z.object({
DISCORD_CLIENT_ID: z.string().optional(), DISCORD_CLIENT_ID: z.string().optional(),
DISCORD_GUILD_ID: z.string().optional(), DISCORD_GUILD_ID: z.string().optional(),
DATABASE_URL: z.string().min(1, "Database URL is required"), DATABASE_URL: z.string().min(1, "Database URL is required"),
PORT: z.coerce.number().default(3000),
}); });
const parsedEnv = envSchema.safeParse(process.env); const parsedEnv = envSchema.safeParse(process.env);

18
src/lib/errors.ts Normal file
View File

@@ -0,0 +1,18 @@
export class ApplicationError extends Error {
constructor(message: string) {
super(message);
this.name = this.constructor.name;
}
}
export class UserError extends ApplicationError {
constructor(message: string) {
super(message);
}
}
export class SystemError extends ApplicationError {
constructor(message: string) {
super(message);
}
}

View File

@@ -0,0 +1,22 @@
import { AutocompleteInteraction } from "discord.js";
import { AuroraClient } from "@/lib/BotClient";
import { logger } from "@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(`Error handling autocomplete for ${interaction.commandName}:`, error);
}
}
}

View File

@@ -0,0 +1,59 @@
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("@/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();
});
});

View File

@@ -0,0 +1,41 @@
import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
import { AuroraClient } from "@/lib/BotClient";
import { userService } from "@/modules/user/user.service";
import { createErrorEmbed } from "@lib/embeds";
import { logger } from "@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(`No command matching ${interaction.commandName} was found.`);
return;
}
// Ensure user exists in database
try {
await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
} catch (error) {
logger.error("Failed to ensure user exists:", error);
}
try {
await command.execute(interaction);
AuroraClient.lastCommandTimestamp = Date.now();
} catch (error) {
logger.error(String(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,78 @@
import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction, MessageFlags } from "discord.js";
import { logger } from "@lib/logger";
import { UserError } from "@lib/errors";
import { createErrorEmbed } from "@lib/embeds";
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(`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(`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(`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,61 @@
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'
},
// --- 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,111 @@
import { readdir } from "node:fs/promises";
import { join } from "node:path";
import type { Command } from "@lib/types";
import { config } from "@lib/config";
import type { LoadResult, LoadError } from "./types";
import type { Client } from "../BotClient";
import { logger } from "@lib/logger";
/**
* 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) {
logger.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) {
logger.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;
const isEnabled = config.commands[command.data.name] !== false;
if (!isEnabled) {
logger.info(`🚫 Skipping disabled command: ${command.data.name}`);
result.skipped++;
continue;
}
this.client.commands.set(command.data.name, command);
logger.success(`Loaded command: ${command.data.name}`);
result.loaded++;
} else {
logger.warn(`Skipping invalid command in ${filePath}`);
result.skipped++;
}
}
} catch (error) {
logger.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 "@lib/types";
import type { LoadResult } from "./types";
import type { Client } from "../BotClient";
import { logger } from "@lib/logger";
/**
* 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) {
logger.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));
}
logger.success(`Loaded event: ${event.name}`);
result.loaded++;
} else {
logger.warn(`Skipping invalid event in ${filePath}`);
result.skipped++;
}
} catch (error) {
logger.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
src/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;
}

39
src/lib/logger.ts Normal file
View File

@@ -0,0 +1,39 @@
/**
* Centralized logging utility with consistent formatting
*/
export const logger = {
/**
* General information message
*/
info: (message: string, ...args: any[]) => {
console.log(` ${message}`, ...args);
},
/**
* Success message
*/
success: (message: string, ...args: any[]) => {
console.log(`${message}`, ...args);
},
/**
* Warning message
*/
warn: (message: string, ...args: any[]) => {
console.warn(`⚠️ ${message}`, ...args);
},
/**
* Error message
*/
error: (message: string, ...args: any[]) => {
console.error(`${message}`, ...args);
},
/**
* Debug message
*/
debug: (message: string, ...args: any[]) => {
console.log(`🔍 ${message}`, ...args);
},
};

56
src/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
src/lib/shutdown.ts Normal file
View File

@@ -0,0 +1,30 @@
import { logger } from "@lib/logger";
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) {
logger.warn(`Shutdown timed out waiting for ${activeTransactions} transactions after ${timeoutMs}ms`);
break;
}
await new Promise(resolve => setTimeout(resolve, 100));
}
};

View File

@@ -1,6 +1,43 @@
import type { ChatInputCommandInteraction, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder, SlashCommandSubcommandsOnlyBuilder } from "discord.js"; import type { AutocompleteInteraction, ChatInputCommandInteraction, ClientEvents, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder, SlashCommandSubcommandsOnlyBuilder } from "discord.js";
import { LootType, EffectType } from "./constants";
import { DrizzleClient } from "./DrizzleClient";
export interface Command { export interface Command {
data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder | SlashCommandSubcommandsOnlyBuilder; data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder | SlashCommandSubcommandsOnlyBuilder;
execute: (interaction: ChatInputCommandInteraction) => Promise<void> | void; execute: (interaction: ChatInputCommandInteraction) => Promise<void> | void;
autocomplete?: (interaction: AutocompleteInteraction) => Promise<void> | void;
category?: string;
} }
export interface Event<K extends keyof ClientEvents> {
name: K;
once?: boolean;
execute: (...args: ClientEvents[K]) => Promise<void> | void;
}
export type ItemEffect =
| { type: EffectType.ADD_XP; amount: number }
| { type: EffectType.ADD_BALANCE; amount: number }
| { type: EffectType.XP_BOOST; multiplier: number; durationSeconds?: number; durationMinutes?: number; durationHours?: number }
| { type: EffectType.TEMP_ROLE; roleId: string; durationSeconds?: number; durationMinutes?: number; durationHours?: number }
| { type: EffectType.REPLY_MESSAGE; message: string }
| { type: EffectType.COLOR_ROLE; roleId: string }
| { type: EffectType.LOOTBOX; pool: LootTableItem[] };
export interface LootTableItem {
type: LootType;
weight: number;
amount?: number; // For CURRENCY, XP
itemId?: number; // For ITEM
minAmount?: number; // Optional range for CURRENCY/XP
maxAmount?: number; // Optional range for CURRENCY/XP
message?: string; // Optional custom message for this outcome
}
export interface ItemUsageData {
consume: boolean;
effects: ItemEffect[];
}
export type DbClient = typeof DrizzleClient;
export type Transaction = Parameters<Parameters<DbClient['transaction']>[0]>[0];

56
src/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("@/lib/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,243 @@
import { type Interaction } from "discord.js";
import { items } from "@/db/schema";
import { DrizzleClient } from "@/lib/DrizzleClient";
import type { ItemUsageData, ItemEffect } from "@/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 "@/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 });
}
}
};

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