336 Commits

Author SHA1 Message Date
syntaxbullet
7cc2f61db6 feat: add item creation tools 2026-02-19 14:40:22 +01:00
syntaxbullet
f5fecb59cb Merge branch 'main' of https://git.ayau.me/syntaxbullet/discord-rpg-concept 2026-02-16 17:22:22 +01:00
syntaxbullet
65f5663c97 feat: implement basic items page, with a placeholder for item creation tool. 2026-02-16 17:22:18 +01:00
de83307adc chore: add newline to readme.md 2026-02-15 14:28:46 +00:00
syntaxbullet
15e01906a3 fix: additional mocks of authentication logic, fix: made path traversal test work with fetch(). 2026-02-15 15:26:46 +01:00
syntaxbullet
fed27c0227 fix: mock authentication logic in server test to ensure tests for protected routes pass. 2026-02-15 15:20:50 +01:00
syntaxbullet
9751e62e30 chore: add citrine task file 2026-02-15 15:18:00 +01:00
syntaxbullet
87d5aa259c feat: add users management page with search, editing, and inventory control
Implements comprehensive user management interface for admin panel:
- Search and filter users by username, class, and active status
- Sort by username, level, balance, or XP with pagination
- View and edit user details (balance, XP, level, class, daily streak, active status)
- Manage user inventories (add/remove items with quantities)
- Debounced search input (300ms delay)
- Responsive design (mobile full-screen, desktop slide-in panel)
- Draft state management with unsaved changes tracking
- Keyboard shortcuts (Escape to close detail panel)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-14 13:15:37 +01:00
syntaxbullet
f0bfaecb0b feat: add settings page with guild config, game settings, and command toggles
Implements the full admin settings page covering all game settings
(leveling, economy, inventory, lootdrops, trivia, moderation, commands)
and guild settings (roles, channels, welcome message, moderation,
feature overrides). Includes role/channel pickers, trivia category
multi-select, and a feature override flag editor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 12:45:23 +01:00
syntaxbullet
9471b6fdab feat: add admin dashboard with sidebar navigation and stats overview
Replace placeholder panel with a full dashboard landing page showing
bot stats, leaderboards, and recent events from /api/stats. Add
sidebar navigation with placeholder pages for Users, Items, Classes,
Quests, Lootdrops, Moderation, Transactions, and Settings. Update
theme to match Aurora design guidelines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 12:23:13 +01:00
syntaxbullet
04e5851387 refactor: rename web/ to api/ to better reflect its purpose
The web/ folder contains the REST API, WebSocket server, and OAuth
routes — not a web frontend. Renaming to api/ clarifies this distinction
since the actual web frontend lives in panel/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 11:37:40 +01:00
1a59c9e796 chore: update prod docker-compose with volume mount for item graphics 2026-02-13 20:16:26 +00:00
syntaxbullet
251616fe15 fix: rename panel asset dir to avoid conflict with bot /assets route
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 21:05:06 +01:00
syntaxbullet
fbb2e0f010 fix: install panel deps in Docker builder stage before build
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 20:53:59 +01:00
syntaxbullet
dc10ad5c37 fix: resolve vite path in Docker build and add OAuth env to prod compose
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 20:53:01 +01:00
syntaxbullet
2381f073ba feat: add admin panel with Discord OAuth and dashboard
Adds a React admin panel (panel/) with Discord OAuth2 login,
live dashboard via WebSocket, and settings/management pages.
Includes Docker build support, Vite proxy config for dev,
game_settings migration, and open-redirect protection on auth callback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 20:27:14 +01:00
syntaxbullet
121c242168 fix: handle permission denied on backup directory
The backups directory may have been created by Docker/root, making it
unwritable by the deploy user. The script now detects this and attempts
to fix permissions automatically (chmod, then sudo chown as fallback).

Also added shared/db/backups to .gitignore.
2026-02-13 14:48:06 +01:00
syntaxbullet
942875e8d0 fix: replace 'source .env' with safe env loader in all scripts
The raw 'source .env' pattern breaks when values contain special bash
characters like ) in passwords or database URLs. This caused deploy:remote
to fail with 'syntax error near unexpected token )'.

Changes:
- Created shared/scripts/lib/load-env.sh: reads .env line-by-line with
  export instead of source, safely handling special characters
- Updated db-backup.sh, db-restore.sh, deploy-remote.sh, remote.sh to
  use the shared loader
- Reordered deploy-remote.sh: git pull now runs first (step 1) so the
  remote always has the latest scripts before running backup (step 2)
2026-02-13 14:46:30 +01:00
syntaxbullet
878e3306eb chore: add missing script aliases and reorganize package.json scripts
Added missing aliases:
- deploy: production deployment script
- deploy:remote: remote VPS deployment
- setup-server: server hardening/provisioning
- test:simulate-ci: local CI simulation with ephemeral Postgres

Reorganized scripts into logical groups:
- Dev (dev, logs, remote)
- Database (db:generate, db:migrate, db:push, db:studio, db:backup, db:restore, migrations)
- Testing (test, test:ci, test:simulate-ci)
- Deployment (deploy, deploy:remote, setup-server)
- Docker (docker:cleanup)

Renamed generate → db:generate, migrate → db:migrate for consistency.
Kept old names as backward-compatible aliases (referenced in AGENTS.md, README.md, docs).
2026-02-13 14:41:32 +01:00
syntaxbullet
aca5538d57 chore: improve DX scripts, fix test suite, and harden tooling
Scripts:
- remote.sh: remove unused open_browser() function
- deploy-remote.sh: add DB backup before deploy, --skip-backup flag, step numbering
- db-backup.sh: fix macOS compat (xargs -r is GNU-only), use portable approach
- db-restore.sh: add safety backup before restore, SQL file validation, file size display
- logs.sh: default to no-follow with --tail=100, order-independent arg parsing
- docker-cleanup.sh: add Docker health check, colored output
- test-sequential.sh: exclude *.integration.test.ts by default, add --integration flag
- simulate-ci.sh: pass --integration flag (has real DB)

Tests:
- db.test.ts: fix mock path from ./DrizzleClient to @shared/db/DrizzleClient
- server.settings.test.ts: rewrite mocks for gameSettingsService (old config/saveConfig removed)
- server.test.ts: add missing config.lootdrop and BotClient mocks, complete DrizzleClient chain
- indexes.test.ts: rename to indexes.integration.test.ts (requires live DB)

Config:
- package.json: test script uses sequential runner, add test:ci and db:restore aliases
- deploy.yml: use --integration flag in CI (has Postgres service)
2026-02-13 14:39:02 +01:00
syntaxbullet
f822d90dd3 refactor: merge dockerfiles 2026-02-13 14:28:43 +01:00
syntaxbullet
141c3098f8 feat: standardize command error handling (Sprint 4)
- Create withCommandErrorHandling utility in bot/lib/commandUtils.ts
- Migrate economy commands: daily, exam, pay, trivia
- Migrate inventory command: use
- Migrate admin/moderation commands: warn, case, cases, clearwarning,
  warnings, note, notes, create_color, listing, webhook, refresh,
  terminal, featureflags, settings, prune
- Add 9 unit tests for the utility
- Update AGENTS.md with new recommended error handling pattern
2026-02-13 14:23:37 +01:00
syntaxbullet
0c67a8754f refactor: Implement Zod schema validation for inventory effect payloads and enhance item route DTO type safety. 2026-02-13 14:12:46 +01:00
syntaxbullet
bf20c61190 chore: exclude tickets from being commited. 2026-02-13 13:53:45 +01:00
syntaxbullet
099601ce6d refactor: convert ModerationService and PruneService from classes to singleton objects
- Convert ModerationService class to moderationService singleton
- Convert PruneService class to pruneService singleton
- Update all command files to use new singleton imports
- Update web routes to use new singleton imports
- Update tests for singleton pattern
- Remove getNextCaseId from tests (now private module function)
2026-02-13 13:33:58 +01:00
syntaxbullet
55d2376ca1 refactor: convert LootdropService from class to singleton object pattern
- Move instance properties to module-level state (channelActivity, channelCooldowns)
- Convert constructor cleanup interval to module-level initialization
- Export state variables for testing
- Update tests to use direct state access instead of (service as any)
- Maintains same behavior while following project service pattern

Closes #4
2026-02-13 13:28:46 +01:00
syntaxbullet
6eb4a32a12 refactor: consolidate config types and remove file-based config
Tickets: #2, #3

- Remove duplicate type definitions from shared/lib/config.ts
- Import types from schema files (game-settings.ts, guild-settings.ts)
- Add GuildConfig interface to guild-settings.ts schema
- Rename ModerationConfig to ModerationCaseConfig in moderation.service.ts
- Delete shared/config/config.json and shared/scripts/migrate-config-to-db.ts
- Update settings API to use gameSettingsService exclusively
- Return DB format (strings) from API instead of runtime BigInts
- Fix moderation service tests to pass config as parameter

Breaking Changes:
- Removes legacy file-based configuration system
- API now returns database format with string values for BigInt fields
2026-02-13 13:24:02 +01:00
syntaxbullet
2d35a5eabb feat: Introduce TimerKey enum and refactor timer key usage across services with new tests. 2026-02-13 13:11:16 +01:00
syntaxbullet
570cdc69c1 fix: call initializeConfig() at startup to load config from database 2026-02-12 16:59:54 +01:00
syntaxbullet
c2b1fb6db1 feat: implement database-backed game settings with a new schema, service, and migration script. 2026-02-12 16:42:40 +01:00
syntaxbullet
d15d53e839 docs: update guild settings documentation with migrated files
List all files that have been updated to use getGuildConfig().
2026-02-12 16:10:59 +01:00
syntaxbullet
58374d1746 refactor: migrate all code to use getGuildConfig() for guild settings
- Update all commands and events to fetch guild config once per execution
- Pass config to service methods that need it (ModerationService.issueWarning)
- Update terminal service to use guildSettingsService for persistence
- Remove direct imports of config for guild-specific settings

This consolidates configuration to database-backed guild settings,
eliminating the dual config system.
2026-02-12 16:09:37 +01:00
syntaxbullet
ae6a068197 docs: add guild settings system documentation
Document guild settings architecture, service layer, admin commands,
API endpoints, and migration strategy from file-based config.
2026-02-12 15:10:58 +01:00
syntaxbullet
43d32918ab feat(api): add guild settings API endpoints
Add REST endpoints for managing per-guild configuration:
- GET /api/guilds/:guildId/settings
- PUT/PATCH /api/guilds/:guildId/settings
- DELETE /api/guilds/:guildId/settings
2026-02-12 15:09:29 +01:00
syntaxbullet
0bc254b728 feat(commands): add /settings admin command for guild configuration
Manage guild settings via Discord with subcommands:
- show: Display current settings
- set: Update individual settings (roles, channels, text, numbers, booleans)
- reset: Clear a setting to default
- colors: Manage color roles (list/add/remove)
2026-02-12 15:04:55 +01:00
syntaxbullet
610d97bde3 feat(scripts): add config migration script for guild settings
Add script to migrate existing config.json values to database with
bun run db:migrate-config command.
2026-02-12 15:02:05 +01:00
syntaxbullet
babccfd08a feat(config): add getGuildConfig() for database-backed guild settings
Add function to fetch guild-specific config from database with:
- 60-second cache TTL
- Fallback to file-based config for migration period
- Cache invalidation helper
2026-02-12 15:00:21 +01:00
syntaxbullet
ee7d63df3e feat(service): add guild settings service layer
Implement service for managing per-guild configuration with methods for
getting, upserting, updating, and deleting settings. Includes helpers
for color role management.
2026-02-12 14:58:41 +01:00
syntaxbullet
5f107d03a7 feat(db): add guild_settings table for per-guild configuration
Store guild-specific settings (roles, channels, moderation options) in
database instead of config file, enabling per-guild configuration and
runtime updates without redeployment.
2026-02-12 14:57:24 +01:00
syntaxbullet
1ff24b0f7f docs: add feature flags system documentation
Document feature flag architecture, usage, admin commands, and best practices for beta testing features in production.
2026-02-12 14:54:51 +01:00
syntaxbullet
a5e3534260 feat(commands): add /featureflags admin command
Add comprehensive feature flag management with subcommands:
- list: Show all feature flags
- create/delete: Manage flags
- enable/disable: Toggle flags
- grant/revoke: Manage access for users/roles/guilds
- access: View access records for a flag
2026-02-12 14:50:36 +01:00
syntaxbullet
228005322e feat(commands): add beta feature flag support to command system
- Add beta and featureFlag properties to Command interface
- Add beta access check in CommandHandler before command execution
- Show beta feature message to non-whitelisted users
2026-02-12 14:45:58 +01:00
syntaxbullet
67a3aa4b0f feat(service): add feature flags service layer
Implement service for managing feature flags and access control with
methods for checking access, creating/enabling flags, and managing
whitelisted users/guilds/roles.
2026-02-12 14:43:11 +01:00
syntaxbullet
64804f7066 feat(db): add feature flags schema for beta feature testing
Add feature_flags and feature_flag_access tables to support controlled
beta testing of new features in production without a separate test environment.
2026-02-12 14:41:12 +01:00
syntaxbullet
73ad889018 docs: update documentation to reflect headless API-only web service
- AGENTS.md: Update project description from web dashboard to REST API

- README.md: Replace Web Dashboard section with REST API, update tech stack

- docs/main.md: Refactor Web Dashboard section to REST API documentation

- web/README.md: Rewrite from React setup to API endpoint documentation

All React/UI references removed - web is now API-only
2026-02-12 12:30:43 +01:00
syntaxbullet
9c7f1e4418 chore(deps): remove unused React/UI dependencies from headless web API
- Remove 31 unused packages: React, Tailwind, Radix UI, etc.

- Clean up web/tsconfig.json (remove JSX, DOM lib)

- Remove old web/dist/ build artifacts

Web dashboard is now API-only, no UI dependencies needed
2026-02-12 12:26:37 +01:00
syntaxbullet
efb50916b2 docs: update CI workflow and AGENTS.md for consolidated deps
Update references to removed web/package.json:
- CI workflow: Remove 'cd web && bun install' step
- AGENTS.md: Remove web-specific dev commands (cd web && bun run dev/build)
- AGENTS.md: Update schema location to reflect domain module split
- Add Docker commands as recommended local dev approach

All dependencies now installed from root package.json.
2026-02-12 12:21:37 +01:00
syntaxbullet
6abb52694e chore(web): remove package.json and bun.lock
Remove web/package.json and web/bun.lock now that all dependencies
are consolidated in root package.json. The web/node_modules directory
will be cleaned up separately (permission restrictions).

Web dashboard now uses dependencies from root node_modules.
2026-02-12 12:20:09 +01:00
syntaxbullet
76968e31a6 refactor(deps): merge web dependencies into root package.json
Move all web dashboard dependencies from web/package.json into root:
- React 19 + React Router 7
- Radix UI components (14 packages)
- Tailwind CSS v4 + bun-plugin-tailwind
- Recharts, React Hook Form, Zod validation
- Dev dependencies: @types/react, @types/react-dom, tailwindcss

This fixes a production issue where web dependencies weren't being
installed in Dockerfile.prod, even though bot/index.ts imports from
web/src/server at runtime.

VPS deployments using Dockerfile.prod will now have all required
dependencies in a single node_modules.
2026-02-12 12:19:51 +01:00
syntaxbullet
29bf0e6f1c refactor(docker): remove duplicate production stage from Dockerfile
Remove the 'production' stage from Dockerfile that was:
- Duplicating functionality already in Dockerfile.prod
- Incorrectly running 'bun run dev' instead of production command

VPS deployments continue to use Dockerfile.prod as the single
source of truth for production builds. Development Dockerfile
now only contains development stage.
2026-02-12 12:19:02 +01:00
syntaxbullet
8c306fbd23 refactor(inventory): flatten effects directory structure
Move effect handlers from effects/ subdirectory to flat structure:
- effects/handlers.ts → effect.handlers.ts
- effects/registry.ts → effect.registry.ts
- effects/types.ts → effect.types.ts

Update import path in inventory.service.ts from
'@/modules/inventory/effects/registry' to
'@/modules/inventory/effect.registry'.

This reduces directory nesting and follows the convention of
keeping module files flat unless there are 5+ files.
2026-02-12 12:15:17 +01:00
syntaxbullet
b0c3baf5b7 refactor(db): split schema into domain modules
Split the 276-line schema.ts into focused domain modules:
- users.ts: classes, users, userTimers (core identity)
- inventory.ts: items, inventory (item system)
- economy.ts: transactions, itemTransactions (currency flow)
- quests.ts: quests, userQuests (quest system)
- moderation.ts: moderationCases, lootdrops (moderation)

Original schema.ts now re-exports from schema/index.ts for backward
compatibility. All existing imports continue to work.
2026-02-12 12:14:15 +01:00
syntaxbullet
f575588b9a feat(db): export all schema types
Add missing type exports for Class, ItemTransaction, Quest,
UserQuest, UserTimer, and Lootdrop tables. All tables now
have consistent type exports available for import.
2026-02-12 12:12:49 +01:00
syntaxbullet
553b9b4952 feat: Implement a new API routing system by adding dedicated route files for users, transactions, assets, items, quests, and other game entities, and integrating them into the server. 2026-02-08 18:57:42 +01:00
syntaxbullet
073348fa55 feat: implement lootdrop management endpoints and fix class api types 2026-02-08 16:56:34 +01:00
syntaxbullet
4232674494 feat: implement user inventory management and class update endpoints 2026-02-08 16:55:04 +01:00
syntaxbullet
fbf1e52c28 test: add deepMerge mock to fix test isolation
Add deepMerge to @shared/lib/utils mocks in both test files to ensure
consistent behavior when tests run together.
2026-02-08 16:42:02 +01:00
syntaxbullet
20284dc57b build(docker): remove web frontend build dependencies
- Remove web package.json install steps from Dockerfiles
- Remove web/dist copy from production build
- Remove web_node_modules volume from docker-compose
2026-02-08 16:41:56 +01:00
syntaxbullet
36f9c76fa9 refactor(web): convert server to API-only mode
- Remove build process spawning for frontend bundler
- Remove SPA fallback and static file serving
- Return 404 for unknown routes instead of serving index.html
- Keep all REST API endpoints and WebSocket functionality
2026-02-08 16:41:47 +01:00
syntaxbullet
46e95ce7b3 refactor(web): remove frontend dashboard files
Delete all React components, pages, hooks, contexts, styles, and build scripts.
The web module now serves as an API-only server.
2026-02-08 16:41:40 +01:00
syntaxbullet
9acd3f3d76 docs: add API reference documentation 2026-02-08 16:41:31 +01:00
syntaxbullet
5e8683a19f feat: Implement structured lootbox results with image support and display referenced items in shop listings. 2026-02-08 16:07:13 +01:00
syntaxbullet
ee088ad84b feat: Increase maximum image upload size from 2MB to 15MB. 2026-02-06 13:48:43 +01:00
syntaxbullet
b18b5fab62 feat: Allow direct icon upload when updating an item in the item form. 2026-02-06 13:37:19 +01:00
syntaxbullet
0b56486ab2 fix(docker): add web network to studio to allow port exposure 2026-02-06 13:14:24 +01:00
syntaxbullet
11c589b01c chore: stop opening browser automatically when connecting to remote 2026-02-06 13:11:16 +01:00
syntaxbullet
e4169d9dd5 chore: add studio service to production compose 2026-02-06 13:10:01 +01:00
syntaxbullet
1929f0dd1f refactor: Abbreviate item rarity values from full names to single-letter codes across the application. 2026-02-06 13:00:41 +01:00
syntaxbullet
db4e7313c3 feat: Add support for local asset URLs for shop item icons and images, attaching them to Discord messages. 2026-02-06 12:52:15 +01:00
syntaxbullet
1ffe397fbb feat: Add image cropping functionality with a new component, dialog, and canvas utilities. 2026-02-06 12:45:09 +01:00
syntaxbullet
34958aa220 feat: implement comprehensive item management system with admin UI, API, and asset handling utilities. 2026-02-06 12:19:14 +01:00
syntaxbullet
109b36ffe2 chore: bump version, add deployment script 2026-02-05 13:05:07 +01:00
syntaxbullet
cd954afe36 chore: improve dev experience via docker override, and remove redundant commands. 2026-02-05 12:57:20 +01:00
syntaxbullet
2b60883173 ci: remove build and deploy jobs from the deploy workflow. 2026-01-31 14:42:10 +01:00
syntaxbullet
c2d67d7435 fix: Explicitly bind web server to 127.0.0.1 in tests and prevent the development build process from running in the test environment. 2026-01-31 14:30:44 +01:00
syntaxbullet
e252d6e00a fix: Install web subdirectory dependencies, set NODE_ENV for tests, and standardize hostname in server tests. 2026-01-30 16:46:16 +01:00
syntaxbullet
95f1b4e04a ci: Update test database host in deployment workflow and add support for running specific tests in the CI simulation script. 2026-01-30 16:42:59 +01:00
syntaxbullet
62c6ca5e87 fix: Replace localhost with 127.0.0.1 in database connection URLs within CI/deployment scripts. 2026-01-30 16:34:14 +01:00
syntaxbullet
aac9be19f2 feat: Add a script to simulate CI locally by setting up a temporary PostgreSQL database, running tests, and updating dependencies. 2026-01-30 16:30:26 +01:00
syntaxbullet
bb823c86c1 refactor: update database index tests to use DrizzleClient.execute for raw SQL queries. 2026-01-30 16:22:29 +01:00
syntaxbullet
119301f1c3 refactor: mock DrizzleClient and external dependencies in trivia service tests. 2026-01-30 16:17:00 +01:00
syntaxbullet
9a2fc101da chore: Enhance database debugging setup and expand test mocks for Drizzle queries and Discord API interactions. 2026-01-30 16:12:15 +01:00
syntaxbullet
7049cbfd9d build: Add step to create a default config.json file during deployment. 2026-01-30 15:47:57 +01:00
syntaxbullet
db859e8f12 feat: Configure CI tests with a dedicated PostgreSQL service and environment variables. 2026-01-30 15:41:34 +01:00
syntaxbullet
5ff3fa9ab5 feat: Implement a sequential test runner script and integrate it into the deploy workflow. 2026-01-30 15:34:59 +01:00
syntaxbullet
c8bf69a969 Remove the admin update service, command, and related files, and update Docker configurations. 2026-01-30 15:29:50 +01:00
syntaxbullet
fee4969910 feat: configure dedicated bot SSH key and non-interactive SSH for git operations. 2026-01-30 15:26:07 +01:00
syntaxbullet
dabcb4cab3 feat: Mount SSH keys for Git authentication and disable interactive prompts in the update service. 2026-01-30 15:21:41 +01:00
syntaxbullet
1a3f5c6654 feat: Introduce scripts for database backup, restore, and log viewing, replacing remote dashboard and studio scripts. 2026-01-30 15:15:22 +01:00
syntaxbullet
422db6479b feat: Store update restart context in the deployment directory and configure Docker to use the default bun user. 2026-01-30 15:06:32 +01:00
syntaxbullet
35ecea16f7 feat: Enable Git operations within a specified deployment directory by adding cwd options and configuring Git to trust the deploy directory. 2026-01-30 14:56:29 +01:00
syntaxbullet
9ff679ee5c feat: Introduce Docker socket proxy and install Docker CLI in the app container for secure deployment operations. 2026-01-30 14:46:06 +01:00
syntaxbullet
ebefd8c0df feat: add bot-triggered deployment via /update deploy command
- Added Docker socket mount to docker-compose.prod.yml
- Added project directory mount for git operations
- Added performDeploy, isDeployAvailable methods to UpdateService
- Added /update deploy subcommand for Discord-triggered deployments
- Added deploy-related embeds to update.view.ts
2026-01-30 14:26:38 +01:00
syntaxbullet
73531f38ae docs: clarify update command behavior in production Docker environment 2026-01-30 14:18:45 +01:00
syntaxbullet
5a6356d271 fix: include web/src in production Dockerfile for direct TS imports 2026-01-30 14:15:30 +01:00
syntaxbullet
f9dafeac3b Merge branch 'main' of https://git.ayau.me/syntaxbullet/discord-rpg-concept 2026-01-30 13:44:04 +01:00
syntaxbullet
1a2bbb011c feat: Introduce production Docker and CI/CD setup, removing internal documentation and agent workflows. 2026-01-30 13:43:59 +01:00
syntaxbullet
2ead35789d fix: prevent studio service from inheriting port 3000 from app 2026-01-23 13:58:37 +01:00
syntaxbullet
c1da71227d chore: update cleanup scripts 2026-01-23 13:47:48 +01:00
syntaxbullet
17e636c4e5 feat: Overhaul Docker infrastructure with multi-stage builds, add a cleanup script, and refactor the update service to combine update and requirement checks. 2026-01-17 16:20:33 +01:00
syntaxbullet
d7543d9f48 feat: (web) add item route 2026-01-17 13:11:50 +01:00
syntaxbullet
afe82c449b feat: add web asset rebuilding to update command and consolidate post-restart messages
- Detect web/src/** changes and trigger frontend rebuild after updates
- Add buildWebAssets flag to RestartContext and needsWebBuild to UpdateCheckResult
- Consolidate post-restart progress into single editable message
- Delete progress message after completion, show only final result
2026-01-16 16:37:11 +01:00
syntaxbullet
3c1334b30e fix: update sub-navigation item colors for active, hover, and default states 2026-01-16 16:27:23 +01:00
syntaxbullet
58f261562a feat: Implement an admin quest management table, enhance toast notifications with descriptions, and add new agent documentation. 2026-01-16 15:58:48 +01:00
syntaxbullet
4ecbffd617 refactor: replace hardcoded SVGs with lucide-react icons in quest-table 2026-01-16 15:27:15 +01:00
syntaxbullet
5491551544 fix: (web) prevent flickering during refresh
- Track isInitialLoading separately from isRefreshing
- Only show skeleton on initial page load (when quests is empty)
- During refresh, keep existing content visible
- Spinning refresh icon indicates refresh in progress without clearing table
2026-01-16 15:22:28 +01:00
syntaxbullet
7d658bbef9 fix: (web) fix refresh icon spinning indefinitely
- Remove redundant isRefreshing state
- Icon spin is controlled by isLoading prop from parent
- Parent correctly manages loading state during fetch
2026-01-16 15:20:36 +01:00
syntaxbullet
d117bcb697 fix: (web) restore quest table loading logic
- Simplify component by removing complex state management
- Show skeleton only during initial load, content otherwise
- Keep refresh icon spin during manual refresh
2026-01-16 15:18:51 +01:00
syntaxbullet
94e332ba57 fix: (web) improve quest table refresh UX
- Keep card visible during refresh to prevent flicker
- Add smooth animations when content loads
- Spin refresh icon independently from skeleton
- Show skeleton in place without replacing entire card
2026-01-16 15:16:48 +01:00
syntaxbullet
3ef9773990 feat: (web) add quest table component for admin quests page
- Add getAllQuests() method to quest.service.ts
- Add GET /api/quests endpoint to server.ts
- Create QuestTable component with data display, formatting, and states
- Update AdminQuests.tsx to fetch and display quests above the form
- Add onSuccess callback to QuestForm for refresh handling
2026-01-16 15:12:41 +01:00
syntaxbullet
d243a11bd3 feat: (docs) add main.md 2026-01-16 13:34:35 +01:00
syntaxbullet
47ce0f12e6 chore: remove old documentation. 2026-01-16 13:18:54 +01:00
syntaxbullet
f2caa1a3ee chore: replace tw-gradient classes with canonical shortened -linear classnames 2026-01-16 12:59:32 +01:00
syntaxbullet
2a72beb0ef feat: Implement new settings pages and refactor application layout and navigation with new components and hooks. 2026-01-16 12:49:17 +01:00
syntaxbullet
2f73f38877 feat: Add web admin page for quest management and refactor Discord bot's quest UI to use new components. 2026-01-15 17:21:49 +01:00
syntaxbullet
9e5c6b5ac3 feat: Implement interactive quest command allowing users to view active/available quests and accept new ones. 2026-01-15 15:30:01 +01:00
syntaxbullet
eb108695d3 feat: Implement flexible quest event matching to allow generic triggers to match specific event instances. 2026-01-15 15:22:20 +01:00
syntaxbullet
7d541825d8 feat: Update quest event triggers to include item IDs for granular tracking. 2026-01-15 15:09:37 +01:00
syntaxbullet
52f8ab11f0 feat: Implement quest event handling and integrate it into leveling, economy, and inventory services. 2026-01-15 15:04:50 +01:00
syntaxbullet
f8436e9755 chore: (agent) remove tickets and skills 2026-01-15 11:13:37 +01:00
syntaxbullet
194a032c7f chore(cleanup): remove completed tickets 2026-01-14 18:10:31 +01:00
syntaxbullet
94a5a183d0 feat(economy): refactor exam command to use ExamService with status-based flow and full test coverage 2026-01-14 18:10:13 +01:00
syntaxbullet
c7730b9355 refactor: migrate web server to centralized logger 2026-01-14 17:58:28 +01:00
syntaxbullet
1e20a5a7a0 refactor: migrate bot handlers to centralized logger 2026-01-14 17:58:28 +01:00
syntaxbullet
54944283a3 feat: implement centralized logger with file persistence 2026-01-14 17:58:28 +01:00
syntaxbullet
f79ee6fbc7 refactor: remove completed ticket file 2026-01-14 16:27:49 +01:00
syntaxbullet
915f1bc4ad fix(economy): improve daily cooldown message and consolidate UserError class 2026-01-14 16:26:27 +01:00
syntaxbullet
4af2690bab feat: implement branded discord embeds and versioning 2026-01-14 16:10:23 +01:00
syntaxbullet
6e57ab07e4 chore: update gitiignore 2026-01-14 15:12:51 +01:00
syntaxbullet
3a620a84c5 feat: add trivia category selection and sync trivia fixes 2026-01-11 16:08:11 +01:00
syntaxbullet
7d68652ea5 fix: fix potential issues with trivia command 2026-01-11 15:00:10 +01:00
syntaxbullet
35bd1f58dd feat: trivia command! 2026-01-11 14:37:17 +01:00
syntaxbullet
1cd3dbcd72 agent: update agent workflows 2026-01-09 22:04:40 +01:00
syntaxbullet
c97249f2ca docs: update README with dashboard architecture and ssh tunnel guide 2026-01-09 22:02:09 +01:00
syntaxbullet
0d923491b5 feat: (ui) settings drawers 2026-01-09 19:28:14 +01:00
syntaxbullet
d870ef69d5 feat: (ui) leaderboards 2026-01-09 16:45:36 +01:00
syntaxbullet
682e9d208e feat: more stat components 2026-01-09 16:18:52 +01:00
syntaxbullet
4a691ac71d feat: (ui) first dynamic data 2026-01-09 15:22:13 +01:00
syntaxbullet
1b84dbd36d feat: (ui) new design 2026-01-09 15:12:35 +01:00
syntaxbullet
a5b8d922e3 feat(web): implement full activity page with charts and logs 2026-01-08 23:20:00 +01:00
syntaxbullet
238d9a8803 refactor(web): enhance ui visual polish and ux
- Replace native selects with Shadcn UI Select in Settings
- Increase ActivityChart height for better visibility
- specific Economy Overview card height to fill column
- Add hover/active scale animations to sidebar items
2026-01-08 23:10:14 +01:00
syntaxbullet
713ea07040 feat(ui): use shadcn switch for toggles and remove sidebar user footer 2026-01-08 23:00:44 +01:00
syntaxbullet
bea6c33024 feat(settings): group commands by category in system tab 2026-01-08 22:55:40 +01:00
syntaxbullet
8fe300c8a2 feat(web): add toast notifications for settings save status 2026-01-08 22:47:31 +01:00
syntaxbullet
9caa95a0d8 feat(settings): support toggling disabled commands and auto-reload bot on save 2026-01-08 22:44:48 +01:00
syntaxbullet
c6fd23b5fa feat(dashboard): implement bot settings page with partial updates and serialization fixes 2026-01-08 22:35:46 +01:00
syntaxbullet
d46434de18 feat(dashboard): expand stats & remove admin token auth 2026-01-08 22:14:13 +01:00
syntaxbullet
cf4c28e1df fix : 404 error fix 2026-01-08 21:45:53 +01:00
syntaxbullet
39e405afde chore: polish analytics API logging and typing 2026-01-08 21:39:53 +01:00
syntaxbullet
6763e3c543 fix: address code review findings for analytics and security 2026-01-08 21:39:01 +01:00
syntaxbullet
11e07a0068 feat: implement visual analytics and activity charts 2026-01-08 21:36:19 +01:00
syntaxbullet
5d2d4bb0c6 refactor: improve type safety and remove forced casts in dashboard service 2026-01-08 21:31:40 +01:00
syntaxbullet
19206b5cc7 fix: address security review findings, implement real cache clearing, and fix lifecycle promises 2026-01-08 21:29:09 +01:00
syntaxbullet
0f6cce9b6e feat: implement administrative control panel with real-time bot actions 2026-01-08 21:19:16 +01:00
syntaxbullet
3f3a6c88e8 fix(dash): resolve test regressions, await promises, and improve TypeScript strictness 2026-01-08 21:12:41 +01:00
syntaxbullet
8253de9f73 fix(dash): address safety constraints, validation, and test quality issues 2026-01-08 21:08:47 +01:00
syntaxbullet
1251df286e feat: implement real-time dashboard updates via WebSockets 2026-01-08 21:01:33 +01:00
syntaxbullet
fff90804c0 feat(dash): Revamp dashboard UI with glassmorphism and real bot data 2026-01-08 20:58:57 +01:00
syntaxbullet
8ebaf7b4ee docs: update ticket status to In Review with implementation notes 2026-01-08 18:51:58 +01:00
syntaxbullet
17cb70ec00 feat: integrate real data into dashboard
- Created dashboard service with DB queries for users, economy, events
- Added client stats provider with 30s caching for Discord metrics
- Implemented /api/stats endpoint aggregating all dashboard data
- Created useDashboardStats React hook with auto-refresh
- Updated Dashboard.tsx to display real data with loading/error states
- Added comprehensive test coverage (11 tests passing)
- Replaced all mock values with live Discord and database metrics
2026-01-08 18:50:44 +01:00
syntaxbullet
a207d511be docs: clarify drizzle studio access via proxy URL 2026-01-08 18:20:27 +01:00
syntaxbullet
cf4f180124 fix: add web network to studio for port publishing 2026-01-08 18:17:27 +01:00
syntaxbullet
5df1396b3f chore: update docker compose 2026-01-08 18:12:39 +01:00
syntaxbullet
daad7be01c chore: attempt fixing drizzle studio 2026-01-08 18:04:40 +01:00
syntaxbullet
05f27ca604 refactor: fix frontend 2026-01-08 17:01:36 +01:00
syntaxbullet
d37059d50f chore: remove tickets from future commits 2026-01-08 16:45:49 +01:00
syntaxbullet
caafe6b34d refactor: update graphics paths 2026-01-08 16:42:14 +01:00
syntaxbullet
017f5ad818 refactor: fix stale imports 2026-01-08 16:39:34 +01:00
syntaxbullet
f92415b89c refactor: move drizzle to shared 2026-01-08 16:29:31 +01:00
syntaxbullet
3f028eb76a refactor: consolidate config loading 2026-01-08 16:21:25 +01:00
syntaxbullet
2b641c952d refactor: move config loading to shared directory 2026-01-08 16:15:55 +01:00
syntaxbullet
88b266f81b refactor: initial moves 2026-01-08 16:09:26 +01:00
syntaxbullet
53a2f1ff0c chore: combine processes 2026-01-08 15:13:09 +01:00
syntaxbullet
dc15212ecf web: mock dashboard 2026-01-08 14:49:59 +01:00
syntaxbullet
99e847175e chore: remove frontend boilerplate 2026-01-08 14:26:16 +01:00
syntaxbullet
b2c7fa6e83 feat: improvements to update command 2026-01-08 14:13:24 +01:00
syntaxbullet
9e7f18787b feat: improvements to web dashboard 2026-01-08 13:56:25 +01:00
47507dd65a Merge pull request 'added react app' (#4) from HotPlate/discord-rpg-concept:reactApp into main
Reviewed-on: syntaxbullet/AuroraBot-discord#4
2026-01-08 11:51:18 +00:00
Vraj Ved
e6f94c3e71 added react app 2026-01-08 17:15:28 +05:30
syntaxbullet
66af870aa9 fix: make dashboard locally accessible only 2026-01-07 14:33:19 +01:00
syntaxbullet
8047bce755 feat: add bot action controls and real-time vital statistics to the web dashboard 2026-01-07 14:26:37 +01:00
syntaxbullet
9804456257 docs: Remove completed and draft feature tickets from the tickets directory. 2026-01-07 13:49:04 +01:00
syntaxbullet
259b8d6875 feat: replace mock dashboard data with live telemetry 2026-01-07 13:47:02 +01:00
syntaxbullet
a2cb684b71 Merge branch 'feat/web-interface-expansion-mockup' into main 2026-01-07 13:39:41 +01:00
syntaxbullet
9c2098bc46 fix(test): use dynamic port for websocket tests 2026-01-07 13:37:21 +01:00
syntaxbullet
618d973863 feat: expansion of web dashboard with live activity feed and metrics 2026-01-07 13:34:29 +01:00
syntaxbullet
63f55b6dfd feat: implement dashboard mockup and route 2026-01-07 13:29:06 +01:00
syntaxbullet
ac4025e179 feat: implement websocket realtime data streaming 2026-01-07 13:25:41 +01:00
syntaxbullet
ff23f22337 feat: move status to footer and clean up home page 2026-01-07 13:21:36 +01:00
syntaxbullet
292991c605 feat: responsive mobile layout and touch optimizations 2026-01-07 13:08:02 +01:00
syntaxbullet
4640cd11a7 feat: ux enhancements (animations, dynamic backgrounds, micro-interactions) 2026-01-07 13:05:42 +01:00
syntaxbullet
43a003f641 feat: visual design system overhaul (HSL palette, fonts, components) 2026-01-07 13:04:40 +01:00
syntaxbullet
6f4426e49d feat: save progress on web server foundation and add new tickets 2026-01-07 13:02:36 +01:00
syntaxbullet
894cad91a8 feat: Implement secure static file serving with path traversal protection and XSS prevention for template titles. 2026-01-07 12:51:08 +01:00
syntaxbullet
2a1c4e65ae feat(web): implement web server foundation 2026-01-07 12:40:21 +01:00
syntaxbullet
022f748517 feat: implement agent workflows for ticket creation, development, and code review. 2026-01-07 12:12:57 +01:00
syntaxbullet
ca392749e3 refactor: replace cleanup service with focused temp role service and fix daily streaks 2026-01-07 11:04:34 +01:00
syntaxbullet
4a1e72c5f3 chore: add additional stats to terminal 2026-01-06 21:05:51 +01:00
syntaxbullet
d29a1ec2b7 chore: update terminal adding nicer graphics 2026-01-06 20:51:39 +01:00
syntaxbullet
1dd269bf2f chore: update terminal service 2026-01-06 20:36:26 +01:00
syntaxbullet
69186ff3e9 chore: add more options to cleanup command 2026-01-06 19:44:18 +01:00
syntaxbullet
b989e807dc feat: Add /cleanup admin command and enhance lootdrop cleanup service to optionally include claimed items. 2026-01-06 19:27:41 +01:00
syntaxbullet
2e6bdec38c refactor: switch Drizzle ORM from postgres-js to bun-sql driver. 2026-01-06 18:52:25 +01:00
syntaxbullet
a9d5c806ad feat: Migrate Drizzle ORM to postgres.js, exclude test files from command loading, and adjust postgres dependency type. 2026-01-06 18:46:30 +01:00
syntaxbullet
6f73178375 feat: Bind docker-compose database and server ports to localhost. 2026-01-06 18:37:42 +01:00
syntaxbullet
dd62336571 fix(test): resolve typescript undefined errors in inventory service tests 2026-01-06 18:25:18 +01:00
syntaxbullet
8280111b66 feat(inventory): implement item name autocomplete with rarity and case-insensitive search 2026-01-06 18:24:15 +01:00
syntaxbullet
34347f0c63 feat: centralized constants and enums for project-wide use 2026-01-06 18:15:52 +01:00
syntaxbullet
c807fd4fd0 test: fix lint errors in moderation service tests 2026-01-06 18:05:05 +01:00
syntaxbullet
47b980eff1 feat: add moderation unit tests and refactor warning logic 2026-01-06 18:03:36 +01:00
syntaxbullet
bc89ddf7c0 feat: implement scheduled cleanup job for expired data 2026-01-06 17:44:08 +01:00
syntaxbullet
606d83a7ae feat: add health check command and tracking 2026-01-06 17:30:55 +01:00
syntaxbullet
3351295bdc feat: add database indexes for performance optimization 2026-01-06 17:26:34 +01:00
syntaxbullet
92cb048a7a test: fix mock leakage in db tests 2026-01-06 17:22:43 +01:00
syntaxbullet
6ead0c0393 feat: implement graceful shutdown handling 2026-01-06 17:21:50 +01:00
syntaxbullet
278ef4b6b0 fix: Normalize exam and cooldown dates to the start of the day for consistent calculations. 2026-01-05 17:36:53 +01:00
syntaxbullet
9a32ab298d feat: Implement a net worth leaderboard by aggregating user balance and inventory item values. 2026-01-05 16:40:26 +01:00
syntaxbullet
a2596d4124 docs: Add command reference and database schema documentation. 2026-01-05 13:13:46 +01:00
syntaxbullet
fbc8952e0a docs: Add guides for lootbox creation and configuration options. 2026-01-05 13:07:36 +01:00
syntaxbullet
d0b4cb80de feat: Add user existence checks to economy commands and refactor trade service to expose sessions for testing. 2026-01-05 12:57:22 +01:00
syntaxbullet
599684cde8 feat: Add lootbox item type with weighted rewards and dedicated UI for item usage results. 2026-01-05 12:52:34 +01:00
syntaxbullet
5606fb6e2f fix: Clarify daily claim cooldown message for daily claims. 2026-01-05 12:17:23 +01:00
syntaxbullet
fb260c5beb feat: Set daily claim cooldown to next UTC midnight and reset streak to 1 if missed by over 24 hours. 2026-01-05 12:10:41 +01:00
syntaxbullet
a227e5db59 feat: Implement graphical lootdrop cards for lootdrop and claimed messages. 2025-12-24 23:13:16 +01:00
syntaxbullet
66d5145885 docs: add guide for standard module structure patterns 2025-12-24 22:26:14 +01:00
syntaxbullet
2412098536 refactor(modules): standardize error handling in interaction handlers 2025-12-24 22:26:12 +01:00
syntaxbullet
d0c48188b9 refactor(core): centralize interaction error handling and organize routes 2025-12-24 22:26:10 +01:00
syntaxbullet
1523a392c2 refactor: add leveling view layer
Create leveling.view.ts with UI logic extracted from leaderboard command:
- getLeaderboardEmbed() for leaderboard display (XP and Balance)
- getMedalEmoji() helper for ranking medals (🥇🥈🥉)
- formatLeaderEntry() helper for entry formatting with null safety

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

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

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

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

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

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

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

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

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

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

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

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

7
.citrine Normal file
View File

@@ -0,0 +1,7 @@
### Frontend
[8bb0] [>] implement items page
[de51] [ ] implement classes page
[d108] [ ] implement quests page
[8bbe] [ ] implement lootdrops page
[094e] [ ] implement moderation page
[220d] [ ] implement transactions page

39
.dockerignore Normal file
View File

@@ -0,0 +1,39 @@
# Dependencies - handled inside container
node_modules
web/node_modules
# Git
.git
.gitignore
# Logs and data
logs
*.log
shared/db/data
shared/db/log
# Development tools
.env
.env.example
.opencode
.agent
# Documentation
docs
*.md
!README.md
# IDE
.vscode
.idea
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Build artifacts
dist
.cache
*.tsbuildinfo

View File

@@ -1,9 +1,33 @@
DB_USER=kyoko
DB_PASSWORD=kyoko
DB_NAME=kyoko
# =============================================================================
# Aurora Environment Configuration
# =============================================================================
# Copy this file to .env and update with your values
# For production, see .env.prod.example with security recommendations
# =============================================================================
# Database
# For production: use a strong password (openssl rand -base64 32)
DB_USER=aurora
DB_PASSWORD=aurora
DB_NAME=aurora
DB_PORT=5432
DB_HOST=db
DATABASE_URL=postgres://aurora:aurora@db:5432/aurora
# Discord
# Get from: https://discord.com/developers/applications
DISCORD_BOT_TOKEN=your-discord-bot-token
DISCORD_CLIENT_ID=your-discord-client-id
DISCORD_GUILD_ID=your-discord-guild-id
DATABASE_URL=postgres://kyoko:kyoko@db:5432/kyoko
# Admin Panel (Discord OAuth)
# Get client secret from: https://discord.com/developers/applications → OAuth2
DISCORD_CLIENT_SECRET=your-discord-client-secret
SESSION_SECRET=change-me-to-a-random-string
ADMIN_USER_IDS=123456789012345678
PANEL_BASE_URL=http://localhost:3000
# Server (for remote access scripts)
# Use a non-root user (see shared/scripts/setup-server.sh)
VPS_USER=deploy
VPS_HOST=your-vps-ip

38
.env.prod.example Normal file
View File

@@ -0,0 +1,38 @@
# =============================================================================
# Aurora Production Environment Template
# =============================================================================
# Copy this file to .env and fill in the values
# IMPORTANT: Use strong, unique passwords in production!
# =============================================================================
# -----------------------------------------------------------------------------
# Database Configuration
# -----------------------------------------------------------------------------
# Generate strong password: openssl rand -base64 32
DB_USER=aurora_prod
DB_PASSWORD=CHANGE_ME_USE_STRONG_PASSWORD
DB_NAME=aurora_prod
DB_PORT=5432
DB_HOST=localhost
# Constructed database URL (used by Drizzle)
DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@localhost:${DB_PORT}/${DB_NAME}
# -----------------------------------------------------------------------------
# Discord Configuration
# -----------------------------------------------------------------------------
# Get these from Discord Developer Portal: https://discord.com/developers
DISCORD_BOT_TOKEN=your_bot_token_here
DISCORD_CLIENT_ID=your_client_id_here
DISCORD_GUILD_ID=your_guild_id_here
# -----------------------------------------------------------------------------
# Server Configuration (for SSH deployment scripts)
# -----------------------------------------------------------------------------
# Use a non-root user for security!
VPS_USER=deploy
VPS_HOST=your_server_ip_here
# Optional: Custom ports for remote access
# DASHBOARD_PORT=3000
# STUDIO_PORT=4983

6
.env.test Normal file
View File

@@ -0,0 +1,6 @@
DATABASE_URL="postgresql://auroradev:auroradev123@localhost:5432/aurora_test"
DISCORD_BOT_TOKEN="test_token"
DISCORD_CLIENT_ID="123456789"
DISCORD_GUILD_ID="123456789"
ADMIN_TOKEN="admin_token_123"
LOG_LEVEL="error"

100
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,100 @@
# Aurora CI/CD Pipeline
# Builds, tests, and deploys to production server
name: Deploy to Production
on:
push:
branches: [main]
workflow_dispatch: # Allow manual trigger
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# ==========================================================================
# Test Job
# ==========================================================================
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: aurora_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install Dependencies
run: bun install --frozen-lockfile
- name: Create Config File
run: |
mkdir -p shared/config
cat <<EOF > shared/config/config.json
{
"leveling": { "base": 100, "exponent": 2.5, "chat": { "cooldownMs": 60000, "minXp": 15, "maxXp": 25 } },
"economy": {
"daily": { "amount": "100", "streakBonus": "10", "weeklyBonus": "50", "cooldownMs": 86400000 },
"transfers": { "allowSelfTransfer": false, "minAmount": "1" },
"exam": { "multMin": 0.05, "multMax": 0.03 }
},
"inventory": { "maxStackSize": "99", "maxSlots": 50 },
"commands": {},
"lootdrop": {
"activityWindowMs": 120000, "minMessages": 1, "spawnChance": 1, "cooldownMs": 3000,
"reward": { "min": 40, "max": 150, "currency": "Astral Units" }
},
"studentRole": "123", "visitorRole": "456", "colorRoles": [],
"moderation": {
"prune": { "maxAmount": 100, "confirmThreshold": 50, "batchSize": 100, "batchDelayMs": 1000 },
"cases": { "dmOnWarn": false }
},
"trivia": {
"entryFee": "50", "rewardMultiplier": 1.5, "timeoutSeconds": 30, "cooldownMs": 60000,
"categories": [], "difficulty": "random"
},
"system": {}
}
EOF
- name: Setup Test Database
run: bun run db:push:local
env:
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/aurora_test
# Create .env.test for implicit usage by bun
DISCORD_BOT_TOKEN: test_token
DISCORD_CLIENT_ID: 123
DISCORD_GUILD_ID: 123
- name: Run Tests
run: |
# Create .env.test for test-sequential.sh / bun test
cat <<EOF > .env.test
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/aurora_test"
DISCORD_BOT_TOKEN="test_token"
DISCORD_CLIENT_ID="123456789"
DISCORD_GUILD_ID="123456789"
ADMIN_TOKEN="admin_token_123"
LOG_LEVEL="error"
EOF
bash shared/scripts/test-sequential.sh --integration
env:
NODE_ENV: test

16
.gitignore vendored
View File

@@ -1,11 +1,17 @@
.env
node_modules
db-logs
db-data
docker-compose.override.yml
shared/db-logs
shared/db/data
shared/db/backups
shared/db/loga
.cursor
# dependencies (bun install)
node_modules
config/
# output
out
dist
@@ -39,4 +45,8 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
.DS_Store
src/db/data
src/db/log
src/db/log
scratchpad/
bot/assets/graphics/items
tickets/
.citrine.local

257
AGENTS.md Normal file
View File

@@ -0,0 +1,257 @@
# AGENTS.md - AI Coding Agent Guidelines
## Project Overview
AuroraBot is a Discord bot with a REST API built using Bun, Discord.js, and PostgreSQL with Drizzle ORM.
## Build/Lint/Test Commands
```bash
# Development
bun --watch bot/index.ts # Run bot + API server with hot reload
# Testing
bun test # Run all tests
bun test path/to/file.test.ts # Run single test file
bun test --watch # Watch mode
bun test shared/modules/economy # Run tests in directory
# Database
bun run generate # Generate Drizzle migrations (Docker)
bun run migrate # Run migrations (Docker)
bun run db:push # Push schema changes (Docker)
bun run db:push:local # Push schema changes (local)
bun run db:studio # Open Drizzle Studio
# Docker (recommended for local dev)
docker compose up # Start bot, API, and database
docker compose up app # Start just the app (bot + API)
docker compose up db # Start just the database
```
## Project Structure
```
bot/ # Discord bot
├── commands/ # Slash commands by category
├── events/ # Discord event handlers
├── lib/ # Bot core (BotClient, handlers, loaders)
├── modules/ # Feature modules (views, interactions)
└── graphics/ # Canvas image generation
shared/ # Shared between bot and web
├── db/ # Database schema and migrations
├── lib/ # Utils, config, errors, types
└── modules/ # Domain services (economy, user, etc.)
web/ # API server
└── src/routes/ # API route handlers
```
## Import Conventions
Use path aliases defined in tsconfig.json:
```typescript
// External packages first
import { SlashCommandBuilder } from "discord.js";
import { eq } from "drizzle-orm";
// Path aliases second
import { economyService } from "@shared/modules/economy/economy.service";
import { UserError } from "@shared/lib/errors";
import { users } from "@db/schema";
import { createErrorEmbed } from "@lib/embeds";
import { handleTradeInteraction } from "@modules/trade/trade.interaction";
// Relative imports last
import { localHelper } from "./helper";
```
**Available Aliases:**
- `@/*` - bot/
- `@shared/*` - shared/
- `@db/*` - shared/db/
- `@lib/*` - bot/lib/
- `@modules/*` - bot/modules/
- `@commands/*` - bot/commands/
## Naming Conventions
| Element | Convention | Example |
| ---------------- | ----------------------- | ---------------------------------------- |
| Files | camelCase or kebab-case | `BotClient.ts`, `economy.service.ts` |
| Classes | PascalCase | `CommandHandler`, `UserError` |
| Functions | camelCase | `createCommand`, `handleShopInteraction` |
| Constants | UPPER_SNAKE_CASE | `EVENTS`, `BRANDING` |
| Enums | PascalCase | `TimerType`, `TransactionType` |
| Services | camelCase singleton | `economyService`, `userService` |
| Types/Interfaces | PascalCase | `Command`, `Event`, `GameConfigType` |
| DB tables | snake_case | `users`, `moderation_cases` |
| Custom IDs | snake_case with prefix | `shop_buy_`, `trade_accept_` |
## Code Patterns
### Command Definition
```typescript
export const commandName = createCommand({
data: new SlashCommandBuilder()
.setName("commandname")
.setDescription("Description"),
execute: async (interaction) => {
await interaction.deferReply();
// Implementation
},
});
```
### Service Pattern (Singleton Object)
```typescript
export const serviceName = {
methodName: async (params: ParamType): Promise<ReturnType> => {
return await withTransaction(async (tx) => {
// Database operations
});
},
};
```
### Module File Organization
- `*.view.ts` - Creates Discord embeds/components
- `*.interaction.ts` - Handles button/select/modal interactions
- `*.types.ts` - Module-specific TypeScript types
- `*.service.ts` - Business logic (in shared/modules/)
- `*.test.ts` - Test files (co-located with source)
## Error Handling
### Custom Error Classes
```typescript
import { UserError, SystemError } from "@shared/lib/errors";
// User-facing errors (shown to user)
throw new UserError("You don't have enough coins!");
// System errors (logged, generic message shown)
throw new SystemError("Database connection failed");
```
### Recommended: `withCommandErrorHandling`
Use the `withCommandErrorHandling` utility from `@lib/commandUtils` to standardize
error handling across all commands. It handles `deferReply`, `UserError` display,
and unexpected error logging automatically.
```typescript
import { withCommandErrorHandling } from "@lib/commandUtils";
export const myCommand = createCommand({
data: new SlashCommandBuilder()
.setName("mycommand")
.setDescription("Does something"),
execute: async (interaction) => {
await withCommandErrorHandling(
interaction,
async () => {
const result = await service.method();
await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
},
{ ephemeral: true } // optional: makes the deferred reply ephemeral
);
},
});
```
Options:
- `ephemeral` — whether `deferReply` should be ephemeral
- `successMessage` — a simple string to send on success
- `onSuccess` — a callback invoked with the operation result
```
## Database Patterns
### Transaction Usage
```typescript
import { withTransaction } from "@/lib/db";
return await withTransaction(async (tx) => {
const user = await tx.query.users.findFirst({
where: eq(users.id, discordId),
});
await tx
.update(users)
.set({ coins: newBalance })
.where(eq(users.id, discordId));
await tx.insert(transactions).values({ userId: discordId, amount, type });
return user;
}, existingTx); // Pass existing tx if in nested transaction
```
### Schema Notes
- Use `bigint` mode for Discord IDs and currency amounts
- Relations defined separately from table definitions
- Schema modules: `shared/db/schema/*.ts` (users, inventory, economy, quests, moderation)
## Testing
### Test File Structure
```typescript
import { describe, it, expect, mock, beforeEach } from "bun:test";
// Mock modules BEFORE imports
mock.module("@shared/db/DrizzleClient", () => ({
DrizzleClient: { query: mockQuery },
}));
describe("serviceName", () => {
beforeEach(() => {
mockFn.mockClear();
});
it("should handle expected case", async () => {
// Arrange
mockFn.mockResolvedValue(testData);
// Act
const result = await service.method(input);
// Assert
expect(result).toEqual(expected);
expect(mockFn).toHaveBeenCalledWith(expectedArgs);
});
});
```
## Tech Stack
- **Runtime:** Bun 1.0+
- **Bot:** Discord.js 14.x
- **Web:** Bun HTTP Server (REST API)
- **Database:** PostgreSQL 16+ with Drizzle ORM
- **UI:** Discord embeds and components
- **Validation:** Zod
- **Testing:** Bun Test
- **Container:** Docker
## Key Files Reference
| Purpose | File |
| ------------- | ---------------------- |
| Bot entry | `bot/index.ts` |
| DB schema | `shared/db/schema.ts` |
| Error classes | `shared/lib/errors.ts` |
| Config loader | `shared/lib/config.ts` |
| Environment | `shared/lib/env.ts` |
| Embed helpers | `bot/lib/embeds.ts` |
| Command utils | `shared/lib/utils.ts` |
| Error handler | `bot/lib/commandUtils.ts` |

187
CLAUDE.md Normal file
View File

@@ -0,0 +1,187 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
# Development
bun --watch bot/index.ts # Run bot + API with hot reload
docker compose up # Start all services (bot, API, database)
docker compose up app # Start just the app (bot + API)
docker compose up db # Start just the database
# Testing
bun test # Run all tests
bun test path/to/file.test.ts # Run a single test file
bun test shared/modules/economy # Run tests in a directory
bun test --watch # Watch mode
# Database
bun run db:push:local # Push schema changes (local)
bun run db:studio # Open Drizzle Studio (localhost:4983)
bun run generate # Generate Drizzle migrations (Docker)
bun run migrate # Apply migrations (Docker)
# Admin Panel
bun run panel:dev # Start Vite dev server for dashboard
bun run panel:build # Build React dashboard for production
```
## Architecture
Aurora is a Discord RPG bot + REST API running as a **single Bun process**. The bot and API share the same database client and services.
```
bot/ # Discord bot
├── commands/ # Slash commands by category (admin, economy, inventory, etc.)
├── events/ # Discord event handlers
├── lib/ # BotClient, handlers, loaders, embed helpers, commandUtils
├── modules/ # Feature modules (views, interactions per domain)
└── graphics/ # Canvas-based image generation (@napi-rs/canvas)
shared/ # Shared between bot and API
├── db/ # Drizzle ORM client + schema (users, economy, inventory, quests, etc.)
├── lib/ # env, config, errors, logger, types, utils
└── modules/ # Domain services (economy, user, inventory, quest, moderation, etc.)
api/ # REST API (Bun HTTP server)
└── src/routes/ # Route handlers for each domain
panel/ # React admin dashboard (Vite + Tailwind + Radix UI)
```
**Key architectural details:**
- Bot and API both import from `shared/` — do not duplicate logic.
- Services in `shared/modules/` are singleton objects, not classes.
- The database uses PostgreSQL 16+ via Drizzle ORM with `bigint` mode for Discord IDs and currency.
- Feature modules follow a strict file suffix convention (see below).
## Import Conventions
Use path aliases (defined in `tsconfig.json`). Order: external packages → aliases → relative.
```typescript
import { SlashCommandBuilder } from "discord.js"; // external
import { economyService } from "@shared/modules/economy/economy.service"; // alias
import { users } from "@db/schema"; // alias
import { createErrorEmbed } from "@lib/embeds"; // alias
import { localHelper } from "./helper"; // relative
```
**Aliases:**
- `@/*``bot/`
- `@shared/*``shared/`
- `@db/*``shared/db/`
- `@lib/*``bot/lib/`
- `@modules/*``bot/modules/`
- `@commands/*``bot/commands/`
## Code Patterns
### Module File Suffixes
- `*.view.ts` — Creates Discord embeds/components
- `*.interaction.ts` — Handles button/select/modal interactions
- `*.service.ts` — Business logic (lives in `shared/modules/`)
- `*.types.ts` — Module-specific TypeScript types
- `*.test.ts` — Tests (co-located with source)
### Command Definition
```typescript
export const commandName = createCommand({
data: new SlashCommandBuilder().setName("name").setDescription("desc"),
execute: async (interaction) => {
await withCommandErrorHandling(interaction, async () => {
const result = await service.method();
await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
}, { ephemeral: true });
},
});
```
`withCommandErrorHandling` (from `@lib/commandUtils`) handles `deferReply`, `UserError` display, and unexpected error logging automatically.
### Service Pattern
```typescript
export const serviceName = {
methodName: async (params: ParamType): Promise<ReturnType> => {
return await withTransaction(async (tx) => {
// database operations
});
},
};
```
### Error Handling
```typescript
import { UserError, SystemError } from "@shared/lib/errors";
throw new UserError("You don't have enough coins!"); // shown to user
throw new SystemError("DB connection failed"); // logged, generic message shown
```
### Database Transactions
```typescript
import { withTransaction } from "@/lib/db";
return await withTransaction(async (tx) => {
const user = await tx.query.users.findFirst({ where: eq(users.id, id) });
await tx.update(users).set({ coins: newBalance }).where(eq(users.id, id));
return user;
}, existingTx); // pass existing tx for nested transactions
```
### Testing
Mock modules **before** imports. Use `bun:test`.
```typescript
import { describe, it, expect, mock, beforeEach } from "bun:test";
mock.module("@shared/db/DrizzleClient", () => ({
DrizzleClient: { query: mockQuery },
}));
describe("serviceName", () => {
beforeEach(() => mockFn.mockClear());
it("should handle expected case", async () => {
mockFn.mockResolvedValue(testData);
const result = await service.method(input);
expect(result).toEqual(expected);
});
});
```
## Naming Conventions
| Element | Convention | Example |
| ---------------- | ---------------------- | -------------------------------- |
| Files | camelCase or kebab-case | `BotClient.ts`, `economy.service.ts` |
| Classes | PascalCase | `CommandHandler`, `UserError` |
| Functions | camelCase | `createCommand`, `handleShopInteraction` |
| Constants | UPPER_SNAKE_CASE | `EVENTS`, `BRANDING` |
| Enums | PascalCase | `TimerType`, `TransactionType` |
| Services | camelCase singleton | `economyService`, `userService` |
| Types/Interfaces | PascalCase | `Command`, `Event`, `GameConfigType` |
| DB tables | snake_case | `users`, `moderation_cases` |
| Custom IDs | snake_case with prefix | `shop_buy_`, `trade_accept_` |
| API routes | kebab-case | `/api/guild-settings` |
## Key Files
| Purpose | File |
| ----------------- | -------------------------- |
| Bot entry point | `bot/index.ts` |
| Discord client | `bot/lib/BotClient.ts` |
| DB schema index | `shared/db/schema.ts` |
| Error classes | `shared/lib/errors.ts` |
| Environment vars | `shared/lib/env.ts` |
| Config loader | `shared/lib/config.ts` |
| Embed helpers | `bot/lib/embeds.ts` |
| Command utils | `bot/lib/commandUtils.ts` |
| API server | `api/src/server.ts` |

View File

@@ -1,15 +1,77 @@
# ============================================
# Base stage - shared configuration
# ============================================
FROM oven/bun:latest AS base
WORKDIR /app
# Install dependencies
# Install system dependencies with cleanup in same layer
RUN apt-get update && \
apt-get install -y --no-install-recommends git && \
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# ============================================
# Dependencies stage - installs all deps
# ============================================
FROM base AS deps
# Copy only package files first (better layer caching)
COPY package.json bun.lock ./
COPY panel/package.json panel/
# Install dependencies
RUN bun install --frozen-lockfile
# Copy source code
COPY . .
# ============================================
# Development stage - for local dev with volume mounts
# ============================================
FROM base AS development
# Expose port
# Copy dependencies from deps stage
COPY --from=deps /app/node_modules ./node_modules
# Expose ports
EXPOSE 3000
# Default command
CMD ["bun", "run", "dev"]
# ============================================
# Builder stage - copies source for production
# ============================================
FROM base AS builder
# Copy source code first, then deps on top (so node_modules aren't overwritten)
COPY . .
COPY --from=deps /app/node_modules ./node_modules
# Install panel deps and build
RUN cd panel && bun install --frozen-lockfile && bun run build
# ============================================
# Production stage - minimal runtime image
# ============================================
FROM oven/bun:latest AS production
WORKDIR /app
# Copy only what's needed for production
COPY --from=builder --chown=bun:bun /app/node_modules ./node_modules
COPY --from=builder --chown=bun:bun /app/api/src ./api/src
COPY --from=builder --chown=bun:bun /app/bot ./bot
COPY --from=builder --chown=bun:bun /app/shared ./shared
COPY --from=builder --chown=bun:bun /app/panel/dist ./panel/dist
COPY --from=builder --chown=bun:bun /app/package.json .
COPY --from=builder --chown=bun:bun /app/drizzle.config.ts .
COPY --from=builder --chown=bun:bun /app/tsconfig.json .
# Switch to non-root user
USER bun
# Expose web dashboard port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD bun -e "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
# Run in production mode
CMD ["bun", "run", "bot/index.ts"]

173
README.md
View File

@@ -1,42 +1,159 @@
# 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.
- **`src/lib/KyokoClient.ts`**: Custom Discord Client wrapper handling command loading and events.
- **`src/lib/env.ts`**: **Centralized Environment Configuration**. Validates environment variables using `zod` at startup.
- **`src/lib/DrizzleClient.ts`**: Database client instance.
- **`src/commands/`**: Command files.
- **`src/db/`**: Database schema and migrations.
**New in v1.0:** Aurora now includes a fully integrated **REST API** for accessing bot data, statistics, and configuration, running alongside the bot in a single process.
## Setup
## ✨ Features
1. **Install Dependencies**:
### Discord Bot
* **Class System**: Users can join different classes.
* **Economy**: Complete economy system with balance, transactions, and daily rewards.
* **Inventory & Items**: Sophisticated item system with rarities, types (Material, Consumable, etc.), and inventory management.
* **Leveling**: XP-based leveling system to track user activity and progress.
* **Quests**: Quest system with requirements and rewards.
* **Trading**: Secure trading system between users.
* **Lootdrops**: Random loot drops in channels to engage users.
* **Admin Tools**: Administrative commands for server management.
### REST API
* **Live Analytics**: Real-time statistics endpoint (commands, transactions).
* **Configuration Management**: Update bot settings via API.
* **Database Inspection**: Integrated Drizzle Studio access.
* **WebSocket Support**: Real-time event streaming for live updates.
## 🏗️ Architecture
Aurora uses a **Single Process Monolith** architecture to maximize performance and simplify resource sharing.
* **Unified Runtime**: Both the Discord Client and the REST API run within the same Bun process.
* **Shared State**: This allows the API to access live bot memory (caches, gateways) directly without complex inter-process communication (IPC).
* **Simplified Deployment**: You only need to deploy a single Docker container.
## 🛠️ Tech Stack
* **Runtime**: [Bun](https://bun.sh/)
* **Bot Framework**: [Discord.js](https://discord.js.org/)
* **API Framework**: Bun HTTP Server (REST API)
* **UI**: Discord embeds and components
* **Database**: [PostgreSQL](https://www.postgresql.org/)
* **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
* **Validation**: [Zod](https://zod.dev/)
* **Containerization**: [Docker](https://www.docker.com/)
## 🚀 Getting Started
### Prerequisites
* [Bun](https://bun.sh/) (latest version)
* [Docker](https://www.docker.com/) & Docker Compose
### Installation
1. **Clone the repository**
```bash
git clone <repository-url>
cd aurora
```
2. **Install dependencies**
```bash
bun install
```
2. **Environment Variables**:
Copy `.env.example` to `.env` (create one if it doesn't exist) and fill in the required values:
3. **Environment Setup**
Copy the example environment file and configure it:
```bash
cp .env.example .env
```
Edit `.env` with your Discord bot token, Client ID, and database credentials.
> **Note**: The `DATABASE_URL` in `.env.example` is pre-configured for Docker.
4. **Start the Database**
Run the database service using Docker Compose:
```bash
docker compose up -d db
```
5. **Run Migrations**
```bash
bun run migrate
```
OR
```bash
bun run db:push
```
### Running the Bot & API
**Development Mode** (with hot reload):
```bash
bun run dev
```
* Bot: Online in Discord
* API: http://localhost:3000
**Production Mode**:
Build and run with Docker (recommended):
```bash
docker compose up -d app
```
### 🔐 Accessing Production Services (SSH Tunnel)
For security, the Production Database and API are **not exposed** to the public internet by default. They are only accessible via localhost on the server.
To access them from your local machine, use the included SSH tunnel script.
1. Add your VPS details to your local `.env` file:
```env
DISCORD_BOT_TOKEN=your_token_here
DISCORD_CLIENT_ID=your_client_id
DISCORD_GUILD_ID=your_guild_id_optional
DATABASE_URL=postgres://user:pass@localhost:5432/db_name
```
*Note: The app will fail to start if `DISCORD_BOT_TOKEN` or `DATABASE_URL` are missing or invalid.*
3. **Run Development**:
```bash
bun run dev
VPS_USER=root
VPS_HOST=123.45.67.89
```
4. **Database Migrations**:
2. Run the remote connection script:
```bash
bun run db:push # Apply schema changes
bun run generate # Generate migrations
```
bun run remote
```
This will establish secure tunnels for:
* **API**: http://localhost:3000
* **Drizzle Studio**: http://localhost:4983
## 📜 Scripts
* `bun run dev`: Start the bot and API server in watch mode.
* `bun run remote`: Open SSH tunnel to production services.
* `bun run generate`: Generate Drizzle migrations.
* `bun run migrate`: Apply migrations (via Docker).
* `bun run db:studio`: Open Drizzle Studio to inspect the database.
* `bun test`: Run tests.
## 📂 Project Structure
```
├── bot # Discord Bot logic & entry point
├── web # REST API Server
├── shared # Shared code (Database, Config, Types)
├── drizzle # Drizzle migration files
├── scripts # Utility scripts
├── docker-compose.yml
└── package.json
```
## 🤝 Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## 📄 License
This project is licensed under the MIT License.

34
api/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

30
api/README.md Normal file
View File

@@ -0,0 +1,30 @@
# Aurora Web API
The web API provides a REST interface and WebSocket support for accessing Aurora bot data and configuration.
## API Endpoints
- `GET /api/stats` - Real-time bot statistics
- `GET /api/settings` - Bot configuration
- `GET /api/users` - User data
- `GET /api/items` - Item catalog
- `GET /api/quests` - Quest information
- `GET /api/transactions` - Economy data
- `GET /api/health` - Health check
## WebSocket
Connect to `/ws` for real-time updates:
- Stats broadcasts every 5 seconds
- Event notifications via system bus
- PING/PONG heartbeat support
## Development
The API runs automatically when you start the bot:
```bash
bun run dev
```
The API will be available at `http://localhost:3000`

17
api/bun-env.d.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
// Generated by `bun init`
declare module "*.svg" {
/**
* A path to the SVG file
*/
const path: `${string}.svg`;
export = path;
}
declare module "*.module.css" {
/**
* A record of class names to their corresponding CSS module classes
*/
const classes: { readonly [key: string]: string };
export = classes;
}

3
api/bunfig.toml Normal file
View File

@@ -0,0 +1,3 @@
[serve.static]
plugins = ["bun-plugin-tailwind"]
env = "BUN_PUBLIC_*"

View File

@@ -0,0 +1,106 @@
/**
* @fileoverview Administrative action endpoints for Aurora API.
* Provides endpoints for system administration tasks like cache clearing
* and maintenance mode toggling.
*/
import type { RouteContext, RouteModule } from "./types";
import { jsonResponse, errorResponse, parseBody, withErrorHandling } from "./utils";
import { MaintenanceModeSchema } from "./schemas";
/**
* Admin actions routes handler.
*
* Endpoints:
* - POST /api/actions/reload-commands - Reload bot slash commands
* - POST /api/actions/clear-cache - Clear internal caches
* - POST /api/actions/maintenance-mode - Toggle maintenance mode
*/
async function handler(ctx: RouteContext): Promise<Response | null> {
const { pathname, method, req } = ctx;
// Only handle POST requests to /api/actions/*
if (!pathname.startsWith("/api/actions/") || method !== "POST") {
return null;
}
const { actionService } = await import("@shared/modules/admin/action.service");
/**
* @route POST /api/actions/reload-commands
* @description Triggers a reload of all Discord slash commands.
* Useful after modifying command configurations.
* @response 200 - `{ success: true, message: string }`
* @response 500 - Error reloading commands
*
* @example
* // Request
* POST /api/actions/reload-commands
*
* // Response
* { "success": true, "message": "Commands reloaded" }
*/
if (pathname === "/api/actions/reload-commands") {
return withErrorHandling(async () => {
const result = await actionService.reloadCommands();
return jsonResponse(result);
}, "reload commands");
}
/**
* @route POST /api/actions/clear-cache
* @description Clears all internal application caches.
* Useful for forcing fresh data fetches.
* @response 200 - `{ success: true, message: string }`
* @response 500 - Error clearing cache
*
* @example
* // Request
* POST /api/actions/clear-cache
*
* // Response
* { "success": true, "message": "Cache cleared" }
*/
if (pathname === "/api/actions/clear-cache") {
return withErrorHandling(async () => {
const result = await actionService.clearCache();
return jsonResponse(result);
}, "clear cache");
}
/**
* @route POST /api/actions/maintenance-mode
* @description Toggles bot maintenance mode on or off.
* When enabled, the bot will respond with a maintenance message.
*
* @body { enabled: boolean, reason?: string }
* @response 200 - `{ success: true, enabled: boolean }`
* @response 400 - Invalid payload with validation errors
* @response 500 - Error toggling maintenance mode
*
* @example
* // Request
* POST /api/actions/maintenance-mode
* Content-Type: application/json
* { "enabled": true, "reason": "Deploying updates..." }
*
* // Response
* { "success": true, "enabled": true }
*/
if (pathname === "/api/actions/maintenance-mode") {
return withErrorHandling(async () => {
const data = await parseBody(req, MaintenanceModeSchema);
if (data instanceof Response) return data;
const result = await actionService.toggleMaintenanceMode(data.enabled, data.reason);
return jsonResponse(result);
}, "toggle maintenance mode");
}
return null;
}
export const actionsRoutes: RouteModule = {
name: "actions",
handler
};

View File

@@ -0,0 +1,83 @@
/**
* @fileoverview Static asset serving for Aurora API.
* Serves item images and other assets from the local filesystem.
*/
import { join, resolve, dirname } from "path";
import type { RouteContext, RouteModule } from "./types";
// Resolve assets root directory
const currentDir = dirname(new URL(import.meta.url).pathname);
const assetsRoot = resolve(currentDir, "../../../bot/assets/graphics");
/** MIME types for supported image formats */
const MIME_TYPES: Record<string, string> = {
"png": "image/png",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"webp": "image/webp",
"gif": "image/gif",
};
/**
* Assets routes handler.
*
* Endpoints:
* - GET /assets/* - Serve static files from the assets directory
*/
async function handler(ctx: RouteContext): Promise<Response | null> {
const { pathname, method } = ctx;
/**
* @route GET /assets/*
* @description Serves static asset files (images) with caching headers.
* Assets are served from the bot's graphics directory.
*
* Path security: Path traversal attacks are prevented by validating
* that the resolved path stays within the assets root.
*
* @response 200 - File content with appropriate MIME type
* @response 403 - Forbidden (path traversal attempt)
* @response 404 - File not found
*
* @example
* // Request
* GET /assets/items/1.png
*
* // Response Headers
* Content-Type: image/png
* Cache-Control: public, max-age=86400
*/
if (pathname.startsWith("/assets/") && method === "GET") {
const assetPath = pathname.replace("/assets/", "");
// Security: prevent path traversal attacks
const safePath = join(assetsRoot, assetPath);
if (!safePath.startsWith(assetsRoot)) {
return new Response("Forbidden", { status: 403 });
}
const file = Bun.file(safePath);
if (await file.exists()) {
// Determine MIME type based on extension
const ext = safePath.split(".").pop()?.toLowerCase();
const contentType = MIME_TYPES[ext || ""] || "application/octet-stream";
return new Response(file, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=86400", // Cache for 24 hours
}
});
}
return new Response("Not found", { status: 404 });
}
return null;
}
export const assetsRoutes: RouteModule = {
name: "assets",
handler
};

View File

@@ -0,0 +1,233 @@
/**
* @fileoverview Discord OAuth2 authentication routes for the admin panel.
* Handles login flow, callback, logout, and session management.
*/
import type { RouteContext, RouteModule } from "./types";
import { jsonResponse, errorResponse } from "./utils";
import { logger } from "@shared/lib/logger";
// In-memory session store: token → { discordId, username, avatar, expiresAt }
export interface Session {
discordId: string;
username: string;
avatar: string | null;
expiresAt: number;
}
const sessions = new Map<string, Session>();
const redirects = new Map<string, string>(); // redirect token -> return_to URL
const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
function getEnv(key: string): string {
const val = process.env[key];
if (!val) throw new Error(`Missing env: ${key}`);
return val;
}
function getAdminIds(): string[] {
const raw = process.env.ADMIN_USER_IDS ?? "";
return raw.split(",").map(s => s.trim()).filter(Boolean);
}
function generateToken(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
}
function getBaseUrl(): string {
return process.env.PANEL_BASE_URL ?? `http://localhost:3000`;
}
function parseCookies(header: string | null): Record<string, string> {
if (!header) return {};
const cookies: Record<string, string> = {};
for (const pair of header.split(";")) {
const [key, ...rest] = pair.trim().split("=");
if (key) cookies[key] = rest.join("=");
}
return cookies;
}
/** Get session from request cookie */
export function getSession(req: Request): Session | null {
const cookies = parseCookies(req.headers.get("cookie"));
const token = cookies["aurora_session"];
if (!token) return null;
const session = sessions.get(token);
if (!session) return null;
if (Date.now() > session.expiresAt) {
sessions.delete(token);
return null;
}
return session;
}
/** Check if request is authenticated as admin */
export function isAuthenticated(req: Request): boolean {
return getSession(req) !== null;
}
async function handler(ctx: RouteContext): Promise<Response | null> {
const { pathname, method } = ctx;
// GET /auth/discord — redirect to Discord OAuth
if (pathname === "/auth/discord" && method === "GET") {
try {
const clientId = getEnv("DISCORD_CLIENT_ID");
const baseUrl = getBaseUrl();
const redirectUri = encodeURIComponent(`${baseUrl}/auth/callback`);
const scope = "identify+email";
// Store return_to URL if provided
const returnTo = ctx.url.searchParams.get("return_to") || "/";
const redirectToken = generateToken();
redirects.set(redirectToken, returnTo);
const url = `https://discord.com/oauth2/authorize?client_id=${clientId}&response_type=code&redirect_uri=${redirectUri}&scope=${scope}`;
// Set a temporary cookie with the redirect token
return new Response(null, {
status: 302,
headers: {
Location: url,
"Set-Cookie": `aurora_redirect=${redirectToken}; Path=/; Max-Age=600; SameSite=Lax`,
},
});
} catch (e) {
logger.error("auth", "Failed to initiate OAuth", e);
return errorResponse("OAuth not configured", 500);
}
}
// GET /auth/callback — handle Discord OAuth callback
if (pathname === "/auth/callback" && method === "GET") {
const code = ctx.url.searchParams.get("code");
if (!code) return errorResponse("Missing code parameter", 400);
try {
const clientId = getEnv("DISCORD_CLIENT_ID");
const clientSecret = getEnv("DISCORD_CLIENT_SECRET");
const baseUrl = getBaseUrl();
const redirectUri = `${baseUrl}/auth/callback`;
// Exchange code for token
const tokenRes = await fetch("https://discord.com/api/oauth2/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
}),
});
if (!tokenRes.ok) {
logger.error("auth", `Token exchange failed: ${tokenRes.status}`);
return errorResponse("OAuth token exchange failed", 401);
}
const tokenData = await tokenRes.json() as { access_token: string };
// Fetch user info
const userRes = await fetch("https://discord.com/api/users/@me", {
headers: { Authorization: `Bearer ${tokenData.access_token}` },
});
if (!userRes.ok) {
return errorResponse("Failed to fetch Discord user", 401);
}
const user = await userRes.json() as { id: string; username: string; avatar: string | null };
// Check allowlist
const adminIds = getAdminIds();
if (adminIds.length > 0 && !adminIds.includes(user.id)) {
logger.warn("auth", `Unauthorized login attempt by ${user.username} (${user.id})`);
return new Response(
`<html><body><h1>Access Denied</h1><p>Your Discord account is not authorized.</p></body></html>`,
{ status: 403, headers: { "Content-Type": "text/html" } }
);
}
// Create session
const token = generateToken();
sessions.set(token, {
discordId: user.id,
username: user.username,
avatar: user.avatar,
expiresAt: Date.now() + SESSION_MAX_AGE,
});
logger.info("auth", `Admin login: ${user.username} (${user.id})`);
// Get return_to URL from redirect token cookie
const cookies = parseCookies(ctx.req.headers.get("cookie"));
const redirectToken = cookies["aurora_redirect"];
let returnTo = redirectToken && redirects.get(redirectToken) ? redirects.get(redirectToken)! : "/";
if (redirectToken) redirects.delete(redirectToken);
// Only allow redirects to localhost or relative paths (prevent open redirect)
try {
const parsed = new URL(returnTo, baseUrl);
if (parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1") {
returnTo = "/";
}
} catch {
returnTo = "/";
}
// Redirect to panel with session cookie
return new Response(null, {
status: 302,
headers: {
Location: returnTo,
"Set-Cookie": `aurora_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_MAX_AGE / 1000}`,
},
});
} catch (e) {
logger.error("auth", "OAuth callback error", e);
return errorResponse("Authentication failed", 500);
}
}
// POST /auth/logout — clear session
if (pathname === "/auth/logout" && method === "POST") {
const cookies = parseCookies(ctx.req.headers.get("cookie"));
const token = cookies["aurora_session"];
if (token) sessions.delete(token);
return new Response(null, {
status: 200,
headers: {
"Set-Cookie": "aurora_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
"Content-Type": "application/json",
},
});
}
// GET /auth/me — return current session info
if (pathname === "/auth/me" && method === "GET") {
const session = getSession(ctx.req);
if (!session) return jsonResponse({ authenticated: false }, 401);
return jsonResponse({
authenticated: true,
user: {
discordId: session.discordId,
username: session.username,
avatar: session.avatar,
},
});
}
return null;
}
export const authRoutes: RouteModule = {
name: "auth",
handler,
};

View File

@@ -0,0 +1,155 @@
/**
* @fileoverview Class management endpoints for Aurora API.
* Provides CRUD operations for player classes/guilds.
*/
import type { RouteContext, RouteModule } from "./types";
import {
jsonResponse,
errorResponse,
parseBody,
parseStringIdFromPath,
withErrorHandling
} from "./utils";
import { CreateClassSchema, UpdateClassSchema } from "./schemas";
/**
* Classes routes handler.
*
* Endpoints:
* - GET /api/classes - List all classes
* - POST /api/classes - Create a new class
* - PUT /api/classes/:id - Update a class
* - DELETE /api/classes/:id - Delete a class
*/
async function handler(ctx: RouteContext): Promise<Response | null> {
const { pathname, method, req } = ctx;
// Only handle requests to /api/classes*
if (!pathname.startsWith("/api/classes")) {
return null;
}
const { classService } = await import("@shared/modules/class/class.service");
/**
* @route GET /api/classes
* @description Returns all classes/guilds in the system.
*
* @response 200 - `{ classes: Class[] }`
* @response 500 - Error fetching classes
*
* @example
* // Response
* {
* "classes": [
* { "id": "1", "name": "Warrior", "balance": "5000", "roleId": "123456789" }
* ]
* }
*/
if (pathname === "/api/classes" && method === "GET") {
return withErrorHandling(async () => {
const classes = await classService.getAllClasses();
return jsonResponse({ classes });
}, "fetch classes");
}
/**
* @route POST /api/classes
* @description Creates a new class/guild.
*
* @body {
* id: string | number (required) - Unique class identifier,
* name: string (required) - Class display name,
* balance?: string | number - Initial class balance (default: 0),
* roleId?: string - Associated Discord role ID
* }
* @response 201 - `{ success: true, class: Class }`
* @response 400 - Missing required fields
* @response 500 - Error creating class
*
* @example
* // Request
* POST /api/classes
* { "id": "2", "name": "Mage", "balance": "0", "roleId": "987654321" }
*/
if (pathname === "/api/classes" && method === "POST") {
return withErrorHandling(async () => {
const data = await req.json() as Record<string, any>;
if (!data.id || !data.name || typeof data.name !== 'string') {
return errorResponse("Missing required fields: id and name are required", 400);
}
const newClass = await classService.createClass({
id: BigInt(data.id),
name: data.name,
balance: data.balance ? BigInt(data.balance) : 0n,
roleId: data.roleId || null,
});
return jsonResponse({ success: true, class: newClass }, 201);
}, "create class");
}
/**
* @route PUT /api/classes/:id
* @description Updates an existing class.
*
* @param id - Class ID
* @body {
* name?: string - Updated class name,
* balance?: string | number - Updated balance,
* roleId?: string - Updated Discord role ID
* }
* @response 200 - `{ success: true, class: Class }`
* @response 404 - Class not found
* @response 500 - Error updating class
*/
if (pathname.match(/^\/api\/classes\/\d+$/) && method === "PUT") {
const id = parseStringIdFromPath(pathname);
if (!id) return null;
return withErrorHandling(async () => {
const data = await req.json() as Record<string, any>;
const updateData: any = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.balance !== undefined) updateData.balance = BigInt(data.balance);
if (data.roleId !== undefined) updateData.roleId = data.roleId;
const updatedClass = await classService.updateClass(BigInt(id), updateData);
if (!updatedClass) {
return errorResponse("Class not found", 404);
}
return jsonResponse({ success: true, class: updatedClass });
}, "update class");
}
/**
* @route DELETE /api/classes/:id
* @description Deletes a class. Users assigned to this class will need to be reassigned.
*
* @param id - Class ID
* @response 204 - Class deleted (no content)
* @response 500 - Error deleting class
*/
if (pathname.match(/^\/api\/classes\/\d+$/) && method === "DELETE") {
const id = parseStringIdFromPath(pathname);
if (!id) return null;
return withErrorHandling(async () => {
await classService.deleteClass(BigInt(id));
return new Response(null, { status: 204 });
}, "delete class");
}
return null;
}
export const classesRoutes: RouteModule = {
name: "classes",
handler
};

View File

@@ -0,0 +1,64 @@
/**
* @fileoverview Guild settings endpoints for Aurora API.
* Provides endpoints for reading and updating per-guild configuration
* stored in the database.
*/
import type { RouteContext, RouteModule } from "./types";
import { jsonResponse, errorResponse, withErrorHandling } from "./utils";
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
import { invalidateGuildConfigCache } from "@shared/lib/config";
const GUILD_SETTINGS_PATTERN = /^\/api\/guilds\/(\d+)\/settings$/;
async function handler(ctx: RouteContext): Promise<Response | null> {
const { pathname, method, req } = ctx;
const match = pathname.match(GUILD_SETTINGS_PATTERN);
if (!match || !match[1]) {
return null;
}
const guildId = match[1];
if (method === "GET") {
return withErrorHandling(async () => {
const settings = await guildSettingsService.getSettings(guildId);
if (!settings) {
return jsonResponse({ guildId, configured: false });
}
return jsonResponse({ ...settings, guildId, configured: true });
}, "fetch guild settings");
}
if (method === "PUT" || method === "PATCH") {
try {
const body = await req.json() as Record<string, unknown>;
const { guildId: _, ...settings } = body;
const result = await guildSettingsService.upsertSettings({
guildId,
...settings,
} as Parameters<typeof guildSettingsService.upsertSettings>[0]);
invalidateGuildConfigCache(guildId);
return jsonResponse(result);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return errorResponse("Failed to save guild settings", 400, message);
}
}
if (method === "DELETE") {
return withErrorHandling(async () => {
await guildSettingsService.deleteSettings(guildId);
invalidateGuildConfigCache(guildId);
return jsonResponse({ success: true });
}, "delete guild settings");
}
return null;
}
export const guildSettingsRoutes: RouteModule = {
name: "guild-settings",
handler
};

View File

@@ -0,0 +1,36 @@
/**
* @fileoverview Health check endpoint for Aurora API.
* Provides a simple health status endpoint for monitoring and load balancers.
*/
import type { RouteContext, RouteModule } from "./types";
/**
* Health routes handler.
*
* @route GET /api/health
* @description Returns server health status with timestamp.
* @response 200 - `{ status: "ok", timestamp: number }`
*
* @example
* // Request
* GET /api/health
*
* // Response
* { "status": "ok", "timestamp": 1707408000000 }
*/
async function handler(ctx: RouteContext): Promise<Response | null> {
if (ctx.pathname === "/api/health" && ctx.method === "GET") {
return Response.json({
status: "ok",
timestamp: Date.now()
});
}
return null;
}
export const healthRoutes: RouteModule = {
name: "health",
handler
};

93
api/src/routes/index.ts Normal file
View File

@@ -0,0 +1,93 @@
/**
* @fileoverview Route registration module for Aurora API.
* Aggregates all route handlers and provides a unified request handler.
*/
import type { RouteContext, RouteModule } from "./types";
import { authRoutes, isAuthenticated } from "./auth.routes";
import { healthRoutes } from "./health.routes";
import { statsRoutes } from "./stats.routes";
import { actionsRoutes } from "./actions.routes";
import { questsRoutes } from "./quests.routes";
import { settingsRoutes } from "./settings.routes";
import { guildSettingsRoutes } from "./guild-settings.routes";
import { itemsRoutes } from "./items.routes";
import { usersRoutes } from "./users.routes";
import { classesRoutes } from "./classes.routes";
import { moderationRoutes } from "./moderation.routes";
import { transactionsRoutes } from "./transactions.routes";
import { lootdropsRoutes } from "./lootdrops.routes";
import { assetsRoutes } from "./assets.routes";
import { errorResponse } from "./utils";
/** Routes that do NOT require authentication */
const publicRoutes: RouteModule[] = [
authRoutes,
healthRoutes,
];
/** Routes that require an authenticated admin session */
const protectedRoutes: RouteModule[] = [
statsRoutes,
actionsRoutes,
questsRoutes,
settingsRoutes,
guildSettingsRoutes,
itemsRoutes,
usersRoutes,
classesRoutes,
moderationRoutes,
transactionsRoutes,
lootdropsRoutes,
assetsRoutes,
];
/**
* Main request handler that routes requests to appropriate handlers.
*
* @param req - The incoming HTTP request
* @param url - Parsed URL object
* @returns Response from matching route handler, or null if no match
*
* @example
* const response = await handleRequest(req, url);
* if (response) return response;
* return new Response("Not Found", { status: 404 });
*/
export async function handleRequest(req: Request, url: URL): Promise<Response | null> {
const ctx: RouteContext = {
req,
url,
method: req.method,
pathname: url.pathname,
};
// Try public routes first (auth, health)
for (const module of publicRoutes) {
const response = await module.handler(ctx);
if (response !== null) return response;
}
// For API routes, enforce authentication
if (ctx.pathname.startsWith("/api/")) {
if (!isAuthenticated(req)) {
return errorResponse("Unauthorized", 401);
}
}
// Try protected routes
for (const module of protectedRoutes) {
const response = await module.handler(ctx);
if (response !== null) return response;
}
return null;
}
/**
* Get list of all registered route module names.
* Useful for debugging and documentation.
*/
export function getRegisteredRoutes(): string[] {
return [...publicRoutes, ...protectedRoutes].map(m => m.name);
}

View File

@@ -0,0 +1,371 @@
/**
* @fileoverview Items management endpoints for Aurora API.
* Provides CRUD operations for game items with image upload support.
*/
import { join, resolve, dirname } from "path";
import type { RouteContext, RouteModule } from "./types";
import type { CreateItemDTO, UpdateItemDTO } from "@shared/modules/items/items.service";
import {
jsonResponse,
errorResponse,
parseBody,
parseIdFromPath,
parseQuery,
withErrorHandling
} from "./utils";
import { CreateItemSchema, UpdateItemSchema, ItemQuerySchema } from "./schemas";
// Resolve assets directory path
const currentDir = dirname(new URL(import.meta.url).pathname);
const assetsDir = resolve(currentDir, "../../../bot/assets/graphics/items");
/**
* Validates image file by checking magic bytes.
* Supports PNG, JPEG, WebP, and GIF formats.
*/
function validateImageFormat(bytes: Uint8Array): boolean {
const isPNG = bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47;
const isJPEG = bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF;
const isWebP = bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50;
const isGIF = bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46;
return isPNG || isJPEG || isWebP || isGIF;
}
/** Maximum image file size: 15MB */
const MAX_IMAGE_SIZE = 15 * 1024 * 1024;
/**
* Items routes handler.
*
* Endpoints:
* - GET /api/items - List items with filters
* - POST /api/items - Create item (JSON or multipart with image)
* - GET /api/items/:id - Get single item
* - PUT /api/items/:id - Update item
* - DELETE /api/items/:id - Delete item and asset
* - POST /api/items/:id/icon - Upload/replace item icon
*/
async function handler(ctx: RouteContext): Promise<Response | null> {
const { pathname, method, req, url } = ctx;
// Only handle requests to /api/items*
if (!pathname.startsWith("/api/items")) {
return null;
}
const { itemsService } = await import("@shared/modules/items/items.service");
/**
* @route GET /api/items
* @description Returns a paginated list of items with optional filtering.
*
* @query search - Filter by name/description (partial match)
* @query type - Filter by item type (CONSUMABLE, EQUIPMENT, etc.)
* @query rarity - Filter by rarity (C, R, SR, SSR)
* @query limit - Max results per page (default: 100, max: 100)
* @query offset - Pagination offset (default: 0)
*
* @response 200 - `{ items: Item[], total: number }`
* @response 500 - Error fetching items
*
* @example
* // Request
* GET /api/items?type=CONSUMABLE&rarity=R&limit=10
*
* // Response
* {
* "items": [{ "id": 1, "name": "Health Potion", ... }],
* "total": 25
* }
*/
if (pathname === "/api/items" && method === "GET") {
return withErrorHandling(async () => {
const filters = {
search: url.searchParams.get("search") || undefined,
type: url.searchParams.get("type") || undefined,
rarity: url.searchParams.get("rarity") || undefined,
limit: url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 100,
offset: url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0,
};
const result = await itemsService.getAllItems(filters);
return jsonResponse(result);
}, "fetch items");
}
/**
* @route POST /api/items
* @description Creates a new item. Supports JSON or multipart/form-data with image.
*
* @body (JSON) {
* name: string (required),
* type: string (required),
* description?: string,
* rarity?: "C" | "R" | "SR" | "SSR",
* price?: string | number,
* usageData?: object
* }
*
* @body (Multipart) {
* data: JSON string with item fields,
* image?: File (PNG, JPEG, WebP, GIF - max 15MB)
* }
*
* @response 201 - `{ success: true, item: Item }`
* @response 400 - Missing required fields or invalid image
* @response 409 - Item name already exists
* @response 500 - Error creating item
*/
if (pathname === "/api/items" && method === "POST") {
return withErrorHandling(async () => {
const contentType = req.headers.get("content-type") || "";
let itemData: CreateItemDTO | null = null;
let imageFile: File | null = null;
if (contentType.includes("multipart/form-data")) {
const formData = await req.formData();
const jsonData = formData.get("data");
imageFile = formData.get("image") as File | null;
if (typeof jsonData === "string") {
itemData = JSON.parse(jsonData) as CreateItemDTO;
} else {
return errorResponse("Missing item data", 400);
}
} else {
itemData = await req.json() as CreateItemDTO;
}
if (!itemData) {
return errorResponse("Missing item data", 400);
}
// Validate required fields
if (!itemData.name || !itemData.type) {
return errorResponse("Missing required fields: name and type are required", 400);
}
// Check for duplicate name
if (await itemsService.isNameTaken(itemData.name)) {
return errorResponse("An item with this name already exists", 409);
}
// Set placeholder URLs if image will be uploaded
const placeholderUrl = "/assets/items/placeholder.png";
const createData = {
name: itemData.name,
description: itemData.description || null,
rarity: itemData.rarity || "C",
type: itemData.type,
price: itemData.price ? BigInt(itemData.price) : null,
iconUrl: itemData.iconUrl || placeholderUrl,
imageUrl: itemData.imageUrl || placeholderUrl,
usageData: itemData.usageData || null,
};
// Create the item
const item = await itemsService.createItem(createData);
// If image was provided, save it and update the item
if (imageFile && item) {
const buffer = await imageFile.arrayBuffer();
const bytes = new Uint8Array(buffer);
if (!validateImageFormat(bytes)) {
await itemsService.deleteItem(item.id);
return errorResponse("Invalid image file. Only PNG, JPEG, WebP, and GIF are allowed.", 400);
}
if (buffer.byteLength > MAX_IMAGE_SIZE) {
await itemsService.deleteItem(item.id);
return errorResponse("Image file too large. Maximum size is 15MB.", 400);
}
const fileName = `${item.id}.png`;
const filePath = join(assetsDir, fileName);
await Bun.write(filePath, buffer);
const assetUrl = `/assets/items/${fileName}`;
await itemsService.updateItem(item.id, {
iconUrl: assetUrl,
imageUrl: assetUrl,
});
const updatedItem = await itemsService.getItemById(item.id);
return jsonResponse({ success: true, item: updatedItem }, 201);
}
return jsonResponse({ success: true, item }, 201);
}, "create item");
}
/**
* @route GET /api/items/:id
* @description Returns a single item by ID.
*
* @param id - Item ID (numeric)
* @response 200 - Full item object
* @response 404 - Item not found
* @response 500 - Error fetching item
*/
if (pathname.match(/^\/api\/items\/\d+$/) && method === "GET") {
const id = parseIdFromPath(pathname);
if (!id) return null;
return withErrorHandling(async () => {
const item = await itemsService.getItemById(id);
if (!item) {
return errorResponse("Item not found", 404);
}
return jsonResponse(item);
}, "fetch item");
}
/**
* @route PUT /api/items/:id
* @description Updates an existing item.
*
* @param id - Item ID (numeric)
* @body Partial item fields to update
* @response 200 - `{ success: true, item: Item }`
* @response 404 - Item not found
* @response 409 - Name already taken by another item
* @response 500 - Error updating item
*/
if (pathname.match(/^\/api\/items\/\d+$/) && method === "PUT") {
const id = parseIdFromPath(pathname);
if (!id) return null;
return withErrorHandling(async () => {
const data = await req.json() as Partial<UpdateItemDTO>;
const existing = await itemsService.getItemById(id);
if (!existing) {
return errorResponse("Item not found", 404);
}
// Check for duplicate name (if name is being changed)
if (data.name && data.name !== existing.name) {
if (await itemsService.isNameTaken(data.name, id)) {
return errorResponse("An item with this name already exists", 409);
}
}
// Build update data
const updateData: Partial<UpdateItemDTO> = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.description !== undefined) updateData.description = data.description;
if (data.rarity !== undefined) updateData.rarity = data.rarity;
if (data.type !== undefined) updateData.type = data.type;
if (data.price !== undefined) updateData.price = data.price ? BigInt(data.price) : null;
if (data.iconUrl !== undefined) updateData.iconUrl = data.iconUrl;
if (data.imageUrl !== undefined) updateData.imageUrl = data.imageUrl;
if (data.usageData !== undefined) updateData.usageData = data.usageData;
const updatedItem = await itemsService.updateItem(id, updateData);
return jsonResponse({ success: true, item: updatedItem });
}, "update item");
}
/**
* @route DELETE /api/items/:id
* @description Deletes an item and its associated asset file.
*
* @param id - Item ID (numeric)
* @response 204 - Item deleted (no content)
* @response 404 - Item not found
* @response 500 - Error deleting item
*/
if (pathname.match(/^\/api\/items\/\d+$/) && method === "DELETE") {
const id = parseIdFromPath(pathname);
if (!id) return null;
return withErrorHandling(async () => {
const existing = await itemsService.getItemById(id);
if (!existing) {
return errorResponse("Item not found", 404);
}
await itemsService.deleteItem(id);
// Try to delete associated asset file
const assetPath = join(assetsDir, `${id}.png`);
try {
const assetFile = Bun.file(assetPath);
if (await assetFile.exists()) {
const { unlink } = await import("node:fs/promises");
await unlink(assetPath);
}
} catch (e) {
// Non-critical: log but don't fail
const { logger } = await import("@shared/lib/logger");
logger.warn("web", `Could not delete asset file for item ${id}`, e);
}
return new Response(null, { status: 204 });
}, "delete item");
}
/**
* @route POST /api/items/:id/icon
* @description Uploads or replaces an item's icon image.
*
* @param id - Item ID (numeric)
* @body (Multipart) { image: File }
* @response 200 - `{ success: true, item: Item }`
* @response 400 - No image file or invalid format
* @response 404 - Item not found
* @response 500 - Error uploading icon
*/
if (pathname.match(/^\/api\/items\/\d+\/icon$/) && method === "POST") {
const id = parseInt(pathname.split("/")[3] || "0");
if (!id) return null;
return withErrorHandling(async () => {
const existing = await itemsService.getItemById(id);
if (!existing) {
return errorResponse("Item not found", 404);
}
const formData = await req.formData();
const imageFile = formData.get("image") as File | null;
if (!imageFile) {
return errorResponse("No image file provided", 400);
}
const buffer = await imageFile.arrayBuffer();
const bytes = new Uint8Array(buffer);
if (!validateImageFormat(bytes)) {
return errorResponse("Invalid image file. Only PNG, JPEG, WebP, and GIF are allowed.", 400);
}
if (buffer.byteLength > MAX_IMAGE_SIZE) {
return errorResponse("Image file too large. Maximum size is 15MB.", 400);
}
const fileName = `${id}.png`;
const filePath = join(assetsDir, fileName);
await Bun.write(filePath, buffer);
const assetUrl = `/assets/items/${fileName}`;
const updatedItem = await itemsService.updateItem(id, {
iconUrl: assetUrl,
imageUrl: assetUrl,
});
return jsonResponse({ success: true, item: updatedItem });
}, "upload item icon");
}
return null;
}
export const itemsRoutes: RouteModule = {
name: "items",
handler
};

View File

@@ -0,0 +1,130 @@
/**
* @fileoverview Lootdrop management endpoints for Aurora API.
* Provides endpoints for viewing, spawning, and canceling lootdrops.
*/
import type { RouteContext, RouteModule } from "./types";
import {
jsonResponse,
errorResponse,
parseStringIdFromPath,
withErrorHandling
} from "./utils";
/**
* Lootdrops routes handler.
*
* Endpoints:
* - GET /api/lootdrops - List lootdrops
* - POST /api/lootdrops - Spawn a lootdrop
* - DELETE /api/lootdrops/:messageId - Cancel/delete a lootdrop
*/
async function handler(ctx: RouteContext): Promise<Response | null> {
const { pathname, method, req, url } = ctx;
// Only handle requests to /api/lootdrops*
if (!pathname.startsWith("/api/lootdrops")) {
return null;
}
/**
* @route GET /api/lootdrops
* @description Returns recent lootdrops, sorted by newest first.
*
* @query limit - Max results (default: 50)
* @response 200 - `{ lootdrops: Lootdrop[] }`
* @response 500 - Error fetching lootdrops
*/
if (pathname === "/api/lootdrops" && method === "GET") {
return withErrorHandling(async () => {
const { lootdrops } = await import("@shared/db/schema");
const { DrizzleClient } = await import("@shared/db/DrizzleClient");
const { desc } = await import("drizzle-orm");
const limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50;
const result = await DrizzleClient.select()
.from(lootdrops)
.orderBy(desc(lootdrops.createdAt))
.limit(limit);
return jsonResponse({ lootdrops: result });
}, "fetch lootdrops");
}
/**
* @route POST /api/lootdrops
* @description Spawns a new lootdrop in a Discord channel.
* Requires a valid text channel ID where the bot has permissions.
*
* @body {
* channelId: string (required) - Discord channel ID to spawn in,
* amount?: number - Reward amount (random if not specified),
* currency?: string - Currency type
* }
* @response 201 - `{ success: true }`
* @response 400 - Invalid channel or missing channelId
* @response 500 - Error spawning lootdrop
*
* @example
* // Request
* POST /api/lootdrops
* { "channelId": "1234567890", "amount": 100, "currency": "Gold" }
*/
if (pathname === "/api/lootdrops" && method === "POST") {
return withErrorHandling(async () => {
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
const { AuroraClient } = await import("../../../bot/lib/BotClient");
const { TextChannel } = await import("discord.js");
const data = await req.json() as Record<string, any>;
if (!data.channelId) {
return errorResponse("Missing required field: channelId", 400);
}
const channel = await AuroraClient.channels.fetch(data.channelId);
if (!channel || !(channel instanceof TextChannel)) {
return errorResponse("Invalid channel. Must be a TextChannel.", 400);
}
await lootdropService.spawnLootdrop(channel, data.amount, data.currency);
return jsonResponse({ success: true }, 201);
}, "spawn lootdrop");
}
/**
* @route DELETE /api/lootdrops/:messageId
* @description Cancels and deletes an active lootdrop.
* The lootdrop is identified by its Discord message ID.
*
* @param messageId - Discord message ID of the lootdrop
* @response 204 - Lootdrop deleted (no content)
* @response 404 - Lootdrop not found
* @response 500 - Error deleting lootdrop
*/
if (pathname.match(/^\/api\/lootdrops\/[^\/]+$/) && method === "DELETE") {
const messageId = parseStringIdFromPath(pathname);
if (!messageId) return null;
return withErrorHandling(async () => {
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
const success = await lootdropService.deleteLootdrop(messageId);
if (!success) {
return errorResponse("Lootdrop not found", 404);
}
return new Response(null, { status: 204 });
}, "delete lootdrop");
}
return null;
}
export const lootdropsRoutes: RouteModule = {
name: "lootdrops",
handler
};

View File

@@ -0,0 +1,217 @@
/**
* @fileoverview Moderation case management endpoints for Aurora API.
* Provides endpoints for viewing, creating, and resolving moderation cases.
*/
import type { RouteContext, RouteModule } from "./types";
import {
jsonResponse,
errorResponse,
parseBody,
withErrorHandling
} from "./utils";
import { CreateCaseSchema, ClearCaseSchema, CaseIdPattern } from "./schemas";
/**
* Moderation routes handler.
*
* Endpoints:
* - GET /api/moderation - List cases with filters
* - GET /api/moderation/:caseId - Get single case
* - POST /api/moderation - Create new case
* - PUT /api/moderation/:caseId/clear - Clear/resolve case
*/
async function handler(ctx: RouteContext): Promise<Response | null> {
const { pathname, method, req, url } = ctx;
// Only handle requests to /api/moderation*
if (!pathname.startsWith("/api/moderation")) {
return null;
}
const { moderationService } = await import("@shared/modules/moderation/moderation.service");
/**
* @route GET /api/moderation
* @description Returns moderation cases with optional filtering.
*
* @query userId - Filter by target user ID
* @query moderatorId - Filter by moderator ID
* @query type - Filter by case type (warn, timeout, kick, ban, note, prune)
* @query active - Filter by active status (true/false)
* @query limit - Max results (default: 50)
* @query offset - Pagination offset (default: 0)
*
* @response 200 - `{ cases: ModerationCase[] }`
* @response 500 - Error fetching cases
*
* @example
* // Request
* GET /api/moderation?type=warn&active=true&limit=10
*
* // Response
* {
* "cases": [
* {
* "id": "1",
* "caseId": "CASE-0001",
* "type": "warn",
* "userId": "123456789",
* "username": "User1",
* "moderatorId": "987654321",
* "moderatorName": "Mod1",
* "reason": "Spam",
* "active": true,
* "createdAt": "2024-01-15T12:00:00Z"
* }
* ]
* }
*/
if (pathname === "/api/moderation" && method === "GET") {
return withErrorHandling(async () => {
const filter: any = {};
if (url.searchParams.get("userId")) filter.userId = url.searchParams.get("userId");
if (url.searchParams.get("moderatorId")) filter.moderatorId = url.searchParams.get("moderatorId");
if (url.searchParams.get("type")) filter.type = url.searchParams.get("type");
const activeParam = url.searchParams.get("active");
if (activeParam !== null) filter.active = activeParam === "true";
filter.limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50;
filter.offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0;
const cases = await moderationService.searchCases(filter);
return jsonResponse({ cases });
}, "fetch moderation cases");
}
/**
* @route GET /api/moderation/:caseId
* @description Returns a single moderation case by case ID.
* Case IDs follow the format CASE-XXXX (e.g., CASE-0001).
*
* @param caseId - Case ID in CASE-XXXX format
* @response 200 - Full case object
* @response 404 - Case not found
* @response 500 - Error fetching case
*/
if (pathname.match(/^\/api\/moderation\/CASE-\d+$/i) && method === "GET") {
const caseId = pathname.split("/").pop()!.toUpperCase();
return withErrorHandling(async () => {
const moderationCase = await moderationService.getCaseById(caseId);
if (!moderationCase) {
return errorResponse("Case not found", 404);
}
return jsonResponse(moderationCase);
}, "fetch moderation case");
}
/**
* @route POST /api/moderation
* @description Creates a new moderation case.
*
* @body {
* type: "warn" | "timeout" | "kick" | "ban" | "note" | "prune" (required),
* userId: string (required) - Target user's Discord ID,
* username: string (required) - Target user's username,
* moderatorId: string (required) - Moderator's Discord ID,
* moderatorName: string (required) - Moderator's username,
* reason: string (required) - Reason for the action,
* metadata?: object - Additional case metadata (e.g., duration)
* }
* @response 201 - `{ success: true, case: ModerationCase }`
* @response 400 - Missing required fields
* @response 500 - Error creating case
*
* @example
* // Request
* POST /api/moderation
* {
* "type": "warn",
* "userId": "123456789",
* "username": "User1",
* "moderatorId": "987654321",
* "moderatorName": "Mod1",
* "reason": "Rule violation",
* "metadata": { "duration": "24h" }
* }
*/
if (pathname === "/api/moderation" && method === "POST") {
return withErrorHandling(async () => {
const data = await req.json() as Record<string, any>;
if (!data.type || !data.userId || !data.username || !data.moderatorId || !data.moderatorName || !data.reason) {
return errorResponse(
"Missing required fields: type, userId, username, moderatorId, moderatorName, reason",
400
);
}
const newCase = await moderationService.createCase({
type: data.type,
userId: data.userId,
username: data.username,
moderatorId: data.moderatorId,
moderatorName: data.moderatorName,
reason: data.reason,
metadata: data.metadata || {},
});
return jsonResponse({ success: true, case: newCase }, 201);
}, "create moderation case");
}
/**
* @route PUT /api/moderation/:caseId/clear
* @description Clears/resolves a moderation case.
* Sets the case as inactive and records who cleared it.
*
* @param caseId - Case ID in CASE-XXXX format
* @body {
* clearedBy: string (required) - Discord ID of user clearing the case,
* clearedByName: string (required) - Username of user clearing the case,
* reason?: string - Reason for clearing (default: "Cleared via API")
* }
* @response 200 - `{ success: true, case: ModerationCase }`
* @response 400 - Missing required fields
* @response 404 - Case not found
* @response 500 - Error clearing case
*
* @example
* // Request
* PUT /api/moderation/CASE-0001/clear
* { "clearedBy": "987654321", "clearedByName": "Admin1", "reason": "Appeal accepted" }
*/
if (pathname.match(/^\/api\/moderation\/CASE-\d+\/clear$/i) && method === "PUT") {
const caseId = (pathname.split("/")[3] || "").toUpperCase();
return withErrorHandling(async () => {
const data = await req.json() as Record<string, any>;
if (!data.clearedBy || !data.clearedByName) {
return errorResponse("Missing required fields: clearedBy, clearedByName", 400);
}
const updatedCase = await moderationService.clearCase({
caseId,
clearedBy: data.clearedBy,
clearedByName: data.clearedByName,
reason: data.reason || "Cleared via API",
});
if (!updatedCase) {
return errorResponse("Case not found", 404);
}
return jsonResponse({ success: true, case: updatedCase });
}, "clear moderation case");
}
return null;
}
export const moderationRoutes: RouteModule = {
name: "moderation",
handler
};

View File

@@ -0,0 +1,207 @@
/**
* @fileoverview Quest management endpoints for Aurora API.
* Provides CRUD operations for game quests.
*/
import type { RouteContext, RouteModule } from "./types";
import { jsonResponse, errorResponse, parseIdFromPath, withErrorHandling } from "./utils";
import { CreateQuestSchema, UpdateQuestSchema } from "@shared/modules/quest/quest.types";
/**
* Quest routes handler.
*
* Endpoints:
* - GET /api/quests - List all quests
* - POST /api/quests - Create a new quest
* - PUT /api/quests/:id - Update an existing quest
* - DELETE /api/quests/:id - Delete a quest
*/
async function handler(ctx: RouteContext): Promise<Response | null> {
const { pathname, method, req } = ctx;
// Only handle requests to /api/quests*
if (!pathname.startsWith("/api/quests")) {
return null;
}
const { questService } = await import("@shared/modules/quest/quest.service");
/**
* @route GET /api/quests
* @description Returns all quests in the system.
* @response 200 - `{ success: true, data: Quest[] }`
* @response 500 - Error fetching quests
*
* @example
* // Response
* {
* "success": true,
* "data": [
* {
* "id": 1,
* "name": "Daily Login",
* "description": "Login once to claim",
* "triggerEvent": "login",
* "requirements": { "target": 1 },
* "rewards": { "xp": 50, "balance": 100 }
* }
* ]
* }
*/
if (pathname === "/api/quests" && method === "GET") {
return withErrorHandling(async () => {
const quests = await questService.getAllQuests();
return jsonResponse({
success: true,
data: quests.map(q => ({
id: q.id,
name: q.name,
description: q.description,
triggerEvent: q.triggerEvent,
requirements: q.requirements,
rewards: q.rewards,
})),
});
}, "fetch quests");
}
/**
* @route POST /api/quests
* @description Creates a new quest.
*
* @body {
* name: string,
* description?: string,
* triggerEvent: string,
* target: number,
* xpReward: number,
* balanceReward: number
* }
* @response 200 - `{ success: true, quest: Quest }`
* @response 400 - Validation error
* @response 500 - Error creating quest
*
* @example
* // Request
* POST /api/quests
* {
* "name": "Win 5 Battles",
* "description": "Defeat 5 enemies in combat",
* "triggerEvent": "battle_win",
* "target": 5,
* "xpReward": 200,
* "balanceReward": 500
* }
*/
if (pathname === "/api/quests" && method === "POST") {
return withErrorHandling(async () => {
const rawData = await req.json();
const parseResult = CreateQuestSchema.safeParse(rawData);
if (!parseResult.success) {
return Response.json({
error: "Invalid payload",
issues: parseResult.error.issues.map(i => ({ path: i.path, message: i.message }))
}, { status: 400 });
}
const data = parseResult.data;
const result = await questService.createQuest({
name: data.name,
description: data.description || "",
triggerEvent: data.triggerEvent,
requirements: { target: data.target },
rewards: {
xp: data.xpReward,
balance: data.balanceReward
}
});
return jsonResponse({ success: true, quest: result[0] });
}, "create quest");
}
/**
* @route PUT /api/quests/:id
* @description Updates an existing quest by ID.
*
* @param id - Quest ID (numeric)
* @body Partial quest fields to update
* @response 200 - `{ success: true, quest: Quest }`
* @response 400 - Invalid quest ID or validation error
* @response 404 - Quest not found
* @response 500 - Error updating quest
*/
if (pathname.match(/^\/api\/quests\/\d+$/) && method === "PUT") {
const id = parseIdFromPath(pathname);
if (!id) {
return errorResponse("Invalid quest ID", 400);
}
return withErrorHandling(async () => {
const rawData = await req.json();
const parseResult = UpdateQuestSchema.safeParse(rawData);
if (!parseResult.success) {
return Response.json({
error: "Invalid payload",
issues: parseResult.error.issues.map(i => ({ path: i.path, message: i.message }))
}, { status: 400 });
}
const data = parseResult.data;
const result = await questService.updateQuest(id, {
...(data.name !== undefined && { name: data.name }),
...(data.description !== undefined && { description: data.description }),
...(data.triggerEvent !== undefined && { triggerEvent: data.triggerEvent }),
...(data.target !== undefined && { requirements: { target: data.target } }),
...((data.xpReward !== undefined || data.balanceReward !== undefined) && {
rewards: {
xp: data.xpReward ?? 0,
balance: data.balanceReward ?? 0
}
})
});
if (!result || result.length === 0) {
return errorResponse("Quest not found", 404);
}
return jsonResponse({ success: true, quest: result[0] });
}, "update quest");
}
/**
* @route DELETE /api/quests/:id
* @description Deletes a quest by ID.
*
* @param id - Quest ID (numeric)
* @response 200 - `{ success: true, deleted: number }`
* @response 400 - Invalid quest ID
* @response 404 - Quest not found
* @response 500 - Error deleting quest
*/
if (pathname.match(/^\/api\/quests\/\d+$/) && method === "DELETE") {
const id = parseIdFromPath(pathname);
if (!id) {
return errorResponse("Invalid quest ID", 400);
}
return withErrorHandling(async () => {
const result = await questService.deleteQuest(id);
if (!result || result.length === 0) {
return errorResponse("Quest not found", 404);
}
return jsonResponse({ success: true, deleted: (result[0] as { id: number }).id });
}, "delete quest");
}
return null;
}
export const questsRoutes: RouteModule = {
name: "quests",
handler
};

274
api/src/routes/schemas.ts Normal file
View File

@@ -0,0 +1,274 @@
/**
* @fileoverview Centralized Zod validation schemas for all Aurora API endpoints.
* Provides type-safe request/response validation for every entity in the system.
*/
import { z } from "zod";
// ============================================================================
// Common Schemas
// ============================================================================
/**
* Standard pagination query parameters.
*/
export const PaginationSchema = z.object({
limit: z.coerce.number().min(1).max(100).optional().default(50),
offset: z.coerce.number().min(0).optional().default(0),
});
/**
* Numeric ID parameter validation.
*/
export const NumericIdSchema = z.coerce.number().int().positive();
/**
* Discord snowflake ID validation (string of digits).
*/
export const SnowflakeIdSchema = z.string().regex(/^\d{17,20}$/, "Invalid Discord ID format");
// ============================================================================
// Items Schemas
// ============================================================================
/**
* Valid item types in the system.
*/
export const ItemTypeEnum = z.enum([
"CONSUMABLE",
"EQUIPMENT",
"MATERIAL",
"LOOTBOX",
"COLLECTIBLE",
"KEY",
"TOOL"
]);
/**
* Valid item rarities.
*/
export const ItemRarityEnum = z.enum(["C", "R", "SR", "SSR"]);
/**
* Query parameters for listing items.
*/
export const ItemQuerySchema = PaginationSchema.extend({
search: z.string().optional(),
type: z.string().optional(),
rarity: z.string().optional(),
});
/**
* Schema for creating a new item.
*/
export const CreateItemSchema = z.object({
name: z.string().min(1, "Name is required").max(100),
description: z.string().max(500).nullable().optional(),
rarity: ItemRarityEnum.optional().default("C"),
type: ItemTypeEnum,
price: z.union([z.string(), z.number()]).nullable().optional(),
iconUrl: z.string().optional(),
imageUrl: z.string().optional(),
usageData: z.any().nullable().optional(),
});
/**
* Schema for updating an existing item.
*/
export const UpdateItemSchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).nullable().optional(),
rarity: ItemRarityEnum.optional(),
type: ItemTypeEnum.optional(),
price: z.union([z.string(), z.number()]).nullable().optional(),
iconUrl: z.string().optional(),
imageUrl: z.string().optional(),
usageData: z.any().nullable().optional(),
});
// ============================================================================
// Users Schemas
// ============================================================================
/**
* Query parameters for listing users.
*/
export const UserQuerySchema = PaginationSchema.extend({
search: z.string().optional(),
sortBy: z.enum(["balance", "level", "xp", "username"]).optional().default("balance"),
sortOrder: z.enum(["asc", "desc"]).optional().default("desc"),
});
/**
* Schema for updating a user.
*/
export const UpdateUserSchema = z.object({
username: z.string().min(1).max(32).optional(),
balance: z.union([z.string(), z.number()]).optional(),
xp: z.union([z.string(), z.number()]).optional(),
level: z.coerce.number().int().min(0).optional(),
dailyStreak: z.coerce.number().int().min(0).optional(),
isActive: z.boolean().optional(),
settings: z.record(z.string(), z.any()).optional(),
classId: z.union([z.string(), z.number()]).optional(),
});
/**
* Schema for adding an item to user inventory.
*/
export const InventoryAddSchema = z.object({
itemId: z.coerce.number().int().positive("Item ID is required"),
quantity: z.union([z.string(), z.number()]).refine(
(val) => BigInt(val) > 0n,
"Quantity must be positive"
),
});
/**
* Query params for removing inventory items.
*/
export const InventoryRemoveQuerySchema = z.object({
amount: z.coerce.number().int().min(1).optional().default(1),
});
// ============================================================================
// Classes Schemas
// ============================================================================
/**
* Schema for creating a new class.
*/
export const CreateClassSchema = z.object({
id: z.union([z.string(), z.number()]),
name: z.string().min(1, "Name is required").max(50),
balance: z.union([z.string(), z.number()]).optional().default("0"),
roleId: z.string().nullable().optional(),
});
/**
* Schema for updating a class.
*/
export const UpdateClassSchema = z.object({
name: z.string().min(1).max(50).optional(),
balance: z.union([z.string(), z.number()]).optional(),
roleId: z.string().nullable().optional(),
});
// ============================================================================
// Moderation Schemas
// ============================================================================
/**
* Valid moderation case types.
*/
export const ModerationTypeEnum = z.enum([
"warn",
"timeout",
"kick",
"ban",
"note",
"prune"
]);
/**
* Query parameters for searching moderation cases.
*/
export const CaseQuerySchema = PaginationSchema.extend({
userId: z.string().optional(),
moderatorId: z.string().optional(),
type: ModerationTypeEnum.optional(),
active: z.preprocess(
(val) => val === "true" ? true : val === "false" ? false : undefined,
z.boolean().optional()
),
});
/**
* Schema for creating a moderation case.
*/
export const CreateCaseSchema = z.object({
type: ModerationTypeEnum,
userId: z.string().min(1, "User ID is required"),
username: z.string().min(1, "Username is required"),
moderatorId: z.string().min(1, "Moderator ID is required"),
moderatorName: z.string().min(1, "Moderator name is required"),
reason: z.string().min(1, "Reason is required").max(1000),
metadata: z.record(z.string(), z.any()).optional().default({}),
});
/**
* Schema for clearing/resolving a moderation case.
*/
export const ClearCaseSchema = z.object({
clearedBy: z.string().min(1, "Cleared by ID is required"),
clearedByName: z.string().min(1, "Cleared by name is required"),
reason: z.string().max(500).optional().default("Cleared via API"),
});
/**
* Case ID pattern validation (CASE-XXXX format).
*/
export const CaseIdPattern = /^CASE-\d+$/i;
// ============================================================================
// Transactions Schemas
// ============================================================================
/**
* Query parameters for listing transactions.
*/
export const TransactionQuerySchema = PaginationSchema.extend({
userId: z.string().optional(),
type: z.string().optional(),
});
// ============================================================================
// Lootdrops Schemas
// ============================================================================
/**
* Query parameters for listing lootdrops.
*/
export const LootdropQuerySchema = z.object({
limit: z.coerce.number().min(1).max(100).optional().default(50),
});
/**
* Schema for spawning a lootdrop.
*/
export const CreateLootdropSchema = z.object({
channelId: z.string().min(1, "Channel ID is required"),
amount: z.coerce.number().int().positive().optional(),
currency: z.string().optional(),
});
// ============================================================================
// Admin Actions Schemas
// ============================================================================
/**
* Schema for toggling maintenance mode.
*/
export const MaintenanceModeSchema = z.object({
enabled: z.boolean(),
reason: z.string().max(200).optional(),
});
// ============================================================================
// Type Exports
// ============================================================================
export type ItemQuery = z.infer<typeof ItemQuerySchema>;
export type CreateItem = z.infer<typeof CreateItemSchema>;
export type UpdateItem = z.infer<typeof UpdateItemSchema>;
export type UserQuery = z.infer<typeof UserQuerySchema>;
export type UpdateUser = z.infer<typeof UpdateUserSchema>;
export type InventoryAdd = z.infer<typeof InventoryAddSchema>;
export type CreateClass = z.infer<typeof CreateClassSchema>;
export type UpdateClass = z.infer<typeof UpdateClassSchema>;
export type CaseQuery = z.infer<typeof CaseQuerySchema>;
export type CreateCase = z.infer<typeof CreateCaseSchema>;
export type ClearCase = z.infer<typeof ClearCaseSchema>;
export type TransactionQuery = z.infer<typeof TransactionQuerySchema>;
export type CreateLootdrop = z.infer<typeof CreateLootdropSchema>;
export type MaintenanceMode = z.infer<typeof MaintenanceModeSchema>;

View File

@@ -0,0 +1,152 @@
/**
* @fileoverview Bot settings endpoints for Aurora API.
* Provides endpoints for reading and updating bot configuration,
* as well as fetching Discord metadata.
*/
import type { RouteContext, RouteModule } from "./types";
import { jsonResponse, errorResponse, withErrorHandling } from "./utils";
/**
* Settings routes handler.
*
* Endpoints:
* - GET /api/settings - Get current bot configuration
* - POST /api/settings - Update bot configuration (partial merge)
* - GET /api/settings/meta - Get Discord metadata (roles, channels, commands)
*/
async function handler(ctx: RouteContext): Promise<Response | null> {
const { pathname, method, req } = ctx;
// Only handle requests to /api/settings*
if (!pathname.startsWith("/api/settings")) {
return null;
}
/**
* @route GET /api/settings
* @description Returns the current bot configuration from database.
* Configuration includes economy settings, leveling settings,
* command toggles, and other system settings.
* @response 200 - Full configuration object (DB format with strings for BigInts)
* @response 500 - Error fetching settings
*
* @example
* // Response
* {
* "economy": { "daily": { "amount": "100", "streakBonus": "10" } },
* "leveling": { "base": 100, "exponent": 1.5 },
* "commands": { "disabled": [], "channelLocks": {} }
* }
*/
if (pathname === "/api/settings" && method === "GET") {
return withErrorHandling(async () => {
const { gameSettingsService } = await import("@shared/modules/game-settings/game-settings.service");
const settings = await gameSettingsService.getSettings();
if (!settings) {
// Return defaults if no settings in DB yet
return jsonResponse(gameSettingsService.getDefaults());
}
return jsonResponse(settings);
}, "fetch settings");
}
/**
* @route POST /api/settings
* @description Updates bot configuration with partial merge.
* Only the provided fields will be updated; other settings remain unchanged.
* After updating, commands are automatically reloaded.
*
* @body Partial configuration object (DB format with strings for BigInts)
* @response 200 - `{ success: true }`
* @response 400 - Validation error
* @response 500 - Error saving settings
*
* @example
* // Request - Only update economy daily reward
* POST /api/settings
* { "economy": { "daily": { "amount": "150" } } }
*/
if (pathname === "/api/settings" && method === "POST") {
try {
const partialConfig = await req.json() as Record<string, unknown>;
const { gameSettingsService } = await import("@shared/modules/game-settings/game-settings.service");
// Use upsertSettings to merge partial update
await gameSettingsService.upsertSettings(partialConfig as Record<string, unknown>);
const { systemEvents, EVENTS } = await import("@shared/lib/events");
systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS);
return jsonResponse({ success: true });
} catch (error) {
// Return 400 for validation errors
const message = error instanceof Error ? error.message : String(error);
return errorResponse("Failed to save settings", 400, message);
}
}
/**
* @route GET /api/settings/meta
* @description Returns Discord server metadata for settings UI.
* Provides lists of roles, channels, and registered commands.
*
* @response 200 - `{ roles: Role[], channels: Channel[], commands: Command[] }`
* @response 500 - Error fetching metadata
*
* @example
* // Response
* {
* "roles": [
* { "id": "123456789", "name": "Admin", "color": "#FF0000" }
* ],
* "channels": [
* { "id": "987654321", "name": "general", "type": 0 }
* ],
* "commands": [
* { "name": "daily", "category": "economy" }
* ]
* }
*/
if (pathname === "/api/settings/meta" && method === "GET") {
return withErrorHandling(async () => {
const { AuroraClient } = await import("../../../bot/lib/BotClient");
const { env } = await import("@shared/lib/env");
if (!env.DISCORD_GUILD_ID) {
return jsonResponse({ roles: [], channels: [], commands: [] });
}
const guild = AuroraClient.guilds.cache.get(env.DISCORD_GUILD_ID);
if (!guild) {
return jsonResponse({ roles: [], channels: [], commands: [] });
}
// Map roles and channels to a simplified format
const roles = guild.roles.cache
.sort((a, b) => b.position - a.position)
.map(r => ({ id: r.id, name: r.name, color: r.hexColor }));
const channels = guild.channels.cache
.map(c => ({ id: c.id, name: c.name, type: c.type }));
const commands = Array.from(AuroraClient.knownCommands.entries())
.map(([name, category]) => ({ name, category }))
.sort((a, b) => {
if (a.category !== b.category) return a.category.localeCompare(b.category);
return a.name.localeCompare(b.name);
});
return jsonResponse({ guildId: env.DISCORD_GUILD_ID, roles, channels, commands });
}, "fetch settings meta");
}
return null;
}
export const settingsRoutes: RouteModule = {
name: "settings",
handler
};

View File

@@ -0,0 +1,94 @@
/**
* @fileoverview Dashboard stats helper for Aurora API.
* Provides the getFullDashboardStats function used by stats routes.
*/
import { logger } from "@shared/lib/logger";
/**
* Fetches comprehensive dashboard statistics.
* Aggregates data from multiple services with error isolation.
*
* @returns Full dashboard stats object including bot info, user counts,
* economy data, leaderboards, and system status.
*/
export async function getFullDashboardStats() {
// Import services (dynamic to avoid circular deps)
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
const { getClientStats } = await import("../../../bot/lib/clientStats");
// Fetch all data in parallel with error isolation
const results = await Promise.allSettled([
Promise.resolve(getClientStats()),
dashboardService.getActiveUserCount(),
dashboardService.getTotalUserCount(),
dashboardService.getEconomyStats(),
dashboardService.getRecentEvents(10),
dashboardService.getTotalItems(),
dashboardService.getActiveLootdrops(),
dashboardService.getLeaderboards(),
Promise.resolve(lootdropService.getLootdropState()),
]);
// Helper to unwrap result or return default
const unwrap = <T>(result: PromiseSettledResult<T>, defaultValue: T, name: string): T => {
if (result.status === 'fulfilled') return result.value;
logger.error("web", `Failed to fetch ${name}`, result.reason);
return defaultValue;
};
const clientStats = unwrap(results[0], {
bot: { name: 'Aurora', avatarUrl: null, status: null },
guilds: 0,
commandsRegistered: 0,
commandsKnown: 0,
cachedUsers: 0,
ping: 0,
uptime: 0,
lastCommandTimestamp: null
}, 'clientStats');
const activeUsers = unwrap(results[1], 0, 'activeUsers');
const totalUsers = unwrap(results[2], 0, 'totalUsers');
const economyStats = unwrap(results[3], { totalWealth: 0n, avgLevel: 0, topStreak: 0 }, 'economyStats');
const recentEvents = unwrap(results[4], [], 'recentEvents');
const totalItems = unwrap(results[5], 0, 'totalItems');
const activeLootdrops = unwrap(results[6], [], 'activeLootdrops');
const leaderboards = unwrap(results[7], { topLevels: [], topWealth: [], topNetWorth: [] }, 'leaderboards');
const lootdropState = unwrap(results[8], undefined, 'lootdropState');
return {
bot: clientStats.bot,
guilds: { count: clientStats.guilds },
users: { active: activeUsers, total: totalUsers },
commands: {
total: clientStats.commandsKnown,
active: clientStats.commandsRegistered,
disabled: clientStats.commandsKnown - clientStats.commandsRegistered
},
ping: { avg: clientStats.ping },
economy: {
totalWealth: economyStats.totalWealth.toString(),
avgLevel: economyStats.avgLevel,
topStreak: economyStats.topStreak,
totalItems,
},
recentEvents: recentEvents.map(event => ({
...event,
timestamp: event.timestamp instanceof Date ? event.timestamp.toISOString() : event.timestamp,
})),
activeLootdrops: activeLootdrops.map(drop => ({
rewardAmount: drop.rewardAmount,
currency: drop.currency,
createdAt: drop.createdAt.toISOString(),
expiresAt: drop.expiresAt ? drop.expiresAt.toISOString() : null,
// Explicitly excluding channelId/messageId to prevent sniping
})),
lootdropState,
leaderboards,
uptime: clientStats.uptime,
lastCommandTimestamp: clientStats.lastCommandTimestamp,
maintenanceMode: (await import("../../../bot/lib/BotClient")).AuroraClient.maintenanceMode,
};
}

View File

@@ -0,0 +1,85 @@
/**
* @fileoverview Statistics endpoints for Aurora API.
* Provides dashboard statistics and activity aggregation data.
*/
import type { RouteContext, RouteModule } from "./types";
import { jsonResponse, errorResponse, withErrorHandling } from "./utils";
// Cache for activity stats (heavy aggregation)
let activityPromise: Promise<import("@shared/modules/dashboard/dashboard.types").ActivityData[]> | null = null;
let lastActivityFetch: number = 0;
const ACTIVITY_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
/**
* Stats routes handler.
*
* Endpoints:
* - GET /api/stats - Full dashboard statistics
* - GET /api/stats/activity - Activity aggregation with caching
*/
async function handler(ctx: RouteContext): Promise<Response | null> {
const { pathname, method } = ctx;
/**
* @route GET /api/stats
* @description Returns comprehensive dashboard statistics including
* bot info, user counts, economy data, and leaderboards.
* @response 200 - Full dashboard stats object
* @response 500 - Error fetching statistics
*/
if (pathname === "/api/stats" && method === "GET") {
return withErrorHandling(async () => {
// Import the stats function from wherever it's defined
// This will be passed in during initialization
const { getFullDashboardStats } = await import("./stats.helper.ts");
const stats = await getFullDashboardStats();
return jsonResponse(stats);
}, "fetch dashboard stats");
}
/**
* @route GET /api/stats/activity
* @description Returns activity aggregation data with 5-minute caching.
* Heavy query, results are cached to reduce database load.
* @response 200 - Array of activity data points
* @response 500 - Error fetching activity statistics
*
* @example
* // Response
* [
* { "date": "2024-02-08", "commands": 150, "users": 25 },
* { "date": "2024-02-07", "commands": 200, "users": 30 }
* ]
*/
if (pathname === "/api/stats/activity" && method === "GET") {
return withErrorHandling(async () => {
const now = Date.now();
// If we have a valid cache, return it
if (activityPromise && (now - lastActivityFetch < ACTIVITY_CACHE_TTL)) {
const data = await activityPromise;
return jsonResponse(data);
}
// Otherwise, trigger a new fetch (deduplicated by the promise)
if (!activityPromise || (now - lastActivityFetch >= ACTIVITY_CACHE_TTL)) {
activityPromise = (async () => {
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
return await dashboardService.getActivityAggregation();
})();
lastActivityFetch = now;
}
const activity = await activityPromise;
return jsonResponse(activity);
}, "fetch activity stats");
}
return null;
}
export const statsRoutes: RouteModule = {
name: "stats",
handler
};

View File

@@ -0,0 +1,91 @@
/**
* @fileoverview Transaction listing endpoints for Aurora API.
* Provides read access to economy transaction history.
*/
import type { RouteContext, RouteModule } from "./types";
import { jsonResponse, withErrorHandling } from "./utils";
/**
* Transactions routes handler.
*
* Endpoints:
* - GET /api/transactions - List transactions with filters
*/
async function handler(ctx: RouteContext): Promise<Response | null> {
const { pathname, method, url } = ctx;
/**
* @route GET /api/transactions
* @description Returns economy transactions with optional filtering.
*
* @query userId - Filter by user ID (Discord snowflake)
* @query type - Filter by transaction type
* @query limit - Max results (default: 50)
* @query offset - Pagination offset (default: 0)
*
* @response 200 - `{ transactions: Transaction[] }`
* @response 500 - Error fetching transactions
*
* Transaction Types:
* - DAILY_REWARD - Daily claim reward
* - TRANSFER_IN - Received from another user
* - TRANSFER_OUT - Sent to another user
* - LOOTDROP_CLAIM - Claimed lootdrop
* - SHOP_BUY - Item purchase
* - QUEST_REWARD - Quest completion reward
*
* @example
* // Request
* GET /api/transactions?userId=123456789&type=DAILY_REWARD&limit=10
*
* // Response
* {
* "transactions": [
* {
* "id": "1",
* "userId": "123456789",
* "amount": "100",
* "type": "DAILY_REWARD",
* "description": "Daily reward (Streak: 3)",
* "createdAt": "2024-01-15T12:00:00Z"
* }
* ]
* }
*/
if (pathname === "/api/transactions" && method === "GET") {
return withErrorHandling(async () => {
const { transactions } = await import("@shared/db/schema");
const { DrizzleClient } = await import("@shared/db/DrizzleClient");
const { eq, desc } = await import("drizzle-orm");
const userId = url.searchParams.get("userId");
const type = url.searchParams.get("type");
const limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50;
const offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0;
let query = DrizzleClient.select().from(transactions);
if (userId) {
query = query.where(eq(transactions.userId, BigInt(userId))) as typeof query;
}
if (type) {
query = query.where(eq(transactions.type, type)) as typeof query;
}
const result = await query
.orderBy(desc(transactions.createdAt))
.limit(limit)
.offset(offset);
return jsonResponse({ transactions: result });
}, "fetch transactions");
}
return null;
}
export const transactionsRoutes: RouteModule = {
name: "transactions",
handler
};

94
api/src/routes/types.ts Normal file
View File

@@ -0,0 +1,94 @@
/**
* @fileoverview Shared types for the Aurora API routing system.
* Provides type definitions for route handlers, responses, and errors.
*/
/**
* Standard API error response structure.
*/
export interface ApiErrorResponse {
error: string;
details?: string;
issues?: Array<{ path: (string | number)[]; message: string }>;
}
/**
* Standard API success response with optional data wrapper.
*/
export interface ApiSuccessResponse<T = unknown> {
success: true;
[key: string]: T | true;
}
/**
* Route context passed to all route handlers.
* Contains parsed URL information and the original request.
*/
export interface RouteContext {
/** The original HTTP request */
req: Request;
/** Parsed URL object */
url: URL;
/** HTTP method (GET, POST, PUT, DELETE, etc.) */
method: string;
/** URL pathname without query string */
pathname: string;
}
/**
* A route handler function that processes a request and returns a response.
* Returns null if the route doesn't match, allowing the next handler to try.
*/
export type RouteHandler = (ctx: RouteContext) => Promise<Response | null> | Response | null;
/**
* A route module that exports a handler function.
*/
export interface RouteModule {
/** Human-readable name for debugging */
name: string;
/** The route handler function */
handler: RouteHandler;
}
/**
* Custom API error class with HTTP status code support.
*/
export class ApiError extends Error {
constructor(
message: string,
public readonly status: number = 500,
public readonly details?: string
) {
super(message);
this.name = 'ApiError';
}
/**
* Creates a 400 Bad Request error.
*/
static badRequest(message: string, details?: string): ApiError {
return new ApiError(message, 400, details);
}
/**
* Creates a 404 Not Found error.
*/
static notFound(resource: string): ApiError {
return new ApiError(`${resource} not found`, 404);
}
/**
* Creates a 409 Conflict error.
*/
static conflict(message: string): ApiError {
return new ApiError(message, 409);
}
/**
* Creates a 500 Internal Server Error.
*/
static internal(message: string, details?: string): ApiError {
return new ApiError(message, 500, details);
}
}

View File

@@ -0,0 +1,263 @@
/**
* @fileoverview User management endpoints for Aurora API.
* Provides CRUD operations for users and user inventory.
*/
import type { RouteContext, RouteModule } from "./types";
import {
jsonResponse,
errorResponse,
parseBody,
parseIdFromPath,
parseStringIdFromPath,
withErrorHandling
} from "./utils";
import { UpdateUserSchema, InventoryAddSchema } from "./schemas";
/**
* Users routes handler.
*
* Endpoints:
* - GET /api/users - List users with filters
* - GET /api/users/:id - Get single user
* - PUT /api/users/:id - Update user
* - GET /api/users/:id/inventory - Get user inventory
* - POST /api/users/:id/inventory - Add item to inventory
* - DELETE /api/users/:id/inventory/:itemId - Remove item from inventory
*/
async function handler(ctx: RouteContext): Promise<Response | null> {
const { pathname, method, req, url } = ctx;
// Only handle requests to /api/users*
if (!pathname.startsWith("/api/users")) {
return null;
}
/**
* @route GET /api/users
* @description Returns a paginated list of users with optional filtering and sorting.
*
* @query search - Filter by username (partial match)
* @query sortBy - Sort field: balance, level, xp, username (default: balance)
* @query sortOrder - Sort direction: asc, desc (default: desc)
* @query limit - Max results (default: 50)
* @query offset - Pagination offset (default: 0)
*
* @response 200 - `{ users: User[], total: number }`
* @response 500 - Error fetching users
*
* @example
* // Request
* GET /api/users?sortBy=level&sortOrder=desc&limit=10
*/
if (pathname === "/api/users" && method === "GET") {
return withErrorHandling(async () => {
const { users } = await import("@shared/db/schema");
const { DrizzleClient } = await import("@shared/db/DrizzleClient");
const { ilike, desc, asc, sql } = await import("drizzle-orm");
const search = url.searchParams.get("search") || undefined;
const sortBy = url.searchParams.get("sortBy") || "balance";
const sortOrder = url.searchParams.get("sortOrder") || "desc";
const limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50;
const offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0;
let query = DrizzleClient.select().from(users);
if (search) {
query = query.where(ilike(users.username, `%${search}%`)) as typeof query;
}
const sortColumn = sortBy === "level" ? users.level :
sortBy === "xp" ? users.xp :
sortBy === "username" ? users.username : users.balance;
const orderFn = sortOrder === "asc" ? asc : desc;
const result = await query
.orderBy(orderFn(sortColumn))
.limit(limit)
.offset(offset);
const countResult = await DrizzleClient.select({ count: sql<number>`count(*)` }).from(users);
const total = Number(countResult[0]?.count || 0);
return jsonResponse({ users: result, total });
}, "fetch users");
}
/**
* @route GET /api/users/:id
* @description Returns a single user by Discord ID.
* Includes related class information if the user has a class assigned.
*
* @param id - Discord User ID (snowflake)
* @response 200 - Full user object with class relation
* @response 404 - User not found
* @response 500 - Error fetching user
*
* @example
* // Response
* {
* "id": "123456789012345678",
* "username": "Player1",
* "balance": "1000",
* "level": 5,
* "class": { "id": "1", "name": "Warrior" }
* }
*/
if (pathname.match(/^\/api\/users\/\d+$/) && method === "GET") {
const id = parseStringIdFromPath(pathname);
if (!id) return null;
return withErrorHandling(async () => {
const { userService } = await import("@shared/modules/user/user.service");
const user = await userService.getUserById(id);
if (!user) {
return errorResponse("User not found", 404);
}
return jsonResponse(user);
}, "fetch user");
}
/**
* @route PUT /api/users/:id
* @description Updates user fields. Only provided fields will be updated.
*
* @param id - Discord User ID (snowflake)
* @body {
* username?: string,
* balance?: string | number,
* xp?: string | number,
* level?: number,
* dailyStreak?: number,
* isActive?: boolean,
* settings?: object,
* classId?: string | number
* }
* @response 200 - `{ success: true, user: User }`
* @response 404 - User not found
* @response 500 - Error updating user
*/
if (pathname.match(/^\/api\/users\/\d+$/) && method === "PUT") {
const id = parseStringIdFromPath(pathname);
if (!id) return null;
return withErrorHandling(async () => {
const { userService } = await import("@shared/modules/user/user.service");
const data = await req.json() as Record<string, any>;
const existing = await userService.getUserById(id);
if (!existing) {
return errorResponse("User not found", 404);
}
// Build update data (only allow safe fields)
const updateData: any = {};
if (data.username !== undefined) updateData.username = data.username;
if (data.balance !== undefined) updateData.balance = BigInt(data.balance);
if (data.xp !== undefined) updateData.xp = BigInt(data.xp);
if (data.level !== undefined) updateData.level = parseInt(data.level);
if (data.dailyStreak !== undefined) updateData.dailyStreak = parseInt(data.dailyStreak);
if (data.isActive !== undefined) updateData.isActive = Boolean(data.isActive);
if (data.settings !== undefined) updateData.settings = data.settings;
if (data.classId !== undefined) updateData.classId = BigInt(data.classId);
const updatedUser = await userService.updateUser(id, updateData);
return jsonResponse({ success: true, user: updatedUser });
}, "update user");
}
/**
* @route GET /api/users/:id/inventory
* @description Returns user's inventory with item details.
*
* @param id - Discord User ID (snowflake)
* @response 200 - `{ inventory: InventoryEntry[] }`
* @response 500 - Error fetching inventory
*
* @example
* // Response
* {
* "inventory": [
* {
* "userId": "123456789",
* "itemId": 1,
* "quantity": "5",
* "item": { "id": 1, "name": "Health Potion", ... }
* }
* ]
* }
*/
if (pathname.match(/^\/api\/users\/\d+\/inventory$/) && method === "GET") {
const id = pathname.split("/")[3] || "0";
return withErrorHandling(async () => {
const { inventoryService } = await import("@shared/modules/inventory/inventory.service");
const inventory = await inventoryService.getInventory(id);
return jsonResponse({ inventory });
}, "fetch inventory");
}
/**
* @route POST /api/users/:id/inventory
* @description Adds an item to user's inventory.
*
* @param id - Discord User ID (snowflake)
* @body { itemId: number, quantity: string | number }
* @response 201 - `{ success: true, entry: InventoryEntry }`
* @response 400 - Missing required fields
* @response 500 - Error adding item
*/
if (pathname.match(/^\/api\/users\/\d+\/inventory$/) && method === "POST") {
const id = pathname.split("/")[3] || "0";
return withErrorHandling(async () => {
const { inventoryService } = await import("@shared/modules/inventory/inventory.service");
const data = await req.json() as Record<string, any>;
if (!data.itemId || !data.quantity) {
return errorResponse("Missing required fields: itemId, quantity", 400);
}
const entry = await inventoryService.addItem(id, data.itemId, BigInt(data.quantity));
return jsonResponse({ success: true, entry }, 201);
}, "add item to inventory");
}
/**
* @route DELETE /api/users/:id/inventory/:itemId
* @description Removes an item from user's inventory.
*
* @param id - Discord User ID (snowflake)
* @param itemId - Item ID to remove
* @query amount - Quantity to remove (default: 1)
* @response 204 - Item removed (no content)
* @response 500 - Error removing item
*/
if (pathname.match(/^\/api\/users\/\d+\/inventory\/\d+$/) && method === "DELETE") {
const parts = pathname.split("/");
const userId = parts[3] || "";
const itemId = parseInt(parts[5] || "0");
if (!userId) return null;
return withErrorHandling(async () => {
const { inventoryService } = await import("@shared/modules/inventory/inventory.service");
const amount = url.searchParams.get("amount");
const quantity = amount ? BigInt(amount) : 1n;
await inventoryService.removeItem(userId, itemId, quantity);
return new Response(null, { status: 204 });
}, "remove item from inventory");
}
return null;
}
export const usersRoutes: RouteModule = {
name: "users",
handler
};

213
api/src/routes/utils.ts Normal file
View File

@@ -0,0 +1,213 @@
/**
* @fileoverview Utility functions for Aurora API route handlers.
* Provides helpers for response formatting, parameter parsing, and validation.
*/
import { z, ZodError, type ZodSchema } from "zod";
import type { ApiErrorResponse } from "./types";
/**
* JSON replacer function that handles BigInt serialization.
* Converts BigInt values to strings for JSON compatibility.
*/
export function jsonReplacer(_key: string, value: unknown): unknown {
return typeof value === "bigint" ? value.toString() : value;
}
/**
* Creates a JSON response with proper content-type header and BigInt handling.
*
* @param data - The data to serialize as JSON
* @param status - HTTP status code (default: 200)
* @returns A Response object with JSON content
*
* @example
* return jsonResponse({ items: [...], total: 10 });
* return jsonResponse({ success: true, item }, 201);
*/
export function jsonResponse<T>(data: T, status: number = 200): Response {
return new Response(JSON.stringify(data, jsonReplacer), {
status,
headers: { "Content-Type": "application/json" }
});
}
/**
* Creates a standardized error response.
*
* @param error - Error message
* @param status - HTTP status code (default: 500)
* @param details - Optional additional error details
* @returns A Response object with error JSON
*
* @example
* return errorResponse("Item not found", 404);
* return errorResponse("Validation failed", 400, "Name is required");
*/
export function errorResponse(
error: string,
status: number = 500,
details?: string
): Response {
const body: ApiErrorResponse = { error };
if (details) body.details = details;
return Response.json(body, { status });
}
/**
* Creates a validation error response from a ZodError.
*
* @param zodError - The ZodError from a failed parse
* @returns A 400 Response with validation issue details
*/
export function validationErrorResponse(zodError: ZodError): Response {
return Response.json(
{
error: "Invalid payload",
issues: zodError.issues.map(issue => ({
path: issue.path,
message: issue.message
}))
},
{ status: 400 }
);
}
/**
* Parses and validates a request body against a Zod schema.
*
* @param req - The HTTP request
* @param schema - Zod schema to validate against
* @returns Validated data or an error Response
*
* @example
* const result = await parseBody(req, CreateItemSchema);
* if (result instanceof Response) return result; // Validation failed
* const data = result; // Type-safe validated data
*/
export async function parseBody<T extends ZodSchema>(
req: Request,
schema: T
): Promise<z.infer<T> | Response> {
try {
const rawBody = await req.json();
const parsed = schema.safeParse(rawBody);
if (!parsed.success) {
return validationErrorResponse(parsed.error);
}
return parsed.data;
} catch (e) {
return errorResponse("Invalid JSON body", 400);
}
}
/**
* Parses query parameters against a Zod schema.
*
* @param url - The URL containing query parameters
* @param schema - Zod schema to validate against
* @returns Validated query params or an error Response
*/
export function parseQuery<T extends ZodSchema>(
url: URL,
schema: T
): z.infer<T> | Response {
const params: Record<string, string> = {};
url.searchParams.forEach((value, key) => {
params[key] = value;
});
const parsed = schema.safeParse(params);
if (!parsed.success) {
return validationErrorResponse(parsed.error);
}
return parsed.data;
}
/**
* Extracts a numeric ID from a URL path segment.
*
* @param pathname - The URL pathname
* @param position - Position from the end (0 = last segment, 1 = second-to-last, etc.)
* @returns The parsed integer ID or null if invalid
*
* @example
* parseIdFromPath("/api/items/123") // returns 123
* parseIdFromPath("/api/items/abc") // returns null
* parseIdFromPath("/api/users/456/inventory", 1) // returns 456
*/
export function parseIdFromPath(pathname: string, position: number = 0): number | null {
const segments = pathname.split("/").filter(Boolean);
const segment = segments[segments.length - 1 - position];
if (!segment) return null;
const id = parseInt(segment, 10);
return isNaN(id) ? null : id;
}
/**
* Extracts a string ID (like Discord snowflake) from a URL path segment.
*
* @param pathname - The URL pathname
* @param position - Position from the end (0 = last segment)
* @returns The string ID or null if segment doesn't exist
*/
export function parseStringIdFromPath(pathname: string, position: number = 0): string | null {
const segments = pathname.split("/").filter(Boolean);
const segment = segments[segments.length - 1 - position];
return segment || null;
}
/**
* Checks if a pathname matches a pattern with optional parameter placeholders.
*
* @param pathname - The actual URL pathname
* @param pattern - The pattern to match (use :id for numeric params, :param for string params)
* @returns True if the pattern matches
*
* @example
* matchPath("/api/items/123", "/api/items/:id") // true
* matchPath("/api/items", "/api/items/:id") // false
*/
export function matchPath(pathname: string, pattern: string): boolean {
const pathParts = pathname.split("/").filter(Boolean);
const patternParts = pattern.split("/").filter(Boolean);
if (pathParts.length !== patternParts.length) return false;
return patternParts.every((part, i) => {
if (part.startsWith(":")) return true; // Matches any value
return part === pathParts[i];
});
}
/**
* Wraps an async route handler with consistent error handling.
* Catches all errors and returns appropriate error responses.
*
* @param handler - The async handler function
* @param logContext - Context string for error logging
* @returns A wrapped handler with error handling
*/
export function withErrorHandling(
handler: () => Promise<Response>,
logContext: string
): Promise<Response> {
return handler().catch((error: unknown) => {
// Dynamic import to avoid circular dependencies
return import("@shared/lib/logger").then(({ logger }) => {
logger.error("web", `Error in ${logContext}`, error);
return errorResponse(
`Failed to ${logContext.toLowerCase()}`,
500,
error instanceof Error ? error.message : String(error)
);
});
});
}

View File

@@ -0,0 +1,446 @@
import { describe, test, expect, afterAll, beforeAll, mock } from "bun:test";
import type { WebServerInstance } from "./server";
import { createWebServer } from "./server";
/**
* Items API Integration Tests
*
* Tests the full CRUD functionality for the Items management API.
* Uses mocked database and service layers.
*/
// --- Mock Types ---
interface MockItem {
id: number;
name: string;
description: string | null;
rarity: string;
type: string;
price: bigint | null;
iconUrl: string;
imageUrl: string;
usageData: { consume: boolean; effects: any[] } | null;
}
// --- Mock Data ---
let mockItems: MockItem[] = [
{
id: 1,
name: "Health Potion",
description: "Restores health",
rarity: "C",
type: "CONSUMABLE",
price: 100n,
iconUrl: "/assets/items/1.png",
imageUrl: "/assets/items/1.png",
usageData: { consume: true, effects: [] },
},
{
id: 2,
name: "Iron Sword",
description: "A basic sword",
rarity: "R",
type: "EQUIPMENT",
price: 500n,
iconUrl: "/assets/items/2.png",
imageUrl: "/assets/items/2.png",
usageData: null,
},
];
let mockIdCounter = 3;
// --- Mock Items Service ---
mock.module("@shared/modules/items/items.service", () => ({
itemsService: {
getAllItems: mock(async (filters: any = {}) => {
let filtered = [...mockItems];
if (filters.search) {
const search = filters.search.toLowerCase();
filtered = filtered.filter(
(item) =>
item.name.toLowerCase().includes(search) ||
(item.description?.toLowerCase().includes(search) ?? false)
);
}
if (filters.type) {
filtered = filtered.filter((item) => item.type === filters.type);
}
if (filters.rarity) {
filtered = filtered.filter((item) => item.rarity === filters.rarity);
}
return {
items: filtered,
total: filtered.length,
};
}),
getItemById: mock(async (id: number) => {
return mockItems.find((item) => item.id === id) ?? null;
}),
isNameTaken: mock(async (name: string, excludeId?: number) => {
return mockItems.some(
(item) =>
item.name.toLowerCase() === name.toLowerCase() &&
item.id !== excludeId
);
}),
createItem: mock(async (data: any) => {
const newItem: MockItem = {
id: mockIdCounter++,
name: data.name,
description: data.description ?? null,
rarity: data.rarity ?? "C",
type: data.type,
price: data.price ?? null,
iconUrl: data.iconUrl,
imageUrl: data.imageUrl,
usageData: data.usageData ?? null,
};
mockItems.push(newItem);
return newItem;
}),
updateItem: mock(async (id: number, data: any) => {
const index = mockItems.findIndex((item) => item.id === id);
if (index === -1) return null;
mockItems[index] = { ...mockItems[index], ...data };
return mockItems[index];
}),
deleteItem: mock(async (id: number) => {
const index = mockItems.findIndex((item) => item.id === id);
if (index === -1) return null;
const [deleted] = mockItems.splice(index, 1);
return deleted;
}),
},
}));
// --- Mock Utilities ---
mock.module("@shared/lib/utils", () => ({
deepMerge: (target: any, source: any) => ({ ...target, ...source }),
jsonReplacer: (key: string, value: any) =>
typeof value === "bigint" ? value.toString() : value,
}));
// --- Mock Auth (bypass authentication) ---
mock.module("./routes/auth.routes", () => ({
authRoutes: { name: "auth", handler: () => null },
isAuthenticated: () => true,
getSession: () => ({ discordId: "123", username: "testuser", expiresAt: Date.now() + 3600000 }),
}));
// --- Mock Logger ---
mock.module("@shared/lib/logger", () => ({
logger: {
info: () => { },
warn: () => { },
error: () => { },
debug: () => { },
},
}));
describe("Items API", () => {
const port = 3002;
const hostname = "127.0.0.1";
const baseUrl = `http://${hostname}:${port}`;
let serverInstance: WebServerInstance | null = null;
beforeAll(async () => {
// Reset mock data before all tests
mockItems = [
{
id: 1,
name: "Health Potion",
description: "Restores health",
rarity: "C",
type: "CONSUMABLE",
price: 100n,
iconUrl: "/assets/items/1.png",
imageUrl: "/assets/items/1.png",
usageData: { consume: true, effects: [] },
},
{
id: 2,
name: "Iron Sword",
description: "A basic sword",
rarity: "R",
type: "EQUIPMENT",
price: 500n,
iconUrl: "/assets/items/2.png",
imageUrl: "/assets/items/2.png",
usageData: null,
},
];
mockIdCounter = 3;
serverInstance = await createWebServer({ port, hostname });
});
afterAll(async () => {
if (serverInstance) {
await serverInstance.stop();
}
});
// ===========================================
// GET /api/items Tests
// ===========================================
describe("GET /api/items", () => {
test("should return all items", async () => {
const response = await fetch(`${baseUrl}/api/items`);
expect(response.status).toBe(200);
const data = (await response.json()) as { items: MockItem[]; total: number };
expect(data.items).toBeInstanceOf(Array);
expect(data.total).toBeGreaterThanOrEqual(0);
});
test("should filter items by search query", async () => {
const response = await fetch(`${baseUrl}/api/items?search=potion`);
expect(response.status).toBe(200);
const data = (await response.json()) as { items: MockItem[]; total: number };
expect(data.items.every((item) =>
item.name.toLowerCase().includes("potion") ||
(item.description?.toLowerCase().includes("potion") ?? false)
)).toBe(true);
});
test("should filter items by type", async () => {
const response = await fetch(`${baseUrl}/api/items?type=CONSUMABLE`);
expect(response.status).toBe(200);
const data = (await response.json()) as { items: MockItem[]; total: number };
expect(data.items.every((item) => item.type === "CONSUMABLE")).toBe(true);
});
test("should filter items by rarity", async () => {
const response = await fetch(`${baseUrl}/api/items?rarity=C`);
expect(response.status).toBe(200);
const data = (await response.json()) as { items: MockItem[]; total: number };
expect(data.items.every((item) => item.rarity === "C")).toBe(true);
});
});
// ===========================================
// GET /api/items/:id Tests
// ===========================================
describe("GET /api/items/:id", () => {
test("should return a single item by ID", async () => {
const response = await fetch(`${baseUrl}/api/items/1`);
expect(response.status).toBe(200);
const data = (await response.json()) as MockItem;
expect(data.id).toBe(1);
expect(data.name).toBe("Health Potion");
});
test("should return 404 for non-existent item", async () => {
const response = await fetch(`${baseUrl}/api/items/9999`);
expect(response.status).toBe(404);
const data = (await response.json()) as { error: string };
expect(data.error).toBe("Item not found");
});
});
// ===========================================
// POST /api/items Tests
// ===========================================
describe("POST /api/items", () => {
test("should create a new item", async () => {
const newItem = {
name: "Magic Staff",
description: "A powerful staff",
rarity: "SR",
type: "EQUIPMENT",
price: "1000",
iconUrl: "/assets/items/placeholder.png",
imageUrl: "/assets/items/placeholder.png",
};
const response = await fetch(`${baseUrl}/api/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newItem),
});
expect(response.status).toBe(201);
const data = (await response.json()) as { success: boolean; item: MockItem };
expect(data.success).toBe(true);
expect(data.item.name).toBe("Magic Staff");
expect(data.item.id).toBeGreaterThan(0);
});
test("should reject item without required fields", async () => {
const response = await fetch(`${baseUrl}/api/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ description: "No name or type" }),
});
expect(response.status).toBe(400);
const data = (await response.json()) as { error: string };
expect(data.error).toContain("required");
});
test("should reject duplicate item name", async () => {
const response = await fetch(`${baseUrl}/api/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Health Potion", // Already exists
type: "CONSUMABLE",
iconUrl: "/assets/items/placeholder.png",
imageUrl: "/assets/items/placeholder.png",
}),
});
expect(response.status).toBe(409);
const data = (await response.json()) as { error: string };
expect(data.error).toContain("already exists");
});
});
// ===========================================
// PUT /api/items/:id Tests
// ===========================================
describe("PUT /api/items/:id", () => {
test("should update an existing item", async () => {
const response = await fetch(`${baseUrl}/api/items/1`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
description: "Updated description",
price: "200",
}),
});
expect(response.status).toBe(200);
const data = (await response.json()) as { success: boolean; item: MockItem };
expect(data.success).toBe(true);
expect(data.item.description).toBe("Updated description");
});
test("should return 404 for updating non-existent item", async () => {
const response = await fetch(`${baseUrl}/api/items/9999`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "New Name" }),
});
expect(response.status).toBe(404);
});
test("should reject duplicate name when updating", async () => {
const response = await fetch(`${baseUrl}/api/items/2`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Health Potion", // ID 1 has this name
}),
});
expect(response.status).toBe(409);
});
});
// ===========================================
// DELETE /api/items/:id Tests
// ===========================================
describe("DELETE /api/items/:id", () => {
test("should delete an existing item", async () => {
// First, create an item to delete
const createResponse = await fetch(`${baseUrl}/api/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Item to Delete",
type: "MATERIAL",
iconUrl: "/assets/items/placeholder.png",
imageUrl: "/assets/items/placeholder.png",
}),
});
const { item } = (await createResponse.json()) as { item: MockItem };
// Now delete it
const deleteResponse = await fetch(`${baseUrl}/api/items/${item.id}`, {
method: "DELETE",
});
expect(deleteResponse.status).toBe(204);
// Verify it's gone
const getResponse = await fetch(`${baseUrl}/api/items/${item.id}`);
expect(getResponse.status).toBe(404);
});
test("should return 404 for deleting non-existent item", async () => {
const response = await fetch(`${baseUrl}/api/items/9999`, {
method: "DELETE",
});
expect(response.status).toBe(404);
});
});
// ===========================================
// Static Asset Serving Tests
// ===========================================
describe("Static Asset Serving (/assets/*)", () => {
test("should return 404 for non-existent asset", async () => {
const response = await fetch(`${baseUrl}/assets/items/nonexistent.png`);
expect(response.status).toBe(404);
});
test("should prevent path traversal attacks", async () => {
// Note: fetch() and HTTP servers normalize ".." segments before the handler sees them,
// so we can't send raw traversal paths over HTTP. Instead, test that a suspicious
// asset path (with encoded sequences) doesn't serve sensitive file content.
const response = await fetch(`${baseUrl}/assets/..%2f..%2f..%2fetc%2fpasswd`);
// Should not serve actual file content — expect 403 or 404
expect([403, 404]).toContain(response.status);
});
});
// ===========================================
// Validation Edge Cases
// ===========================================
describe("Validation Edge Cases", () => {
test("should handle empty search query gracefully", async () => {
const response = await fetch(`${baseUrl}/api/items?search=`);
expect(response.status).toBe(200);
});
test("should handle invalid pagination values", async () => {
const response = await fetch(`${baseUrl}/api/items?limit=abc&offset=xyz`);
// Should not crash, may use defaults
expect(response.status).toBe(200);
});
test("should handle missing content-type header", async () => {
const response = await fetch(`${baseUrl}/api/items`, {
method: "POST",
body: JSON.stringify({ name: "Test", type: "MATERIAL" }),
});
// May fail due to no content-type, but shouldn't crash
expect([200, 201, 400, 415]).toContain(response.status);
});
});
});

View File

@@ -0,0 +1,201 @@
import { describe, expect, it, mock, beforeEach, afterEach, jest } from "bun:test";
import { type WebServerInstance } from "./server";
// Mock gameSettingsService — the route now uses this instead of config/saveConfig
const mockSettings = {
leveling: {
base: 100,
exponent: 1.5,
chat: { minXp: 10, maxXp: 20, cooldownMs: 60000 }
},
economy: {
daily: { amount: "100", streakBonus: "10", weeklyBonus: "50", cooldownMs: 86400000 },
transfers: { allowSelfTransfer: false, minAmount: "1" },
exam: { multMin: 1.5, multMax: 2.5 }
},
inventory: { maxStackSize: "99", maxSlots: 20 },
lootdrop: {
spawnChance: 0.1,
cooldownMs: 3600000,
minMessages: 10,
activityWindowMs: 300000,
reward: { min: 100, max: 500, currency: "gold" }
},
commands: { "help": true },
system: {},
moderation: {
prune: { maxAmount: 100, confirmThreshold: 50, batchSize: 100, batchDelayMs: 1000 },
},
trivia: {
entryFee: "50",
rewardMultiplier: 1.5,
timeoutSeconds: 30,
cooldownMs: 60000,
categories: [],
difficulty: "random"
}
};
const mockGetSettings = jest.fn(() => Promise.resolve(mockSettings));
const mockUpsertSettings = jest.fn(() => Promise.resolve(mockSettings));
const mockGetDefaults = jest.fn(() => mockSettings);
mock.module("@shared/modules/game-settings/game-settings.service", () => ({
gameSettingsService: {
getSettings: mockGetSettings,
upsertSettings: mockUpsertSettings,
getDefaults: mockGetDefaults,
invalidateCache: jest.fn(),
}
}));
// Mock DrizzleClient (dependency potentially imported transitively)
mock.module("@shared/db/DrizzleClient", () => ({
DrizzleClient: {}
}));
// Mock @shared/lib/utils (deepMerge is used by settings API)
mock.module("@shared/lib/utils", () => ({
deepMerge: (target: any, source: any) => ({ ...target, ...source }),
jsonReplacer: (key: string, value: any) =>
typeof value === "bigint" ? value.toString() : value,
}));
// Mock BotClient
const mockGuild = {
roles: {
cache: [
{ id: "role1", name: "Admin", hexColor: "#ffffff", position: 1 },
{ id: "role2", name: "User", hexColor: "#000000", position: 0 }
]
},
channels: {
cache: [
{ id: "chan1", name: "general", type: 0 }
]
}
};
mock.module("../../bot/lib/BotClient", () => ({
AuroraClient: {
guilds: {
cache: {
get: () => mockGuild
}
},
commands: [
{ data: { name: "ping" } }
],
knownCommands: new Map([
["ping", "utility"],
["help", "utility"],
["disabled-cmd", "admin"]
])
}
}));
mock.module("@shared/lib/env", () => ({
env: {
DISCORD_GUILD_ID: "123456789"
}
}));
// Mock spawn
mock.module("bun", () => {
return {
spawn: jest.fn(() => ({
unref: () => { }
})),
serve: Bun.serve
};
});
// Mock auth (bypass authentication)
mock.module("./routes/auth.routes", () => ({
authRoutes: { name: "auth", handler: () => null },
isAuthenticated: () => true,
getSession: () => ({ discordId: "123", username: "testuser", expiresAt: Date.now() + 3600000 }),
}));
// Import createWebServer after mocks
import { createWebServer } from "./server";
describe("Settings API", () => {
let serverInstance: WebServerInstance;
const PORT = 3009;
const HOSTNAME = "127.0.0.1";
const BASE_URL = `http://${HOSTNAME}:${PORT}`;
beforeEach(async () => {
jest.clearAllMocks();
mockGetSettings.mockImplementation(() => Promise.resolve(mockSettings));
mockUpsertSettings.mockImplementation(() => Promise.resolve(mockSettings));
serverInstance = await createWebServer({ port: PORT, hostname: HOSTNAME });
});
afterEach(async () => {
if (serverInstance) {
await serverInstance.stop();
}
});
it("GET /api/settings should return current configuration", async () => {
const res = await fetch(`${BASE_URL}/api/settings`);
expect(res.status).toBe(200);
const data = await res.json() as any;
// Check values come through correctly
expect(data.economy.daily.amount).toBe("100");
expect(data.leveling.base).toBe(100);
});
it("POST /api/settings should save valid configuration via merge", async () => {
const partialConfig = { economy: { daily: { amount: "200" } } };
const res = await fetch(`${BASE_URL}/api/settings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(partialConfig)
});
expect(res.status).toBe(200);
// upsertSettings should be called with the partial config
expect(mockUpsertSettings).toHaveBeenCalledWith(
expect.objectContaining({
economy: { daily: { amount: "200" } }
})
);
});
it("POST /api/settings should return 400 when save fails", async () => {
mockUpsertSettings.mockImplementationOnce(() => {
throw new Error("Validation failed");
});
const res = await fetch(`${BASE_URL}/api/settings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});
expect(res.status).toBe(400);
const data = await res.json() as any;
expect(data.details).toBe("Validation failed");
});
it("GET /api/settings/meta should return simplified metadata", async () => {
const res = await fetch(`${BASE_URL}/api/settings/meta`);
expect(res.status).toBe(200);
const data = await res.json() as any;
expect(data.roles).toHaveLength(2);
expect(data.roles[0]).toEqual({ id: "role1", name: "Admin", color: "#ffffff" });
expect(data.channels[0]).toEqual({ id: "chan1", name: "general", type: 0 });
// Check new commands structure
expect(data.commands).toBeArray();
expect(data.commands.length).toBeGreaterThan(0);
expect(data.commands[0]).toHaveProperty("name");
expect(data.commands[0]).toHaveProperty("category");
});
});

160
api/src/server.test.ts Normal file
View File

@@ -0,0 +1,160 @@
import { describe, test, expect, afterAll, mock } from "bun:test";
import type { WebServerInstance } from "./server";
interface MockBotStats {
bot: { name: string; avatarUrl: string | null };
guilds: number;
ping: number;
cachedUsers: number;
commandsRegistered: number;
uptime: number;
lastCommandTimestamp: number | null;
}
// 1. Mock DrizzleClient (dependency of dashboardService)
// Must provide full chainable builder for select().from().leftJoin().groupBy().orderBy().limit()
mock.module("@shared/db/DrizzleClient", () => {
const mockBuilder: Record<string, any> = {};
// Every chainable method returns mock builder; terminal calls return resolved promise
mockBuilder.where = mock(() => Promise.resolve([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]));
mockBuilder.then = (onfulfilled: any) => onfulfilled([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]);
mockBuilder.orderBy = mock(() => mockBuilder);
mockBuilder.limit = mock(() => Promise.resolve([]));
mockBuilder.leftJoin = mock(() => mockBuilder);
mockBuilder.groupBy = mock(() => mockBuilder);
mockBuilder.from = mock(() => mockBuilder);
return {
DrizzleClient: {
select: mock(() => mockBuilder),
query: {
transactions: { findMany: mock(() => Promise.resolve([])) },
moderationCases: { findMany: mock(() => Promise.resolve([])) },
users: {
findFirst: mock(() => Promise.resolve({ username: "test" })),
findMany: mock(() => Promise.resolve([])),
},
lootdrops: { findMany: mock(() => Promise.resolve([])) },
}
},
};
});
// 2. Mock Bot Stats Provider
mock.module("../../bot/lib/clientStats", () => ({
getClientStats: mock((): MockBotStats => ({
bot: { name: "TestBot", avatarUrl: null },
guilds: 5,
ping: 42,
cachedUsers: 100,
commandsRegistered: 10,
uptime: 3600,
lastCommandTimestamp: Date.now(),
})),
}));
// 3. Mock config (used by lootdrop.service.getLootdropState)
mock.module("@shared/lib/config", () => ({
config: {
lootdrop: {
activityWindowMs: 120000,
minMessages: 1,
spawnChance: 1,
cooldownMs: 3000,
reward: { min: 40, max: 150, currency: "Astral Units" }
}
}
}));
// 4. Mock auth (bypass authentication for testing)
mock.module("./routes/auth.routes", () => ({
authRoutes: { name: "auth", handler: () => null },
isAuthenticated: () => true,
getSession: () => ({ discordId: "123", username: "testuser", expiresAt: Date.now() + 3600000 }),
}));
// 5. Mock BotClient (used by stats helper for maintenanceMode)
mock.module("../../bot/lib/BotClient", () => ({
AuroraClient: {
maintenanceMode: false,
guilds: { cache: { get: () => null } },
commands: [],
knownCommands: new Map(),
}
}));
// Import after all mocks are set up
import { createWebServer } from "./server";
describe("WebServer Security & Limits", () => {
const port = 3001;
const hostname = "127.0.0.1";
let serverInstance: WebServerInstance | null = null;
afterAll(async () => {
if (serverInstance) {
await serverInstance.stop();
}
});
test("should reject more than 10 concurrent WebSocket connections", async () => {
serverInstance = await createWebServer({ port, hostname });
const wsUrl = `ws://${hostname}:${port}/ws`;
const sockets: WebSocket[] = [];
try {
// Attempt to open 12 connections (limit is 10)
for (let i = 0; i < 12; i++) {
const ws = new WebSocket(wsUrl);
sockets.push(ws);
await new Promise(resolve => setTimeout(resolve, 5));
}
// Give connections time to settle
await new Promise(resolve => setTimeout(resolve, 800));
const pendingCount = serverInstance.server.pendingWebSockets;
expect(pendingCount).toBeLessThanOrEqual(10);
} finally {
sockets.forEach(s => {
if (s.readyState === WebSocket.OPEN || s.readyState === WebSocket.CONNECTING) {
s.close();
}
});
}
});
test("should return 200 for health check", async () => {
if (!serverInstance) {
serverInstance = await createWebServer({ port, hostname });
}
const response = await fetch(`http://${hostname}:${port}/api/health`);
expect(response.status).toBe(200);
const data = (await response.json()) as { status: string };
expect(data.status).toBe("ok");
});
describe("Administrative Actions", () => {
test("should allow administrative actions without token", async () => {
const response = await fetch(`http://${hostname}:${port}/api/actions/reload-commands`, {
method: "POST"
});
// Should be 200 (OK) or 500 (if underlying service fails, but NOT 401)
expect(response.status).not.toBe(401);
expect(response.status).toBe(200);
});
test("should reject maintenance mode with invalid payload", async () => {
const response = await fetch(`http://${hostname}:${port}/api/actions/maintenance-mode`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ not_enabled: true }) // Wrong field
});
expect(response.status).toBe(400);
const data = await response.json() as { error: string };
expect(data.error).toBe("Invalid payload");
});
});
});

243
api/src/server.ts Normal file
View File

@@ -0,0 +1,243 @@
/**
* @fileoverview API server factory module.
* Exports a function to create and start the API server.
* This allows the server to be started in-process from the main application.
*
* Routes are organized into modular files in the ./routes directory.
* Each route module handles its own validation, business logic, and responses.
*/
import { serve, file } from "bun";
import { logger } from "@shared/lib/logger";
import { handleRequest } from "./routes";
import { getFullDashboardStats } from "./routes/stats.helper";
import { join } from "path";
export interface WebServerConfig {
port?: number;
hostname?: string;
}
export interface WebServerInstance {
server: ReturnType<typeof serve>;
stop: () => Promise<void>;
url: string;
}
/**
* Creates and starts the API server.
*
* @param config - Server configuration options
* @param config.port - Port to listen on (default: 3000)
* @param config.hostname - Hostname to bind to (default: "localhost")
* @returns Promise resolving to server instance with stop() method
*
* @example
* const server = await createWebServer({ port: 3000, hostname: "0.0.0.0" });
* console.log(`Server running at ${server.url}`);
*
* // To stop the server:
* await server.stop();
*/
const MIME_TYPES: Record<string, string> = {
".html": "text/html",
".js": "application/javascript",
".css": "text/css",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
};
/**
* Serve static files from the panel dist directory.
* Falls back to index.html for SPA routing.
*/
async function servePanelStatic(pathname: string, distDir: string): Promise<Response | null> {
// Don't serve panel for API/auth/ws/assets routes
if (pathname.startsWith("/api/") || pathname.startsWith("/auth/") || pathname === "/ws" || pathname.startsWith("/assets/")) {
return null;
}
// Try to serve the exact file
const filePath = join(distDir, pathname);
const bunFile = file(filePath);
if (await bunFile.exists()) {
const ext = pathname.substring(pathname.lastIndexOf("."));
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
return new Response(bunFile, {
headers: {
"Content-Type": contentType,
"Cache-Control": ext === ".html" ? "no-cache" : "public, max-age=31536000, immutable",
},
});
}
// SPA fallback: serve index.html for all non-file routes
const indexFile = file(join(distDir, "index.html"));
if (await indexFile.exists()) {
return new Response(indexFile, {
headers: { "Content-Type": "text/html", "Cache-Control": "no-cache" },
});
}
return null;
}
export async function createWebServer(config: WebServerConfig = {}): Promise<WebServerInstance> {
const { port = 3000, hostname = "localhost" } = config;
// Configuration constants
const MAX_CONNECTIONS = 10;
const MAX_PAYLOAD_BYTES = 16384; // 16KB
const IDLE_TIMEOUT_SECONDS = 60;
// Interval for broadcasting stats to all connected WS clients
let statsBroadcastInterval: Timer | undefined;
const server = serve({
port,
hostname,
async fetch(req, server) {
const url = new URL(req.url);
// WebSocket upgrade handling
if (url.pathname === "/ws") {
const currentConnections = server.pendingWebSockets;
if (currentConnections >= MAX_CONNECTIONS) {
logger.warn("web", `Connection rejected: limit reached (${currentConnections}/${MAX_CONNECTIONS})`);
return new Response("Connection limit reached", { status: 429 });
}
const success = server.upgrade(req);
if (success) return undefined;
return new Response("WebSocket upgrade failed", { status: 400 });
}
// Delegate to modular route handlers
const response = await handleRequest(req, url);
if (response) return response;
// Serve panel static files (production)
const panelDistDir = join(import.meta.dir, "../../panel/dist");
const staticResponse = await servePanelStatic(url.pathname, panelDistDir);
if (staticResponse) return staticResponse;
// No matching route found
return new Response("Not Found", { status: 404 });
},
websocket: {
/**
* Called when a WebSocket client connects.
* Subscribes the client to the dashboard channel and sends initial stats.
*/
open(ws) {
ws.subscribe("dashboard");
logger.debug("web", `Client connected. Total: ${server.pendingWebSockets}`);
// Send initial stats
getFullDashboardStats().then(stats => {
ws.send(JSON.stringify({ type: "STATS_UPDATE", data: stats }));
});
// Start broadcast interval if this is the first client
if (!statsBroadcastInterval) {
statsBroadcastInterval = setInterval(async () => {
try {
const stats = await getFullDashboardStats();
server.publish("dashboard", JSON.stringify({ type: "STATS_UPDATE", data: stats }));
} catch (error) {
logger.error("web", "Error in stats broadcast", error);
}
}, 5000);
}
},
/**
* Called when a WebSocket message is received.
* Handles PING/PONG heartbeat messages.
*/
async message(ws, message) {
try {
const messageStr = message.toString();
// Defense-in-depth: redundant length check before parsing
if (messageStr.length > MAX_PAYLOAD_BYTES) {
logger.error("web", "Payload exceeded maximum limit");
return;
}
const rawData = JSON.parse(messageStr);
const { WsMessageSchema } = await import("@shared/modules/dashboard/dashboard.types");
const parsed = WsMessageSchema.safeParse(rawData);
if (!parsed.success) {
logger.error("web", "Invalid message format", parsed.error.issues);
return;
}
if (parsed.data.type === "PING") {
ws.send(JSON.stringify({ type: "PONG" }));
}
} catch (e) {
logger.error("web", "Failed to handle message", e);
}
},
/**
* Called when a WebSocket client disconnects.
* Stops the broadcast interval if no clients remain.
*/
close(ws) {
ws.unsubscribe("dashboard");
logger.debug("web", `Client disconnected. Total remaining: ${server.pendingWebSockets}`);
// Stop broadcast interval if no clients left
if (server.pendingWebSockets === 0 && statsBroadcastInterval) {
clearInterval(statsBroadcastInterval);
statsBroadcastInterval = undefined;
}
},
maxPayloadLength: MAX_PAYLOAD_BYTES,
idleTimeout: IDLE_TIMEOUT_SECONDS,
},
});
// Listen for real-time events from the system bus
const { systemEvents, EVENTS } = await import("@shared/lib/events");
systemEvents.on(EVENTS.DASHBOARD.NEW_EVENT, (event) => {
server.publish("dashboard", JSON.stringify({ type: "NEW_EVENT", data: event }));
});
const url = `http://${hostname}:${port}`;
return {
server,
url,
stop: async () => {
if (statsBroadcastInterval) {
clearInterval(statsBroadcastInterval);
}
server.stop(true);
},
};
}
/**
* Starts the web server from the main application root.
* Kept for backward compatibility.
*
* @param webProjectPath - Deprecated, no longer used
* @param config - Server configuration options
* @returns Promise resolving to server instance
*/
export async function startWebServerFromRoot(
webProjectPath: string,
config: WebServerConfig = {}
): Promise<WebServerInstance> {
return createWebServer(config);
}

38
api/tsconfig.json Normal file
View File

@@ -0,0 +1,38 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
],
"@shared/*": [
"../shared/*"
],
"@bot/*": [
"../bot/*"
]
},
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
},
"exclude": ["node_modules"]
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@@ -0,0 +1,51 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { moderationService } from "@shared/modules/moderation/moderation.service";
import { getCaseEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
import { withCommandErrorHandling } from "@lib/commandUtils";
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 withCommandErrorHandling(
interaction,
async () => {
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)]
});
},
{ ephemeral: true }
);
}
});

View File

@@ -0,0 +1,51 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
import { moderationService } from "@shared/modules/moderation/moderation.service";
import { getCasesListEmbed } from "@/modules/moderation/moderation.view";
import { withCommandErrorHandling } from "@lib/commandUtils";
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 withCommandErrorHandling(
interaction,
async () => {
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)]
});
},
{ ephemeral: true }
);
}
});

View File

@@ -0,0 +1,81 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
import { moderationService } from "@shared/modules/moderation/moderation.service";
import { getClearSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
import { withCommandErrorHandling } from "@lib/commandUtils";
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 withCommandErrorHandling(
interaction,
async () => {
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)]
});
},
{ ephemeral: true }
);
}
});

View File

@@ -0,0 +1,92 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config";
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { items } from "@db/schema";
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
import { withCommandErrorHandling } from "@lib/commandUtils";
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 withCommandErrorHandling(
interaction,
async () => {
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;
}
// 2. Create Role
const role = await interaction.guild?.roles.create({
name: name,
color: colorInput as any,
reason: `Created via /createcolor by ${interaction.user.tag}`
});
if (!role) {
throw new Error("Failed to create role.");
}
// 3. Add to guild settings
await guildSettingsService.addColorRole(interaction.guildId!, role.id);
invalidateGuildConfigCache(interaction.guildId!);
// 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"
)]
});
},
{ ephemeral: true }
);
}
});

View File

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

View File

@@ -0,0 +1,293 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, Colors, userMention, roleMention, ChatInputCommandInteraction } from "discord.js";
import { featureFlagsService } from "@shared/modules/feature-flags/feature-flags.service";
import { createBaseEmbed, createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const featureflags = createCommand({
data: new SlashCommandBuilder()
.setName("featureflags")
.setDescription("Manage feature flags for beta testing")
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.addSubcommand(sub =>
sub.setName("list")
.setDescription("List all feature flags")
)
.addSubcommand(sub =>
sub.setName("create")
.setDescription("Create a new feature flag")
.addStringOption(opt =>
opt.setName("name")
.setDescription("Name of the feature flag")
.setRequired(true)
)
.addStringOption(opt =>
opt.setName("description")
.setDescription("Description of the feature flag")
.setRequired(false)
)
)
.addSubcommand(sub =>
sub.setName("delete")
.setDescription("Delete a feature flag")
.addStringOption(opt =>
opt.setName("name")
.setDescription("Name of the feature flag")
.setRequired(true)
.setAutocomplete(true)
)
)
.addSubcommand(sub =>
sub.setName("enable")
.setDescription("Enable a feature flag")
.addStringOption(opt =>
opt.setName("name")
.setDescription("Name of the feature flag")
.setRequired(true)
.setAutocomplete(true)
)
)
.addSubcommand(sub =>
sub.setName("disable")
.setDescription("Disable a feature flag")
.addStringOption(opt =>
opt.setName("name")
.setDescription("Name of the feature flag")
.setRequired(true)
.setAutocomplete(true)
)
)
.addSubcommand(sub =>
sub.setName("grant")
.setDescription("Grant access to a feature flag")
.addStringOption(opt =>
opt.setName("name")
.setDescription("Name of the feature flag")
.setRequired(true)
.setAutocomplete(true)
)
.addUserOption(opt =>
opt.setName("user")
.setDescription("User to grant access to")
.setRequired(false)
)
.addRoleOption(opt =>
opt.setName("role")
.setDescription("Role to grant access to")
.setRequired(false)
)
)
.addSubcommand(sub =>
sub.setName("revoke")
.setDescription("Revoke access from a feature flag")
.addIntegerOption(opt =>
opt.setName("id")
.setDescription("Access record ID to revoke")
.setRequired(true)
)
)
.addSubcommand(sub =>
sub.setName("access")
.setDescription("List access records for a feature flag")
.addStringOption(opt =>
opt.setName("name")
.setDescription("Name of the feature flag")
.setRequired(true)
.setAutocomplete(true)
)
),
autocomplete: async (interaction) => {
const focused = interaction.options.getFocused(true);
if (focused.name === "name") {
const flags = await featureFlagsService.listFlags();
const filtered = flags
.filter(f => f.name.toLowerCase().includes(focused.value.toLowerCase()))
.slice(0, 25);
await interaction.respond(
filtered.map(f => ({ name: `${f.name} (${f.enabled ? "enabled" : "disabled"})`, value: f.name }))
);
}
},
execute: async (interaction) => {
await withCommandErrorHandling(
interaction,
async () => {
const subcommand = interaction.options.getSubcommand();
switch (subcommand) {
case "list":
await handleList(interaction);
break;
case "create":
await handleCreate(interaction);
break;
case "delete":
await handleDelete(interaction);
break;
case "enable":
await handleEnable(interaction);
break;
case "disable":
await handleDisable(interaction);
break;
case "grant":
await handleGrant(interaction);
break;
case "revoke":
await handleRevoke(interaction);
break;
case "access":
await handleAccess(interaction);
break;
}
},
{ ephemeral: true }
);
},
});
async function handleList(interaction: ChatInputCommandInteraction) {
const flags = await featureFlagsService.listFlags();
if (flags.length === 0) {
await interaction.editReply({ embeds: [createBaseEmbed("Feature Flags", "No feature flags have been created yet.", Colors.Blue)] });
return;
}
const embed = createBaseEmbed("Feature Flags", undefined, Colors.Blue)
.addFields(
flags.map(f => ({
name: f.name,
value: `${f.enabled ? "✅ Enabled" : "❌ Disabled"}\n${f.description || "*No description*"}`,
inline: false,
}))
);
await interaction.editReply({ embeds: [embed] });
}
async function handleCreate(interaction: ChatInputCommandInteraction) {
const name = interaction.options.getString("name", true);
const description = interaction.options.getString("description");
const flag = await featureFlagsService.createFlag(name, description ?? undefined);
if (!flag) {
await interaction.editReply({ embeds: [createErrorEmbed("Failed to create feature flag.")] });
return;
}
await interaction.editReply({
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" created successfully. Use \`/featureflags enable\` to enable it.`)]
});
}
async function handleDelete(interaction: ChatInputCommandInteraction) {
const name = interaction.options.getString("name", true);
const flag = await featureFlagsService.deleteFlag(name);
await interaction.editReply({
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been deleted.`)]
});
}
async function handleEnable(interaction: ChatInputCommandInteraction) {
const name = interaction.options.getString("name", true);
const flag = await featureFlagsService.setFlagEnabled(name, true);
await interaction.editReply({
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been enabled.`)]
});
}
async function handleDisable(interaction: ChatInputCommandInteraction) {
const name = interaction.options.getString("name", true);
const flag = await featureFlagsService.setFlagEnabled(name, false);
await interaction.editReply({
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been disabled.`)]
});
}
async function handleGrant(interaction: ChatInputCommandInteraction) {
const name = interaction.options.getString("name", true);
const user = interaction.options.getUser("user");
const role = interaction.options.getRole("role");
if (!user && !role) {
await interaction.editReply({
embeds: [createErrorEmbed("You must specify either a user or a role to grant access to.")]
});
return;
}
const access = await featureFlagsService.grantAccess(name, {
userId: user?.id,
roleId: role?.id,
guildId: interaction.guildId!,
});
if (!access) {
await interaction.editReply({ embeds: [createErrorEmbed("Failed to grant access.")] });
return;
}
let target: string;
if (user) {
target = userMention(user.id);
} else if (role) {
target = roleMention(role.id);
} else {
target = "Unknown";
}
await interaction.editReply({
embeds: [createSuccessEmbed(`Access to "**${name}**" granted to ${target} (ID: ${access.id})`)]
});
}
async function handleRevoke(interaction: ChatInputCommandInteraction) {
const id = interaction.options.getInteger("id", true);
const access = await featureFlagsService.revokeAccess(id);
await interaction.editReply({
embeds: [createSuccessEmbed(`Access record #${access.id} has been revoked.`)]
});
}
async function handleAccess(interaction: ChatInputCommandInteraction) {
const name = interaction.options.getString("name", true);
const accessRecords = await featureFlagsService.listAccess(name);
if (accessRecords.length === 0) {
await interaction.editReply({
embeds: [createBaseEmbed("Feature Flag Access", `No access records for "**${name}**".`, Colors.Blue)]
});
return;
}
const fields = accessRecords.map(a => {
let target = "Unknown";
if (a.userId) target = `User: ${userMention(a.userId.toString())}`;
else if (a.roleId) target = `Role: ${roleMention(a.roleId.toString())}`;
else if (a.guildId) target = `Guild: ${a.guildId.toString()}`;
return {
name: `ID: ${a.id}`,
value: target,
inline: true,
};
});
const embed = createBaseEmbed(`Feature Flag Access: ${name}`, undefined, Colors.Blue)
.addFields(fields);
await interaction.editReply({ embeds: [embed] });
}

View File

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

View File

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

View File

@@ -0,0 +1,120 @@
import { createCommand } from "@shared/lib/utils";
import {
SlashCommandBuilder,
type BaseGuildTextChannel,
PermissionFlagsBits,
MessageFlags
} from "discord.js";
import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { createErrorEmbed } from "@lib/embeds";
import { items } from "@db/schema";
import { ilike, isNotNull, and, inArray } from "drizzle-orm";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { getShopListingMessage } from "@/modules/economy/shop.view";
import { EffectType, LootType } from "@shared/lib/constants";
import { withCommandErrorHandling } from "@lib/commandUtils";
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 withCommandErrorHandling(
interaction,
async () => {
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;
}
// Prepare context for lootboxes
const context: { referencedItems: Map<number, { name: string; rarity: string }> } = { referencedItems: new Map() };
const usageData = item.usageData as any;
const lootboxEffect = usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX);
if (lootboxEffect && lootboxEffect.pool) {
const itemIds = lootboxEffect.pool
.filter((drop: any) => drop.type === LootType.ITEM && drop.itemId)
.map((drop: any) => drop.itemId);
if (itemIds.length > 0) {
// Remove duplicates
const uniqueIds = [...new Set(itemIds)] as number[];
const referencedItems = await DrizzleClient.select({
id: items.id,
name: items.name,
rarity: items.rarity
}).from(items).where(inArray(items.id, uniqueIds));
for (const ref of referencedItems) {
context.referencedItems.set(ref.id, { name: ref.name, rarity: ref.rarity || 'C' });
}
}
}
const listingMessage = getShopListingMessage({
...item,
rarity: item.rarity || undefined,
formattedPrice: `${item.price} 🪙`,
price: item.price
}, context);
await targetChannel.send(listingMessage as any);
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
},
{ 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,59 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
import { moderationService } from "@shared/modules/moderation/moderation.service";
import { CaseType } from "@shared/lib/constants";
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
import { withCommandErrorHandling } from "@lib/commandUtils";
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 withCommandErrorHandling(
interaction,
async () => {
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)]
});
},
{ ephemeral: true }
);
}
});

View File

@@ -0,0 +1,40 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
import { moderationService } from "@shared/modules/moderation/moderation.service";
import { getCasesListEmbed } from "@/modules/moderation/moderation.view";
import { withCommandErrorHandling } from "@lib/commandUtils";
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 withCommandErrorHandling(
interaction,
async () => {
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}**`
)]
});
},
{ ephemeral: true }
);
}
});

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

@@ -0,0 +1,164 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
import { config } from "@shared/lib/config";
import { pruneService } from "@shared/modules/moderation/prune.service";
import {
getConfirmationMessage,
getProgressEmbed,
getSuccessEmbed,
getPruneErrorEmbed,
getPruneWarningEmbed,
getCancelledEmbed
} from "@/modules/moderation/prune.view";
import { withCommandErrorHandling } from "@lib/commandUtils";
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 withCommandErrorHandling(
interaction,
async () => {
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)]
});
}
},
{ ephemeral: true }
);
}
});

View File

@@ -0,0 +1,33 @@
import { createCommand } from "@shared/lib/utils";
import { AuroraClient } from "@/lib/BotClient";
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
import { createSuccessEmbed } from "@lib/embeds";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const refresh = createCommand({
data: new SlashCommandBuilder()
.setName("refresh")
.setDescription("Reloads all commands and config without restarting")
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
await withCommandErrorHandling(
interaction,
async () => {
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] });
},
{ ephemeral: true }
);
}
});

View File

@@ -0,0 +1,243 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, Colors, ChatInputCommandInteraction } from "discord.js";
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
import { createBaseEmbed, createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const settings = createCommand({
data: new SlashCommandBuilder()
.setName("settings")
.setDescription("Manage guild settings")
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.addSubcommand(sub =>
sub.setName("show")
.setDescription("Show current guild settings"))
.addSubcommand(sub =>
sub.setName("set")
.setDescription("Set a guild setting")
.addStringOption(opt =>
opt.setName("key")
.setDescription("Setting to change")
.setRequired(true)
.addChoices(
{ name: "Student Role", value: "studentRole" },
{ name: "Visitor Role", value: "visitorRole" },
{ name: "Welcome Channel", value: "welcomeChannel" },
{ name: "Welcome Message", value: "welcomeMessage" },
{ name: "Feedback Channel", value: "feedbackChannel" },
{ name: "Terminal Channel", value: "terminalChannel" },
{ name: "Terminal Message", value: "terminalMessage" },
{ name: "Moderation Log Channel", value: "moderationLogChannel" },
{ name: "DM on Warn", value: "moderationDmOnWarn" },
{ name: "Auto Timeout Threshold", value: "moderationAutoTimeoutThreshold" },
))
.addRoleOption(opt =>
opt.setName("role")
.setDescription("Role value"))
.addChannelOption(opt =>
opt.setName("channel")
.setDescription("Channel value"))
.addStringOption(opt =>
opt.setName("text")
.setDescription("Text value"))
.addIntegerOption(opt =>
opt.setName("number")
.setDescription("Number value"))
.addBooleanOption(opt =>
opt.setName("boolean")
.setDescription("Boolean value (true/false)")))
.addSubcommand(sub =>
sub.setName("reset")
.setDescription("Reset a setting to default")
.addStringOption(opt =>
opt.setName("key")
.setDescription("Setting to reset")
.setRequired(true)
.addChoices(
{ name: "Student Role", value: "studentRole" },
{ name: "Visitor Role", value: "visitorRole" },
{ name: "Welcome Channel", value: "welcomeChannel" },
{ name: "Welcome Message", value: "welcomeMessage" },
{ name: "Feedback Channel", value: "feedbackChannel" },
{ name: "Terminal Channel", value: "terminalChannel" },
{ name: "Terminal Message", value: "terminalMessage" },
{ name: "Moderation Log Channel", value: "moderationLogChannel" },
{ name: "DM on Warn", value: "moderationDmOnWarn" },
{ name: "Auto Timeout Threshold", value: "moderationAutoTimeoutThreshold" },
)))
.addSubcommand(sub =>
sub.setName("colors")
.setDescription("Manage color roles")
.addStringOption(opt =>
opt.setName("action")
.setDescription("Action to perform")
.setRequired(true)
.addChoices(
{ name: "List", value: "list" },
{ name: "Add", value: "add" },
{ name: "Remove", value: "remove" },
))
.addRoleOption(opt =>
opt.setName("role")
.setDescription("Role to add/remove")
.setRequired(false))),
execute: async (interaction) => {
await withCommandErrorHandling(
interaction,
async () => {
const subcommand = interaction.options.getSubcommand();
const guildId = interaction.guildId!;
switch (subcommand) {
case "show":
await handleShow(interaction, guildId);
break;
case "set":
await handleSet(interaction, guildId);
break;
case "reset":
await handleReset(interaction, guildId);
break;
case "colors":
await handleColors(interaction, guildId);
break;
}
},
{ ephemeral: true }
);
},
});
async function handleShow(interaction: ChatInputCommandInteraction, guildId: string) {
const settings = await getGuildConfig(guildId);
const colorRolesDisplay = settings.colorRoles?.length
? settings.colorRoles.map(id => `<@&${id}>`).join(", ")
: "None";
const embed = createBaseEmbed("Guild Settings", undefined, Colors.Blue)
.addFields(
{ name: "Student Role", value: settings.studentRole ? `<@&${settings.studentRole}>` : "Not set", inline: true },
{ name: "Visitor Role", value: settings.visitorRole ? `<@&${settings.visitorRole}>` : "Not set", inline: true },
{ name: "\u200b", value: "\u200b", inline: true },
{ name: "Welcome Channel", value: settings.welcomeChannelId ? `<#${settings.welcomeChannelId}>` : "Not set", inline: true },
{ name: "Feedback Channel", value: settings.feedbackChannelId ? `<#${settings.feedbackChannelId}>` : "Not set", inline: true },
{ name: "Moderation Log", value: settings.moderation?.cases?.logChannelId ? `<#${settings.moderation.cases.logChannelId}>` : "Not set", inline: true },
{ name: "Terminal Channel", value: settings.terminal?.channelId ? `<#${settings.terminal.channelId}>` : "Not set", inline: true },
{ name: "DM on Warn", value: settings.moderation?.cases?.dmOnWarn !== false ? "Enabled" : "Disabled", inline: true },
{ name: "Auto Timeout", value: settings.moderation?.cases?.autoTimeoutThreshold ? `${settings.moderation.cases.autoTimeoutThreshold} warnings` : "Disabled", inline: true },
{ name: "Color Roles", value: colorRolesDisplay, inline: false },
);
if (settings.welcomeMessage) {
embed.addFields({ name: "Welcome Message", value: settings.welcomeMessage.substring(0, 1024), inline: false });
}
await interaction.editReply({ embeds: [embed] });
}
async function handleSet(interaction: ChatInputCommandInteraction, guildId: string) {
const key = interaction.options.getString("key", true);
const role = interaction.options.getRole("role");
const channel = interaction.options.getChannel("channel");
const text = interaction.options.getString("text");
const number = interaction.options.getInteger("number");
const boolean = interaction.options.getBoolean("boolean");
let value: string | number | boolean | null = null;
if (role) value = role.id;
else if (channel) value = channel.id;
else if (text) value = text;
else if (number !== null) value = number;
else if (boolean !== null) value = boolean;
if (value === null) {
await interaction.editReply({
embeds: [createErrorEmbed("Please provide a role, channel, text, number, or boolean value")]
});
return;
}
await guildSettingsService.updateSetting(guildId, key, value);
invalidateGuildConfigCache(guildId);
await interaction.editReply({
embeds: [createSuccessEmbed(`Setting "${key}" updated`)]
});
}
async function handleReset(interaction: ChatInputCommandInteraction, guildId: string) {
const key = interaction.options.getString("key", true);
await guildSettingsService.updateSetting(guildId, key, null);
invalidateGuildConfigCache(guildId);
await interaction.editReply({
embeds: [createSuccessEmbed(`Setting "${key}" reset to default`)]
});
}
async function handleColors(interaction: ChatInputCommandInteraction, guildId: string) {
const action = interaction.options.getString("action", true);
const role = interaction.options.getRole("role");
switch (action) {
case "list": {
const settings = await getGuildConfig(guildId);
const colorRoles = settings.colorRoles ?? [];
if (colorRoles.length === 0) {
await interaction.editReply({
embeds: [createBaseEmbed("Color Roles", "No color roles configured.", Colors.Blue)]
});
return;
}
const embed = createBaseEmbed("Color Roles", undefined, Colors.Blue)
.addFields({
name: `Configured Roles (${colorRoles.length})`,
value: colorRoles.map(id => `<@&${id}>`).join("\n"),
});
await interaction.editReply({ embeds: [embed] });
break;
}
case "add": {
if (!role) {
await interaction.editReply({
embeds: [createErrorEmbed("Please specify a role to add.")]
});
return;
}
await guildSettingsService.addColorRole(guildId, role.id);
invalidateGuildConfigCache(guildId);
await interaction.editReply({
embeds: [createSuccessEmbed(`Added <@&${role.id}> to color roles.`)]
});
break;
}
case "remove": {
if (!role) {
await interaction.editReply({
embeds: [createErrorEmbed("Please specify a role to remove.")]
});
return;
}
await guildSettingsService.removeColorRole(guildId, role.id);
invalidateGuildConfigCache(guildId);
await interaction.editReply({
embeds: [createSuccessEmbed(`Removed <@&${role.id}> from color roles.`)]
});
break;
}
}
}

View File

@@ -0,0 +1,37 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js";
import { terminalService } from "@shared/modules/terminal/terminal.service";
import { createErrorEmbed } from "@/lib/embeds";
import { withCommandErrorHandling } from "@lib/commandUtils";
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 withCommandErrorHandling(
interaction,
async () => {
await terminalService.init(channel as TextChannel);
await interaction.editReply({ content: "✅ Terminal initialized!" });
},
{ ephemeral: true }
);
}
}
});

View File

@@ -0,0 +1,90 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { moderationService } from "@shared/modules/moderation/moderation.service";
import {
getWarnSuccessEmbed,
getModerationErrorEmbed,
} from "@/modules/moderation/moderation.view";
import { getGuildConfig } from "@shared/lib/config";
import { withCommandErrorHandling } from "@lib/commandUtils";
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 withCommandErrorHandling(
interaction,
async () => {
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;
}
// Fetch guild config for moderation settings
const guildConfig = await getGuildConfig(interaction.guildId!);
// 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),
config: {
dmOnWarn: guildConfig.moderation?.cases?.dmOnWarn,
autoTimeoutThreshold: guildConfig.moderation?.cases?.autoTimeoutThreshold,
},
});
// 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
});
}
},
{ ephemeral: true }
);
}
});

View File

@@ -0,0 +1,36 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
import { moderationService } from "@shared/modules/moderation/moderation.service";
import { getWarningsEmbed } from "@/modules/moderation/moderation.view";
import { withCommandErrorHandling } from "@lib/commandUtils";
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 withCommandErrorHandling(
interaction,
async () => {
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)]
});
},
{ ephemeral: true }
);
}
});

View File

@@ -0,0 +1,54 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { createErrorEmbed } from "@/lib/embeds";
import { sendWebhookMessage } from "@/lib/webhookUtils";
import { withCommandErrorHandling } from "@lib/commandUtils";
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 withCommandErrorHandling(
interaction,
async () => {
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;
}
await sendWebhookMessage(
channel,
payload,
interaction.client.user,
`Proxy message requested by ${interaction.user.tag}`
);
await interaction.editReply({ content: "Message sent successfully!" });
},
{ ephemeral: true }
);
}
});

View File

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

View File

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

View File

@@ -0,0 +1,72 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { examService, ExamStatus } from "@shared/modules/economy/exam.service";
import { withCommandErrorHandling } from "@lib/commandUtils";
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 withCommandErrorHandling(
interaction,
async () => {
// First, try to take the exam or check status
const result = await examService.takeExam(interaction.user.id);
if (result.status === ExamStatus.NOT_REGISTERED) {
// Register the user
const regResult = await examService.registerForExam(interaction.user.id, interaction.user.username);
const nextRegTimestamp = Math.floor(regResult.nextExamAt!.getTime() / 1000);
await interaction.editReply({
embeds: [createSuccessEmbed(
`You have registered for the exam! Your exam day is **${DAYS[regResult.examDay!]}** (Server Time).\n` +
`Come back on <t:${nextRegTimestamp}:D> (<t:${nextRegTimestamp}:R>) to take your first exam!`,
"Exam Registration Successful"
)]
});
return;
}
const nextExamTimestamp = Math.floor(result.nextExamAt!.getTime() / 1000);
if (result.status === ExamStatus.COOLDOWN) {
await interaction.editReply({
embeds: [createErrorEmbed(
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
`Next exam available: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`
)]
});
return;
}
if (result.status === ExamStatus.MISSED) {
await interaction.editReply({
embeds: [createErrorEmbed(
`You missed your exam day! Your exam day is **${DAYS[result.examDay!]}** (Server Time).\n` +
`You verify your attendance but score a **0**.\n` +
`Your next exam opportunity is: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`,
"Exam Failed"
)]
});
return;
}
// If it reached here with AVAILABLE, it means they passed
await interaction.editReply({
embeds: [createSuccessEmbed(
`**XP Gained:** ${result.xpDiff?.toString()}\n` +
`**Multiplier:** x${result.multiplier?.toFixed(2)}\n` +
`**Reward:** ${result.reward?.toString()} Currency\n\n` +
`See you next week: <t:${nextExamTimestamp}:D>`,
"Exam Passed!"
)]
});
}
);
}
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,74 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js";
import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { userService } from "@shared/modules/user/user.service";
import { createErrorEmbed } from "@lib/embeds";
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
import { withCommandErrorHandling } from "@lib/commandUtils";
import { getGuildConfig } from "@shared/lib/config";
export const use = createCommand({
data: new SlashCommandBuilder()
.setName("use")
.setDescription("Use an item from your inventory")
.addNumberOption(option =>
option.setName("item")
.setDescription("The item to use")
.setRequired(true)
.setAutocomplete(true)
),
execute: async (interaction) => {
await withCommandErrorHandling(
interaction,
async () => {
const guildConfig = await getGuildConfig(interaction.guildId!);
const colorRoles = guildConfig.colorRoles ?? [];
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;
}
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 = 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, files } = getItemUseResultEmbed(result.results, result.item);
await interaction.editReply({ embeds: [embed], files });
}
);
},
autocomplete: async (interaction) => {
const focusedValue = interaction.options.getFocused();
const userId = interaction.user.id;
const results = await inventoryService.getAutocompleteItems(userId, focusedValue);
await interaction.respond(results);
}
});

View File

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

View File

@@ -0,0 +1,83 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, MessageFlags } from "discord.js";
import { questService } from "@shared/modules/quest/quest.service";
import { createSuccessEmbed } from "@lib/embeds";
import {
getQuestListComponents,
getAvailableQuestsComponents,
getQuestActionRows
} from "@/modules/quest/quest.view";
export const quests = createCommand({
data: new SlashCommandBuilder()
.setName("quests")
.setDescription("View your active and available quests"),
execute: async (interaction) => {
const response = await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const userId = interaction.user.id;
const updateView = async (viewType: 'active' | 'available') => {
const userQuests = await questService.getUserQuests(userId);
const availableQuests = await questService.getAvailableQuests(userId);
const containers = viewType === 'active'
? getQuestListComponents(userQuests)
: getAvailableQuestsComponents(availableQuests);
const actionRows = getQuestActionRows(viewType);
await interaction.editReply({
content: null,
embeds: null as any,
components: [...containers, ...actionRows] as any,
flags: MessageFlags.IsComponentsV2,
allowedMentions: { parse: [] }
});
};
// Initial view
await updateView('active');
const collector = response.createMessageComponentCollector({
time: 120000, // 2 minutes
componentType: undefined // Allow buttons
});
collector.on('collect', async (i) => {
if (i.user.id !== interaction.user.id) return;
try {
if (i.customId === "quest_view_active") {
await i.deferUpdate();
await updateView('active');
} else if (i.customId === "quest_view_available") {
await i.deferUpdate();
await updateView('available');
} else if (i.customId.startsWith("quest_accept:")) {
const questIdStr = i.customId.split(":")[1];
if (!questIdStr) return;
const questId = parseInt(questIdStr);
await questService.assignQuest(userId, questId);
await i.reply({
embeds: [createSuccessEmbed(`You have accepted a new quest!`, "Quest Accepted")],
flags: MessageFlags.Ephemeral
});
await updateView('active');
}
} catch (error) {
console.error("Quest interaction error:", error);
await i.followUp({
content: "Something went wrong while processing your quest interaction.",
flags: MessageFlags.Ephemeral
});
}
});
collector.on('end', () => {
interaction.editReply({ components: [] }).catch(() => {});
});
}
});

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

53
bot/index.ts Normal file
View File

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

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

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

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

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

View File

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

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

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

View File

@@ -0,0 +1,147 @@
import { describe, it, expect, mock, beforeEach, spyOn } from "bun:test";
import { UserError } from "@shared/lib/errors";
// --- Mocks ---
const mockDeferReply = mock(() => Promise.resolve());
const mockEditReply = mock(() => Promise.resolve());
const mockInteraction = {
deferReply: mockDeferReply,
editReply: mockEditReply,
} as any;
const mockCreateErrorEmbed = mock((msg: string) => ({ description: msg, type: "error" }));
mock.module("./embeds", () => ({
createErrorEmbed: mockCreateErrorEmbed,
}));
// Import AFTER mocking
const { withCommandErrorHandling } = await import("./commandUtils");
// --- Tests ---
describe("withCommandErrorHandling", () => {
let consoleErrorSpy: ReturnType<typeof spyOn>;
beforeEach(() => {
mockDeferReply.mockClear();
mockEditReply.mockClear();
mockCreateErrorEmbed.mockClear();
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => { });
});
it("should always call deferReply", async () => {
await withCommandErrorHandling(
mockInteraction,
async () => "result"
);
expect(mockDeferReply).toHaveBeenCalledTimes(1);
});
it("should pass ephemeral option to deferReply", async () => {
await withCommandErrorHandling(
mockInteraction,
async () => "result",
{ ephemeral: true }
);
expect(mockDeferReply).toHaveBeenCalledWith({ ephemeral: true });
});
it("should return the operation result on success", async () => {
const result = await withCommandErrorHandling(
mockInteraction,
async () => ({ data: "test" })
);
expect(result).toEqual({ data: "test" });
});
it("should call onSuccess with the result", async () => {
const onSuccess = mock(async (_result: string) => { });
await withCommandErrorHandling(
mockInteraction,
async () => "hello",
{ onSuccess }
);
expect(onSuccess).toHaveBeenCalledWith("hello");
});
it("should send successMessage when no onSuccess is provided", async () => {
await withCommandErrorHandling(
mockInteraction,
async () => "result",
{ successMessage: "It worked!" }
);
expect(mockEditReply).toHaveBeenCalledWith({
content: "It worked!",
});
});
it("should prefer onSuccess over successMessage", async () => {
const onSuccess = mock(async (_result: string) => { });
await withCommandErrorHandling(
mockInteraction,
async () => "result",
{ successMessage: "This should not be sent", onSuccess }
);
expect(onSuccess).toHaveBeenCalledTimes(1);
// editReply should NOT have been called with the successMessage
expect(mockEditReply).not.toHaveBeenCalledWith({
content: "This should not be sent",
});
});
it("should show error embed for UserError", async () => {
const result = await withCommandErrorHandling(
mockInteraction,
async () => {
throw new UserError("You can't do that!");
}
);
expect(result).toBeUndefined();
expect(mockCreateErrorEmbed).toHaveBeenCalledWith("You can't do that!");
expect(mockEditReply).toHaveBeenCalledTimes(1);
});
it("should show generic error and log for unexpected errors", async () => {
const unexpectedError = new Error("Database exploded");
const result = await withCommandErrorHandling(
mockInteraction,
async () => {
throw unexpectedError;
}
);
expect(result).toBeUndefined();
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Unexpected error in command:",
unexpectedError
);
expect(mockCreateErrorEmbed).toHaveBeenCalledWith(
"An unexpected error occurred."
);
expect(mockEditReply).toHaveBeenCalledTimes(1);
});
it("should return undefined on error", async () => {
const result = await withCommandErrorHandling(
mockInteraction,
async () => {
throw new Error("fail");
}
);
expect(result).toBeUndefined();
});
});

79
bot/lib/commandUtils.ts Normal file
View File

@@ -0,0 +1,79 @@
import type { ChatInputCommandInteraction } from "discord.js";
import { UserError } from "@shared/lib/errors";
import { createErrorEmbed } from "./embeds";
/**
* Wraps a command's core logic with standardized error handling.
*
* - Calls `interaction.deferReply()` automatically
* - On success, invokes `onSuccess` callback or sends `successMessage`
* - On `UserError`, shows the error message in an error embed
* - On unexpected errors, logs to console and shows a generic error embed
*
* @example
* ```typescript
* export const myCommand = createCommand({
* execute: async (interaction) => {
* await withCommandErrorHandling(
* interaction,
* async () => {
* const result = await doSomething();
* await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
* }
* );
* }
* });
* ```
*
* @example
* ```typescript
* // With deferReply options (e.g. ephemeral)
* await withCommandErrorHandling(
* interaction,
* async () => doSomething(),
* {
* ephemeral: true,
* successMessage: "Done!",
* }
* );
* ```
*/
export async function withCommandErrorHandling<T>(
interaction: ChatInputCommandInteraction,
operation: () => Promise<T>,
options?: {
/** Message to send on success (if no onSuccess callback is provided) */
successMessage?: string;
/** Callback invoked with the operation result on success */
onSuccess?: (result: T) => Promise<void>;
/** Whether the deferred reply should be ephemeral */
ephemeral?: boolean;
}
): Promise<T | undefined> {
try {
await interaction.deferReply({ ephemeral: options?.ephemeral });
const result = await operation();
if (options?.onSuccess) {
await options.onSuccess(result);
} else if (options?.successMessage) {
await interaction.editReply({
content: options.successMessage,
});
}
return result;
} catch (error) {
if (error instanceof UserError) {
await interaction.editReply({
embeds: [createErrorEmbed(error.message)],
});
} else {
console.error("Unexpected error in command:", error);
await interaction.editReply({
embeds: [createErrorEmbed("An unexpected error occurred.")],
});
}
return undefined;
}
}

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

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

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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,81 @@
import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
import { AuroraClient } from "@/lib/BotClient";
import { userService } from "@shared/modules/user/user.service";
import { featureFlagsService } from "@shared/modules/feature-flags/feature-flags.service";
import { createErrorEmbed } from "@lib/embeds";
import { logger } from "@shared/lib/logger";
/**
* Handles slash command execution
* Includes user validation and comprehensive error handling
*/
export class CommandHandler {
static async handle(interaction: ChatInputCommandInteraction): Promise<void> {
const command = AuroraClient.commands.get(interaction.commandName);
if (!command) {
logger.error("bot", `No command matching ${interaction.commandName} was found.`);
return;
}
// Check maintenance mode
if (AuroraClient.maintenanceMode) {
const errorEmbed = createErrorEmbed('The bot is currently undergoing maintenance. Please try again later.');
await interaction.reply({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
return;
}
// Check beta feature access
if (command.beta) {
const flagName = command.featureFlag || interaction.commandName;
let memberRoles: string[] = [];
if (interaction.member && 'roles' in interaction.member) {
const roles = interaction.member.roles;
if (typeof roles === 'object' && 'cache' in roles) {
memberRoles = [...roles.cache.keys()];
} else if (Array.isArray(roles)) {
memberRoles = roles;
}
}
const hasAccess = await featureFlagsService.hasAccess(flagName, {
guildId: interaction.guildId!,
userId: interaction.user.id,
memberRoles,
});
if (!hasAccess) {
const errorEmbed = createErrorEmbed(
"This feature is currently in beta testing and not available to all users. " +
"Stay tuned for the official release!",
"Beta Feature"
);
await interaction.reply({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
return;
}
}
// Ensure user exists in database
try {
await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
} catch (error) {
logger.error("bot", "Failed to ensure user exists", error);
}
try {
await command.execute(interaction);
AuroraClient.lastCommandTimestamp = Date.now();
} catch (error) {
logger.error("bot", `Error executing command ${interaction.commandName}`, error);
const errorEmbed = createErrorEmbed('There was an error while executing this command!');
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
} else {
await interaction.reply({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
}
}
}
}

View File

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

View File

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

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