129 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
259 changed files with 26321 additions and 10757 deletions

View File

@@ -1,63 +0,0 @@
---
description: Converts conversational brain dumps into structured, metric-driven Markdown tickets in the ./tickets directory.
---
# WORKFLOW: PRAGMATIC ARCHITECT TICKET GENERATOR
## 1. High-Level Goal
Transform informal user "brain dumps" into high-precision, metric-driven engineering tickets stored as Markdown files in the `./tickets/` directory. The workflow enforces a quality gate via targeted inquiry before any file persistence occurs, ensuring all tasks are observable, measurable, and actionable.
## 2. Assumptions & Clarifications
- **Assumptions:** The agent has write access to the `./tickets/` and `/temp/` directories. The current date is accessible for naming conventions. "Metrics" refer to quantifiable constraints (latency, line counts, status codes).
- **Ambiguities:** If the user provides a second brain dump while a ticket is in progress, the agent will prioritize the current workflow until completion or explicit cancellation.
## 3. Stage Breakdown
### Stage 1: Discovery & Quality Gate
- **Stage Name:** Requirement Analysis
- **Purpose:** Analyze input for vagueness and enforce the "Quality Gate" by extracting metrics.
- **Inputs:** Raw user brain dump (text).
- **Actions:** 1. Identify "Known Unknowns" (vague terms like "fast," "better," "clean").
2. Formulate exactly three (3) targeted questions to convert vague goals into comparable metrics.
3. Check for logical inconsistencies in the request.
- **Outputs:** Three questions presented to the user.
- **Persistence Strategy:** Save the original brain dump and the three questions to `/temp/pending_ticket_state.json`.
### Stage 2: Drafting & Refinement
- **Stage Name:** Ticket Drafting
- **Purpose:** Synthesize the original dump and user answers into a structured Markdown draft.
- **Inputs:** User responses to the three questions; `/temp/pending_ticket_state.json`.
- **Actions:** 1. Construct a Markdown draft using the provided template.
2. Generate a slug-based filename: `YYYYMMDD-slug.md`.
3. Present the draft and filename to the user for review.
- **Outputs:** Formatted Markdown text and suggested filename displayed in the chat.
- **Persistence Strategy:** Update `/temp/pending_ticket_state.json` with the full Markdown content and the proposed filename.
### Stage 3: Execution & Persistence
- **Stage Name:** Finalization
- **Purpose:** Commit the approved ticket to the permanent `./tickets/` directory.
- **Inputs:** User confirmation (e.g., "Go," "Approved"); `/temp/pending_ticket_state.json`.
- **Actions:** 1. Write the finalized Markdown content to `./tickets/[filename]`.
2. Delete the temporary state file in `/temp/`.
- **Outputs:** Confirmation message containing the relative path to the new file.
- **Persistence Strategy:** Permanent write to `./tickets/`.
## 4. Data & File Contracts
- **State File:** `/temp/pending_ticket_state.json`
- Schema: `{ "original_input": string, "questions": string[], "answers": string[], "draft_content": string, "filename": string, "step": integer }`
- **Output File:** `./tickets/YYYYMMDD-[slug].md`
- Format: Markdown
- Sections: `# Title`, `## Context`, `## Acceptance Criteria`, `## Suggested Affected Files`, `## Technical Constraints`.
## 5. Failure & Recovery Handling
- **Incomplete Inputs:** If the user fails to answer the 3 questions, the agent must politely restate that metrics are required for high-precision engineering and repeat the questions.
- **Inconsistencies:** If the users answers contradict the original dump, the agent must flag the contradiction and ask for a tie-break before drafting.
- **Missing Directory:** If `./tickets/` does not exist during Stage 3, the agent must attempt to create it before writing the file.
## 6. Final Deliverable Specification
- **Format:** A valid Markdown file in the `./tickets/` folder.
- **Quality Bar:**
- Zero fluff in the Context section.
- All Acceptance Criteria must be binary (pass/fail) or metric-based.
- Filename must strictly follow `YYYYMMDD-slug.md` (e.g., `20240520-auth-refactor.md`).
- No "Status" or "Priority" fields.

View File

@@ -1,89 +0,0 @@
---
description: Analyzes the codebase to find dependencies and side effects related to a specific ticket.
---
# WORKFLOW: Dependency Architect & Blast Radius Analysis
## 1. High-Level Goal
Perform a deterministic "Blast Radius" analysis for a code change defined in a Jira/Linear-style ticket. The agent will identify direct consumers, side effects, and relevant test suites, then append a structured "Impact Analysis" section to the original ticket file to guide developers and ensure high-velocity execution without regressions.
## 2. Assumptions & Clarifications
- **Location:** Tickets are stored in the `./tickets/` directory as Markdown files.
- **Code Access:** The agent has full read access to the project root and subdirectories.
- **Scope:** Dependency tracing is limited to "one level deep" (direct imports/references) unless a global configuration or core database schema change is detected.
- **Ambiguity Handling:** If "Suggested Affected Files" are missing from the ticket, the agent will attempt to infer them from the "Acceptance Criteria" logic; if inference is impossible, the agent will halt and request the file list.
## 3. Stage Breakdown
### Stage 1: Ticket Parsing & Context Extraction
- **Purpose:** Extract the specific files and logic constraints requiring analysis.
- **Inputs:** A specific ticket filename (e.g., `./tickets/TASK-123.md`).
- **Actions:**
1. Read the ticket file.
2. Extract the list of "Suggested Affected Files".
3. Extract keywords and logic from the "Acceptance Criteria".
4. Validate that all "Suggested Affected Files" exist in the current codebase.
- **Outputs:** A JSON object containing the target file list and key logic requirements.
- **Persistence Strategy:** Save extracted data to `/temp/context.json`.
### Stage 2: Recursive Dependency Mapping
- **Purpose:** Identify which external modules rely on the target files.
- **Inputs:** `/temp/context.json`.
- **Actions:**
1. For each file in the target list, perform a search (e.g., `grep` or AST walk) for import statements or references in the rest of the codebase.
2. Filter out internal references within the same module (focus on external consumers).
3. Detect if the change involves shared utilities (e.g., `utils/`, `common/`) or database schemas (e.g., `prisma/schema.prisma`).
- **Outputs:** A list of unique consumer file paths and their specific usage context.
- **Persistence Strategy:** Save findings to `/temp/dependencies.json`.
### Stage 3: Test Suite Identification
- **Purpose:** Locate the specific test files required to validate the change.
- **Inputs:** `/temp/context.json` and `/temp/dependencies.json`.
- **Actions:**
1. Search for files following patterns: `[filename].test.ts`, `[filename].spec.js`, or within `__tests__` folders related to affected files.
2. Identify integration or E2E tests that cover the consumer paths identified in Stage 2.
- **Outputs:** A list of relevant test file paths.
- **Persistence Strategy:** Save findings to `/temp/tests.json`.
### Stage 4: Risk Hotspot Synthesis
- **Purpose:** Interpret raw dependency data into actionable risk warnings.
- **Inputs:** All files in `/temp/`.
- **Actions:**
1. Analyze the volume of consumers; if a file has >5 consumers, flag it as a "High Impact Hotspot."
2. Check for breaking contract changes (e.g., interface modifications) based on the "Acceptance Criteria".
3. Formulate specific "Risk Hotspot" warnings (e.g., "Changing Auth interface affects 12 files; consider a wrapper.").
- **Outputs:** A structured Markdown-ready report object.
- **Persistence Strategy:** Save final report data to `/temp/final_analysis.json`.
### Stage 5: Ticket Augmentation & Finalization
- **Purpose:** Update the physical ticket file with findings.
- **Inputs:** Original ticket file and `/temp/final_analysis.json`.
- **Actions:**
1. Read the current content of the ticket file.
2. Generate a Markdown section titled `## Impact Analysis (Generated: 2026-01-09)`.
3. Append the Direct Consumers, Test Coverage, and Risk Hotspots sections.
4. Write the combined content back to the original file path.
- **Outputs:** Updated Markdown ticket.
- **Persistence Strategy:** None (Final Action).
## 4. Data & File Contracts
- **State File (`/temp/state.json`):** - `affected_files`: string[]
- `consumers`: { path: string, context: string }[]
- `tests`: string[]
- `risks`: string[]
- **File Format:** All `/temp` files must be valid JSON.
- **Ticket Format:** Standard Markdown. Use `###` for sub-headers in the generated section.
## 5. Failure & Recovery Handling
- **Missing Ticket:** If the ticket path is invalid, exit immediately with error: "TICKET_NOT_FOUND".
- **Zero Consumers Found:** If no external consumers are found, state "No external dependencies detected" in the report; do not fail.
- **Broken Imports:** If AST parsing fails due to syntax errors in the codebase, fallback to `grep` for string-based matching.
- **Write Permission:** If the ticket file is read-only, output the final Markdown to the console and provide a warning.
## 6. Final Deliverable Specification
- **Format:** The original ticket file must be modified in-place.
- **Content:**
- **Direct Consumers:** Bulleted list of `[File Path]: [Usage description]`.
- **Test Coverage:** Bulleted list of `[File Path]`.
- **Risk Hotspots:** Clear, one-sentence warnings for high-risk areas.
- **Quality Bar:** No hallucinations. Every file path listed must exist in the repository. No deletions of original ticket content.

View File

@@ -1,72 +0,0 @@
---
description: Performs a high-intensity, "hostile" technical audit of the provided code.
---
# WORKFLOW: HOSTILE TECHNICAL AUDIT & SECURITY REVIEW
## 1. High-Level Goal
Execute a multi-pass, hyper-critical technical audit of provided source code to identify fatal logic flaws, security vulnerabilities, and architectural debt. The agent acts as a hostile reviewer with a "guilty until proven innocent" mindset, aiming to justify a REJECTED verdict unless the code demonstrates exceptional robustness and simplicity.
## 2. Assumptions & Clarifications
- **Assumption:** The user will provide either raw code snippets or paths to files within the agent's accessible environment.
- **Assumption:** The agent has access to `/temp/` for multi-stage state persistence.
- **Clarification:** If a "ticket description" or "requirement" is not provided, the agent will infer intent from the code but must flag "Lack of Context" as a potential risk.
- **Clarification:** "Hostile" refers to a rigorous, zero-tolerance standard, not unprofessional language.
## 3. Stage Breakdown
### Stage 1: Contextual Ingestion & Dependency Mapping
- **Purpose:** Map the attack surface and understand the logical flow before the audit.
- **Inputs:** Target source code files.
- **Actions:** - Identify all external dependencies and entry points.
- Map data flow from input to storage/output.
- Identify "High-Risk Zones" (e.g., auth logic, DB queries, memory management).
- **Outputs:** A structured map of the code's architecture.
- **Persistence Strategy:** Save `audit_map.json` to `/temp/` containing the file list and identified High-Risk Zones.
### Stage 2: Security & Logic Stress Test (The "Hostile" Pass)
- **Purpose:** Identify reasons to reject the code based on security and logical integrity.
- **Inputs:** `/temp/audit_map.json` and source code.
- **Actions:**
- Scan for injection, race conditions, and improper state handling.
- Simulate edge cases: null inputs, buffer overflows, and malformed data.
- Evaluate "Silent Failures": Does the code swallow exceptions or fail to log critical errors?
- **Outputs:** List of fatal flaws and security risks.
- **Persistence Strategy:** Save `vulnerabilities.json` to `/temp/`.
### Stage 3: Performance & Velocity Debt Assessment
- **Purpose:** Evaluate the "Pragmatic Performance" and maintainability of the implementation.
- **Inputs:** Source code and `/temp/vulnerabilities.json`.
- **Actions:**
- Identify redundant API calls or unnecessary allocations.
- Flag "Over-Engineering" (unnecessary abstractions) vs. "Lazy Code" (hardcoded values).
- Identify missing unit test scenarios for identified edge cases.
- **Outputs:** List of optimization debt and missing test scenarios.
- **Persistence Strategy:** Save `debt_and_tests.json` to `/temp/`.
### Stage 4: Synthesis & Verdict Generation
- **Purpose:** Compile all findings into the final "Hostile Audit" report.
- **Inputs:** `/temp/vulnerabilities.json` and `/temp/debt_and_tests.json`.
- **Actions:**
- Consolidate all findings into the mandated "Response Format."
- Apply the "Burden of Proof" rule: If any Fatal Flaws or Security Risks exist, the verdict is REJECTED.
- Ensure no sycophantic language is present.
- **Outputs:** Final Audit Report.
- **Persistence Strategy:** Final output is delivered to the user; `/temp/` files may be purged.
## 4. Data & File Contracts
- **Filename:** `/temp/audit_context.json` | **Schema:** `{ "high_risk_zones": [], "entry_points": [] }`
- **Filename:** `/temp/findings.json` | **Schema:** `{ "fatal_flaws": [], "security_risks": [], "debt": [], "missing_tests": [] }`
- **Final Report Format:** Markdown with specific headers: `## 🛑 FATAL FLAWS`, `## ⚠️ SECURITY & VULNERABILITIES`, `## 📉 VELOCITY DEBT`, `## 🧪 MISSING TESTS`, and `### VERDICT`.
## 5. Failure & Recovery Handling
- **Incomplete Input:** If the code is snippet-based and missing context, the agent must assume the worst-case scenario for the missing parts and flag them as "Critical Unknowns."
- **Stage Failure:** If a specific file cannot be parsed, log the error in the `findings.json` and proceed with the remaining files.
- **Clarification:** The agent will NOT ask for clarification mid-audit. It will make a "hostile assumption" and document it as a risk.
## 6. Final Deliverable Specification
- **Tone:** Senior Security Auditor. Clinical, critical, and direct.
- **Acceptance Criteria:** - No "Good job" or introductory filler.
- Every flaw must include [Why it fails] and [How to fix it].
- Verdict must be REJECTED unless the code is "solid" (simple, robust, and secure).
- Must identify at least one specific edge case for the "Missing Tests" section.

View File

@@ -1,99 +0,0 @@
---
description: Work on a ticket
---
# WORKFLOW: Automated Feature Implementation and Review Cycle
## 1. High-Level Goal
The objective of this workflow is to autonomously ingest a task from a local `/tickets` directory, establish a dedicated development environment via Git branching, implement the requested changes with incremental commits, validate the work through an internal review process, and finalize the lifecycle by cleaning up ticket artifacts and seeking user authorization for the final merge.
---
## 2. Assumptions & Clarifications
- **Assumptions:**
- The `/tickets` directory contains one or more files representing tasks (e.g., `.md` or `.txt`).
- The agent has authenticated access to the local Git repository.
- A "Review Workflow" exists as an executable command or internal process.
- The branch naming convention is `feature/[ticket-filename-slug]`.
- **Ambiguities:**
- If multiple tickets exist, the agent will select the one with the earliest "Last Modified" timestamp.
- "Regular commits" are defined as committing after every logically complete file change or functional milestone.
---
## 3. Stage Breakdown
### Stage 1: Ticket Selection and Branch Initialization
- **Purpose:** Identify the next task and prepare the workspace.
- **Inputs:** Contents of the `/tickets` directory.
- **Actions:**
1. Scan `/tickets` and select the oldest file.
2. Parse the ticket content to understand requirements.
3. Ensure the current working directory is a Git repository.
4. Create and switch to a new branch: `feature/[ticket-id]`.
- **Outputs:** Active feature branch.
- **Persistence Strategy:** Save `state.json` to `/temp` containing `ticket_path`, `branch_name`, and `start_time`.
### Stage 2: Implementation and Incremental Committing
- **Purpose:** Execute the technical requirements of the ticket.
- **Inputs:** `/temp/state.json`, Ticket requirements.
- **Actions:**
1. Modify codebase according to requirements.
2. For every distinct file change or logical unit of work:
- Run basic syntax checks.
- Execute `git add [file]`.
- Execute `git commit -m "feat: [brief description of change]"`
3. Repeat until the feature is complete.
- **Outputs:** Committed code changes on the feature branch.
- **Persistence Strategy:** Update `state.json` with `implementation_complete: true` and a list of `modified_files`.
### Stage 3: Review Workflow Execution
- **Purpose:** Validate the implementation against quality standards.
- **Inputs:** `/temp/state.json`, Modified codebase.
- **Actions:**
1. Trigger the "Review Workflow" (static analysis, tests, or linter).
2. If errors are found:
- Log errors to `/temp/review_log.txt`.
- Re-enter Stage 2 to apply fixes and commit.
3. If review passes:
- Proceed to Stage 4.
- **Outputs:** Review results/logs.
- **Persistence Strategy:** Update `state.json` with `review_passed: true`.
### Stage 4: Cleanup and User Handoff
- **Purpose:** Finalize the ticket lifecycle and request merge permission.
- **Inputs:** `/temp/state.json`.
- **Actions:**
1. Delete the ticket file from `/tickets` using the path stored in `state.json`.
2. Format a summary of changes and a request for merge.
- **Outputs:** Deletion of the ticket file; user-facing summary.
- **Persistence Strategy:** Clear `/temp/state.json` upon successful completion.
---
## 4. Data & File Contracts
- **State File:** `/temp/state.json`
- Format: JSON
- Schema: `{ "ticket_path": string, "branch_name": string, "implementation_complete": boolean, "review_passed": boolean }`
- **Ticket Files:** Located in `/tickets/*` (Markdown or Plain Text).
- **Logs:** `/temp/review_log.txt` (Plain Text) for capturing stderr from review tools.
---
## 5. Failure & Recovery Handling
- **Empty Ticket Directory:** If no files are found in `/tickets`, the agent will output "NO_TICKETS_FOUND" and terminate the workflow.
- **Commit Failures:** If a commit fails (e.g., pre-commit hooks), the agent must resolve the hook violation before retrying the commit.
- **Review Failure Loop:** If the review fails more than 3 times for the same issue, the agent must halt and output a "BLOCKER_REPORT" detailing the persistent errors to the user.
- **State Recovery:** On context reset, the agent must check `/temp/state.json` to resume the workflow from the last recorded stage.
---
## 6. Final Deliverable Specification
- **Final Output:** A clear message to the user in the following format:
> **Task Completed:** [Ticket Name]
> **Branch:** [Branch Name]
> **Changes:** [Brief list of modified files]
> **Review Status:** Passed
> **Cleanup:** Ticket file removed from /tickets.
> **Action Required:** Would you like me to merge [Branch Name] into `main`? (Yes/No)
- **Quality Bar:** Code must be committed with descriptive messages; the ticket file must be successfully deleted; the workspace must be left on the feature branch awaiting the merge command.

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,12 +1,33 @@
# =============================================================================
# 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://aurora:aurora@db:5432/aurora
VPS_USER=your-vps-user
# 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

7
.gitignore vendored
View File

@@ -1,7 +1,9 @@
.env
node_modules
docker-compose.override.yml
shared/db-logs
shared/db/data
shared/db/backups
shared/db/loga
.cursor
# dependencies (bun install)
@@ -44,4 +46,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
src/db/data
src/db/log
scratchpad/
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,22 +1,77 @@
# ============================================
# Base stage - shared configuration
# ============================================
FROM oven/bun:latest AS base
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
# 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/*
# Install root project dependencies
# ============================================
# 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
# Install web project dependencies
COPY web/package.json web/bun.lock ./web/
RUN cd web && bun install --frozen-lockfile
# ============================================
# Development stage - for local dev with volume mounts
# ============================================
FROM base AS development
# Copy source code
COPY . .
# Copy dependencies from deps stage
COPY --from=deps /app/node_modules ./node_modules
# Expose ports (3000 for web dashboard)
# 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"]

View File

@@ -7,11 +7,10 @@
![Discord.js](https://img.shields.io/badge/Discord.js-14.x-5865F2)
![Drizzle ORM](https://img.shields.io/badge/Drizzle_ORM-0.30+-C5F74F)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-336791)
![React](https://img.shields.io/badge/React-19-61DAFB)
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.
**New in v1.0:** Aurora now includes a fully integrated **Web Dashboard** for managing the bot, viewing statistics, and configuring settings, running alongside the bot in a single process.
**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.
## ✨ Features
@@ -25,26 +24,26 @@ Aurora is a powerful Discord bot designed to facilitate RPG-like elements within
* **Lootdrops**: Random loot drops in channels to engage users.
* **Admin Tools**: Administrative commands for server management.
### Web Dashboard
* **Live Analytics**: View real-time activity charts (commands, transactions).
* **Configuration Management**: Update bot settings without restarting.
### REST API
* **Live Analytics**: Real-time statistics endpoint (commands, transactions).
* **Configuration Management**: Update bot settings via API.
* **Database Inspection**: Integrated Drizzle Studio access.
* **State Monitoring**: View internal bot state (Lootdrops, etc.).
* **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 Web Dashboard run within the same Bun process.
* **Shared State**: This allows the Dashboard to access live bot memory (caches, gateways) directly without complex inter-process communication (IPC).
* **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/)
* **Web Framework**: [React 19](https://react.dev/) + [Vite](https://vitejs.dev/) (served via Bun)
* **Styling**: [Tailwind CSS v4](https://tailwindcss.com/) + [Radix UI](https://www.radix-ui.com/)
* **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/)
@@ -94,14 +93,14 @@ Aurora uses a **Single Process Monolith** architecture to maximize performance a
bun run db:push
```
### Running the Bot & Dashboard
### Running the Bot & API
**Development Mode** (with hot reload):
```bash
bun run dev
```
* Bot: Online in Discord
* Dashboard: http://localhost:3000
* API: http://localhost:3000
**Production Mode**:
Build and run with Docker (recommended):
@@ -111,7 +110,7 @@ docker compose up -d app
### 🔐 Accessing Production Services (SSH Tunnel)
For security, the Production Database and Dashboard are **not exposed** to the public internet by default. They are only accessible via localhost on the server.
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.
@@ -127,12 +126,12 @@ To access them from your local machine, use the included SSH tunnel script.
```
This will establish secure tunnels for:
* **Dashboard**: http://localhost:3000
* **API**: http://localhost:3000
* **Drizzle Studio**: http://localhost:4983
## 📜 Scripts
* `bun run dev`: Start the bot and dashboard in watch mode.
* `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).
@@ -143,7 +142,7 @@ This will establish secure tunnels for:
```
├── bot # Discord Bot logic & entry point
├── web # React Web Dashboard (Frontend + Server)
├── web # REST API Server
├── shared # Shared code (Database, Config, Types)
├── drizzle # Drizzle migration files
├── scripts # Utility scripts

View File

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`

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

@@ -1,40 +1,64 @@
import { describe, expect, it, mock, beforeEach, afterEach, jest } from "bun:test";
import { type WebServerInstance } from "./server";
// Mock the dependencies
const mockConfig = {
// 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: 100n, streakBonus: 10n, weeklyBonus: 50n, cooldownMs: 86400000 },
transfers: { allowSelfTransfer: false, minAmount: 50n },
daily: { amount: "100", streakBonus: "10", weeklyBonus: "50", cooldownMs: 86400000 },
transfers: { allowSelfTransfer: false, minAmount: "1" },
exam: { multMin: 1.5, multMax: 2.5 }
},
inventory: { maxStackSize: 99n, maxSlots: 20 },
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 },
cases: { dmOnWarn: true }
},
trivia: {
entryFee: "50",
rewardMultiplier: 1.5,
timeoutSeconds: 30,
cooldownMs: 60000,
categories: [],
difficulty: "random"
}
};
const mockSaveConfig = jest.fn();
const mockGetSettings = jest.fn(() => Promise.resolve(mockSettings));
const mockUpsertSettings = jest.fn(() => Promise.resolve(mockSettings));
const mockGetDefaults = jest.fn(() => mockSettings);
// Mock @shared/lib/config using mock.module
mock.module("@shared/lib/config", () => ({
config: mockConfig,
saveConfig: mockSaveConfig,
GameConfigType: {}
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
@@ -86,17 +110,27 @@ mock.module("bun", () => {
};
});
// 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 BASE_URL = `http://localhost:${PORT}`;
const HOSTNAME = "127.0.0.1";
const BASE_URL = `http://${HOSTNAME}:${PORT}`;
beforeEach(async () => {
jest.clearAllMocks();
serverInstance = await createWebServer({ port: PORT });
mockGetSettings.mockImplementation(() => Promise.resolve(mockSettings));
mockUpsertSettings.mockImplementation(() => Promise.resolve(mockSettings));
serverInstance = await createWebServer({ port: PORT, hostname: HOSTNAME });
});
afterEach(async () => {
@@ -109,18 +143,14 @@ describe("Settings API", () => {
const res = await fetch(`${BASE_URL}/api/settings`);
expect(res.status).toBe(200);
const data = await res.json();
// Check if BigInts are converted to strings
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 () => {
// We only send a partial update, expecting the server to merge it
// Note: For now the server implementation might still default to overwrite if we haven't updated it yet.
// But the user requested "partial vs full" fix.
// Let's assume we implement the merge logic.
const partialConfig = { studentRole: "new-role-partial" };
const partialConfig = { economy: { daily: { amount: "200" } } };
const res = await fetch(`${BASE_URL}/api/settings`, {
method: "POST",
@@ -129,26 +159,27 @@ describe("Settings API", () => {
});
expect(res.status).toBe(200);
// Expect saveConfig to be called with the MERGED result
expect(mockSaveConfig).toHaveBeenCalledWith(expect.objectContaining({
studentRole: "new-role-partial",
leveling: mockConfig.leveling // Should keep existing values
}));
// 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 () => {
mockSaveConfig.mockImplementationOnce(() => {
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({}) // Empty might be valid partial, but mocks throw
body: JSON.stringify({})
});
expect(res.status).toBe(400);
const data = await res.json();
const data = await res.json() as any;
expect(data.details).toBe("Validation failed");
});
@@ -156,7 +187,7 @@ describe("Settings API", () => {
const res = await fetch(`${BASE_URL}/api/settings/meta`);
expect(res.status).toBe(200);
const data = await res.json();
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 });

View File

@@ -1,6 +1,5 @@
import { describe, test, expect, afterAll, mock } from "bun:test";
import type { WebServerInstance } from "./server";
import { createWebServer } from "./server";
interface MockBotStats {
bot: { name: string; avatarUrl: string | null };
@@ -13,21 +12,21 @@ interface MockBotStats {
}
// 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 = {
where: mock(() => Promise.resolve([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }])),
then: (onfulfilled: any) => onfulfilled([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]),
orderBy: mock(() => mockBuilder), // Chainable
limit: mock(() => Promise.resolve([])), // Terminal
};
const mockFrom = {
from: mock(() => mockBuilder),
};
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(() => mockFrom),
select: mock(() => mockBuilder),
query: {
transactions: { findMany: mock(() => Promise.resolve([])) },
moderationCases: { findMany: mock(() => Promise.resolve([])) },
@@ -54,10 +53,42 @@ mock.module("../../bot/lib/clientStats", () => ({
})),
}));
// 3. System Events (No mock needed, use real events)
// 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 () => {
@@ -67,8 +98,8 @@ describe("WebServer Security & Limits", () => {
});
test("should reject more than 10 concurrent WebSocket connections", async () => {
serverInstance = await createWebServer({ port, hostname: "localhost" });
const wsUrl = `ws://localhost:${port}/ws`;
serverInstance = await createWebServer({ port, hostname });
const wsUrl = `ws://${hostname}:${port}/ws`;
const sockets: WebSocket[] = [];
try {
@@ -95,9 +126,9 @@ describe("WebServer Security & Limits", () => {
test("should return 200 for health check", async () => {
if (!serverInstance) {
serverInstance = await createWebServer({ port, hostname: "localhost" });
serverInstance = await createWebServer({ port, hostname });
}
const response = await fetch(`http://localhost:${port}/api/health`);
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");
@@ -105,7 +136,7 @@ describe("WebServer Security & Limits", () => {
describe("Administrative Actions", () => {
test("should allow administrative actions without token", async () => {
const response = await fetch(`http://localhost:${port}/api/actions/reload-commands`, {
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)
@@ -114,7 +145,7 @@ describe("WebServer Security & Limits", () => {
});
test("should reject maintenance mode with invalid payload", async () => {
const response = await fetch(`http://localhost:${port}/api/actions/maintenance-mode`, {
const response = await fetch(`http://${hostname}:${port}/api/actions/maintenance-mode`, {
method: "POST",
headers: {
"Content-Type": "application/json"

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

View File

@@ -1,14 +1,10 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": [
"ESNext",
"DOM"
],
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
@@ -38,8 +34,5 @@
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
},
"exclude": [
"dist",
"node_modules"
]
"exclude": ["node_modules"]
}

View File

View File

@@ -1,7 +1,8 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@shared/modules/moderation/moderation.service";
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()
@@ -16,39 +17,35 @@ export const moderationCase = createCommand({
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
await withCommandErrorHandling(
interaction,
async () => {
const caseId = interaction.options.getString("case_id", true).toUpperCase();
try {
const caseId = interaction.options.getString("case_id", true).toUpperCase();
// Validate case ID format
if (!caseId.match(/^CASE-\d+$/)) {
await interaction.editReply({
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
});
return;
}
// Validate case ID format
if (!caseId.match(/^CASE-\d+$/)) {
// 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: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
embeds: [getCaseEmbed(moderationCase)]
});
return;
}
// Get the case
const moderationCase = await ModerationService.getCaseById(caseId);
if (!moderationCase) {
await interaction.editReply({
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
});
return;
}
// Display the case
await interaction.editReply({
embeds: [getCaseEmbed(moderationCase)]
});
} catch (error) {
console.error("Case command error:", error);
await interaction.editReply({
embeds: [getModerationErrorEmbed("An error occurred while fetching the case.")]
});
}
},
{ ephemeral: true }
);
}
});

View File

@@ -1,7 +1,8 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@shared/modules/moderation/moderation.service";
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
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()
@@ -22,33 +23,29 @@ export const cases = createCommand({
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
await withCommandErrorHandling(
interaction,
async () => {
const targetUser = interaction.options.getUser("user", true);
const activeOnly = interaction.options.getBoolean("active_only") || false;
try {
const targetUser = interaction.options.getUser("user", true);
const activeOnly = interaction.options.getBoolean("active_only") || false;
// Get cases for the user
const userCases = await moderationService.getUserCases(targetUser.id, activeOnly);
// 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 title = activeOnly
? `⚠️ Active Cases for ${targetUser.username}`
: `📋 All Cases for ${targetUser.username}`;
const description = userCases.length === 0
? undefined
: `Total cases: **${userCases.length}**`;
const description = userCases.length === 0
? undefined
: `Total cases: **${userCases.length}**`;
// Display the cases
await interaction.editReply({
embeds: [getCasesListEmbed(userCases, title, description)]
});
} catch (error) {
console.error("Cases command error:", error);
await interaction.editReply({
embeds: [getModerationErrorEmbed("An error occurred while fetching cases.")]
});
}
// Display the cases
await interaction.editReply({
embeds: [getCasesListEmbed(userCases, title, description)]
});
},
{ ephemeral: true }
);
}
});

View File

@@ -1,7 +1,8 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@shared/modules/moderation/moderation.service";
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()
@@ -23,62 +24,58 @@ export const clearwarning = createCommand({
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
await withCommandErrorHandling(
interaction,
async () => {
const caseId = interaction.options.getString("case_id", true).toUpperCase();
const reason = interaction.options.getString("reason") || "Cleared by moderator";
try {
const caseId = interaction.options.getString("case_id", true).toUpperCase();
const reason = interaction.options.getString("reason") || "Cleared by moderator";
// Validate case ID format
if (!caseId.match(/^CASE-\d+$/)) {
await interaction.editReply({
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
});
return;
}
// Validate case ID format
if (!caseId.match(/^CASE-\d+$/)) {
await interaction.editReply({
embeds: [getModerationErrorEmbed("Invalid case ID format. Expected format: CASE-0001")]
// 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
});
return;
}
// Check if case exists and is active
const existingCase = await ModerationService.getCaseById(caseId);
if (!existingCase) {
// Send success message
await interaction.editReply({
embeds: [getModerationErrorEmbed(`Case **${caseId}** not found.`)]
embeds: [getClearSuccessEmbed(caseId)]
});
return;
}
if (!existingCase.active) {
await interaction.editReply({
embeds: [getModerationErrorEmbed(`Case **${caseId}** is already resolved.`)]
});
return;
}
if (existingCase.type !== 'warn') {
await interaction.editReply({
embeds: [getModerationErrorEmbed(`Case **${caseId}** is not a warning. Only warnings can be cleared.`)]
});
return;
}
// Clear the warning
await ModerationService.clearCase({
caseId,
clearedBy: interaction.user.id,
clearedByName: interaction.user.username,
reason
});
// Send success message
await interaction.editReply({
embeds: [getClearSuccessEmbed(caseId)]
});
} catch (error) {
console.error("Clear warning command error:", error);
await interaction.editReply({
embeds: [getModerationErrorEmbed("An error occurred while clearing the warning.")]
});
}
},
{ ephemeral: true }
);
}
});

View File

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

View File

@@ -1,9 +1,11 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, AttachmentBuilder } from "discord.js";
import { config, saveConfig } from "@shared/lib/config";
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()
@@ -31,64 +33,60 @@ export const createColor = createCommand({
)
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
await interaction.deferReply();
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";
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;
}
// 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}`
});
try {
// 2. Create Role
const role = await interaction.guild?.roles.create({
name: name,
color: colorInput as any, // Discord.js types are a bit strict on ColorResolvable, but string generally works or needs parsing
reason: `Created via /createcolor by ${interaction.user.tag}`
});
if (!role) {
throw new Error("Failed to create role.");
}
if (!role) {
throw new Error("Failed to create role.");
}
// 3. Add to guild settings
await guildSettingsService.addColorRole(interaction.guildId!, role.id);
invalidateGuildConfigCache(interaction.guildId!);
// 3. Update Config
if (!config.colorRoles.includes(role.id)) {
config.colorRoles.push(role.id);
saveConfig(config);
}
// 4. Create Item
await DrizzleClient.insert(items).values({
name: `Color Role - ${name}`,
description: `Use this item to apply the ${name} color to your name.`,
type: "CONSUMABLE",
rarity: "Common",
price: BigInt(price),
iconUrl: "",
imageUrl: imageUrl,
usageData: {
consume: false,
effects: [{ type: "COLOR_ROLE", roleId: role.id }]
} as any
});
// 4. Create Item
await DrizzleClient.insert(items).values({
name: `Color Role - ${name}`,
description: `Use this item to apply the ${name} color to your name.`,
type: "CONSUMABLE",
rarity: "Common",
price: BigInt(price),
iconUrl: "",
imageUrl: imageUrl,
usageData: {
consume: false,
effects: [{ type: "COLOR_ROLE", roleId: role.id }]
} as any
});
// 5. Success
await interaction.editReply({
embeds: [createSuccessEmbed(
`**Role:** <@&${role.id}> (${colorInput})\n**Item:** Color Role - ${name}\n**Price:** ${price} 🪙`,
"✅ Color Role & Item Created"
)]
});
} catch (error: any) {
console.error("Error in createcolor:", error);
await interaction.editReply({ embeds: [createErrorEmbed(`Failed to create color role: ${error.message}`)] });
}
// 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,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

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

View File

@@ -1,20 +1,18 @@
import { createCommand } from "@shared/lib/utils";
import {
SlashCommandBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
type BaseGuildTextChannel,
PermissionFlagsBits,
MessageFlags
} from "discord.js";
import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { createSuccessEmbed, createErrorEmbed, createBaseEmbed } from "@lib/embeds";
import { UserError } from "@/lib/errors";
import { createErrorEmbed } from "@lib/embeds";
import { items } from "@db/schema";
import { ilike, isNotNull, and } from "drizzle-orm";
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()
@@ -33,44 +31,67 @@ export const listing = createCommand({
)
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
await withCommandErrorHandling(
interaction,
async () => {
const itemId = interaction.options.getNumber("item", true);
const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel;
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;
}
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;
}
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;
}
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 listingMessage = getShopListingMessage({
...item,
formattedPrice: `${item.price} 🪙`,
price: item.price
});
const usageData = item.usageData as any;
const lootboxEffect = usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX);
try {
await targetChannel.send(listingMessage);
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
} catch (error: any) {
if (error instanceof UserError) {
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
} else {
console.error("Error creating listing:", error);
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
}
}
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();

View File

@@ -1,8 +1,9 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@shared/modules/moderation/moderation.service";
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()
@@ -24,39 +25,35 @@ export const note = createCommand({
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
await withCommandErrorHandling(
interaction,
async () => {
const targetUser = interaction.options.getUser("user", true);
const noteText = interaction.options.getString("note", true);
try {
const targetUser = interaction.options.getUser("user", true);
const noteText = interaction.options.getString("note", true);
// Create the note case
const moderationCase = await ModerationService.createCase({
type: CaseType.NOTE,
userId: targetUser.id,
username: targetUser.username,
moderatorId: interaction.user.id,
moderatorName: interaction.user.username,
reason: noteText,
});
if (!moderationCase) {
await interaction.editReply({
embeds: [getModerationErrorEmbed("Failed to create note.")]
// 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,
});
return;
}
// Send success message
await interaction.editReply({
embeds: [getNoteSuccessEmbed(moderationCase.caseId, targetUser.username)]
});
if (!moderationCase) {
await interaction.editReply({
embeds: [getModerationErrorEmbed("Failed to create note.")]
});
return;
}
} catch (error) {
console.error("Note command error:", error);
await interaction.editReply({
embeds: [getModerationErrorEmbed("An error occurred while adding the note.")]
});
}
// Send success message
await interaction.editReply({
embeds: [getNoteSuccessEmbed(moderationCase.caseId, targetUser.username)]
});
},
{ ephemeral: true }
);
}
});

View File

@@ -1,7 +1,8 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@shared/modules/moderation/moderation.service";
import { getCasesListEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
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()
@@ -16,28 +17,24 @@ export const notes = createCommand({
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
await withCommandErrorHandling(
interaction,
async () => {
const targetUser = interaction.options.getUser("user", true);
try {
const targetUser = interaction.options.getUser("user", true);
// Get all notes for the user
const userNotes = await moderationService.getUserNotes(targetUser.id);
// Get all notes for the user
const userNotes = await ModerationService.getUserNotes(targetUser.id);
// Display the notes
await interaction.editReply({
embeds: [getCasesListEmbed(
userNotes,
`📝 Staff Notes for ${targetUser.username}`,
userNotes.length === 0 ? undefined : `Total notes: **${userNotes.length}**`
)]
});
} catch (error) {
console.error("Notes command error:", error);
await interaction.editReply({
embeds: [getModerationErrorEmbed("An error occurred while fetching notes.")]
});
}
// Display the notes
await interaction.editReply({
embeds: [getCasesListEmbed(
userNotes,
`📝 Staff Notes for ${targetUser.username}`,
userNotes.length === 0 ? undefined : `Total notes: **${userNotes.length}**`
)]
});
},
{ ephemeral: true }
);
}
});

View File

@@ -1,7 +1,7 @@
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 { pruneService } from "@shared/modules/moderation/prune.service";
import {
getConfirmationMessage,
getProgressEmbed,
@@ -10,6 +10,7 @@ import {
getPruneWarningEmbed,
getCancelledEmbed
} from "@/modules/moderation/prune.view";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const prune = createCommand({
data: new SlashCommandBuilder()
@@ -38,142 +39,126 @@ export const prune = createCommand({
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
await withCommandErrorHandling(
interaction,
async () => {
const amount = interaction.options.getInteger("amount");
const user = interaction.options.getUser("user");
const all = interaction.options.getBoolean("all") || false;
try {
const amount = interaction.options.getInteger("amount");
const user = interaction.options.getUser("user");
const all = interaction.options.getBoolean("all") || false;
// Validate inputs
if (!amount && !all) {
// Default to 10 messages
} else if (amount && all) {
await interaction.editReply({
embeds: [getPruneErrorEmbed("Cannot specify both `amount` and `all`. Please use one or the other.")]
});
return;
}
const finalAmount = all ? 'all' : (amount || 10);
const confirmThreshold = config.moderation.prune.confirmThreshold;
// Check if confirmation is needed
const needsConfirmation = all || (typeof finalAmount === 'number' && finalAmount > confirmThreshold);
if (needsConfirmation) {
// Estimate message count for confirmation
let estimatedCount: number | undefined;
if (all) {
try {
estimatedCount = await PruneService.estimateMessageCount(interaction.channel!);
} catch {
estimatedCount = undefined;
}
}
const { embeds, components } = getConfirmationMessage(finalAmount, estimatedCount);
const response = await interaction.editReply({ embeds, components });
try {
const confirmation = await response.awaitMessageComponent({
filter: (i) => i.user.id === interaction.user.id,
componentType: ComponentType.Button,
time: 30000
});
if (confirmation.customId === "cancel_prune") {
await confirmation.update({
embeds: [getCancelledEmbed()],
components: []
});
return;
}
// User confirmed, proceed with deletion
await confirmation.update({
embeds: [getProgressEmbed({ current: 0, total: estimatedCount || finalAmount as number })],
components: []
});
// Execute deletion with progress callback for 'all' mode
const result = await PruneService.deleteMessages(
interaction.channel!,
{
amount: typeof finalAmount === 'number' ? finalAmount : undefined,
userId: user?.id,
all
},
all ? async (progress) => {
await interaction.editReply({
embeds: [getProgressEmbed(progress)]
});
} : undefined
);
// Show success
// Validate inputs
if (!amount && !all) {
// Default to 10 messages
} else if (amount && all) {
await interaction.editReply({
embeds: [getSuccessEmbed(result)],
components: []
embeds: [getPruneErrorEmbed("Cannot specify both `amount` and `all`. Please use one or the other.")]
});
} 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)]
});
}
const finalAmount = all ? 'all' : (amount || 10);
const confirmThreshold = config.moderation.prune.confirmThreshold;
} catch (error) {
console.error("Prune command error:", error);
// Check if confirmation is needed
const needsConfirmation = all || (typeof finalAmount === 'number' && finalAmount > confirmThreshold);
let errorMessage = "An unexpected error occurred while trying to delete messages.";
if (error instanceof Error) {
if (error.message.includes("permission")) {
errorMessage = "I don't have permission to delete messages in this channel.";
} else if (error.message.includes("channel type")) {
errorMessage = "This command cannot be used in this type of channel.";
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 {
errorMessage = error.message;
}
}
// No confirmation needed, proceed directly
const result = await pruneService.deleteMessages(
interaction.channel!,
{
amount: finalAmount as number,
userId: user?.id,
all: false
}
);
await interaction.editReply({
embeds: [getPruneErrorEmbed(errorMessage)]
});
}
// 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

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

@@ -2,7 +2,8 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js";
import { terminalService } from "@shared/modules/terminal/terminal.service";
import { createBaseEmbed, createErrorEmbed } from "@/lib/embeds";
import { createErrorEmbed } from "@/lib/embeds";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const terminal = createCommand({
data: new SlashCommandBuilder()
@@ -23,15 +24,14 @@ export const terminal = createCommand({
return;
}
await interaction.reply({ ephemeral: true, content: "Initializing terminal..." });
try {
await terminalService.init(channel as TextChannel);
await interaction.editReply({ content: "✅ Terminal initialized!" });
} catch (error) {
console.error(error);
await interaction.editReply({ content: "❌ Failed to initialize terminal." });
}
await withCommandErrorHandling(
interaction,
async () => {
await terminalService.init(channel as TextChannel);
await interaction.editReply({ content: "✅ Terminal initialized!" });
},
{ ephemeral: true }
);
}
}
});

View File

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

View File

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

@@ -1,7 +1,8 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@shared/modules/moderation/moderation.service";
import { getWarningsEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
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()
@@ -16,24 +17,20 @@ export const warnings = createCommand({
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
await withCommandErrorHandling(
interaction,
async () => {
const targetUser = interaction.options.getUser("user", true);
try {
const targetUser = interaction.options.getUser("user", true);
// Get active warnings for the user
const activeWarnings = await moderationService.getUserWarnings(targetUser.id);
// Get active warnings for the user
const activeWarnings = await ModerationService.getUserWarnings(targetUser.id);
// Display the warnings
await interaction.editReply({
embeds: [getWarningsEmbed(activeWarnings, targetUser.username)]
});
} catch (error) {
console.error("Warnings command error:", error);
await interaction.editReply({
embeds: [getModerationErrorEmbed("An error occurred while fetching warnings.")]
});
}
// Display the warnings
await interaction.editReply({
embeds: [getWarningsEmbed(activeWarnings, targetUser.username)]
});
},
{ ephemeral: true }
);
}
});

View File

@@ -2,6 +2,7 @@ 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()
@@ -14,43 +15,40 @@ export const webhook = createCommand({
.setRequired(true)
),
execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
await withCommandErrorHandling(
interaction,
async () => {
const payloadString = interaction.options.getString("payload", true);
let payload;
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;
}
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;
const channel = interaction.channel;
if (!channel || !('createWebhook' in channel)) {
await interaction.editReply({
embeds: [createErrorEmbed("This channel does not support webhooks.", "Unsupported Channel")]
});
return;
}
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}`
);
try {
await sendWebhookMessage(
channel,
payload,
interaction.client.user,
`Proxy message requested by ${interaction.user.tag}`
);
await interaction.editReply({ content: "Message sent successfully!" });
} catch (error) {
console.error("Webhook error:", error);
await interaction.editReply({
embeds: [createErrorEmbed("Failed to send message via webhook. Ensure the bot has 'Manage Webhooks' permission and the payload is valid.", "Delivery Failed")]
});
}
await interaction.editReply({ content: "Message sent successfully!" });
},
{ ephemeral: true }
);
}
});

View File

@@ -2,34 +2,29 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js";
import { economyService } from "@shared/modules/economy/economy.service";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { UserError } from "@/lib/errors";
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) => {
try {
const result = await economyService.claimDaily(interaction.user.id);
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");
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.reply({ embeds: [embed] });
} catch (error: any) {
if (error instanceof UserError) {
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
} else {
console.error("Error claiming daily:", error);
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
await interaction.editReply({ embeds: [embed] });
}
}
);
}
});

View File

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

View File

@@ -5,7 +5,7 @@ import { economyService } from "@shared/modules/economy/economy.service";
import { userService } from "@shared/modules/user/user.service";
import { config } from "@shared/lib/config";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { UserError } from "@/lib/errors";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const pay = createCommand({
data: new SlashCommandBuilder()
@@ -50,20 +50,14 @@ export const pay = createCommand({
return;
}
try {
await interaction.deferReply();
await economyService.transfer(senderId, receiverId.toString(), amount);
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}>` });
} catch (error: any) {
if (error instanceof UserError) {
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
} else {
console.error("Error sending payment:", error);
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
const embed = createSuccessEmbed(`Successfully sent ** ${amount}** Astral Units to <@${targetUser.id}>.`, "💸 Transfer Successful");
await interaction.editReply({ embeds: [embed], content: `<@${receiverId}>` });
}
}
);
}
});

View File

@@ -3,9 +3,10 @@ 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 "@/lib/errors";
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()
@@ -53,64 +54,54 @@ export const trivia = createCommand({
return;
}
// User can play - defer publicly for trivia question
await interaction.deferReply();
// 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
);
// 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
}
);
// 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) {
// Check if we've already deferred
if (interaction.deferred) {
await interaction.editReply({
embeds: [createErrorEmbed(error.message)]
});
} else {
await interaction.reply({
embeds: [createErrorEmbed(error.message)],
ephemeral: true
});
}
await interaction.reply({
embeds: [createErrorEmbed(error.message)],
ephemeral: true
});
} else {
console.error("Error in trivia command:", error);
// Check if we've already deferred
if (interaction.deferred) {
await interaction.editReply({
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")]
});
} else {
await interaction.reply({
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")],
ephemeral: true
});
}
await interaction.reply({
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")],
ephemeral: true
});
}
}
}

View File

@@ -1,6 +1,6 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js";
import { config } from "@shared/lib/config";
import { getGuildConfig } from "@shared/lib/config";
import { createErrorEmbed } from "@/lib/embeds";
import { getFeedbackTypeMenu } from "@/modules/feedback/feedback.view";
@@ -9,8 +9,10 @@ export const feedback = createCommand({
.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 (!config.feedbackChannelId) {
if (!guildConfig.feedbackChannelId) {
await interaction.reply({
embeds: [createErrorEmbed("Feedback system is not configured. Please contact an administrator.")],
ephemeral: true

View File

@@ -4,9 +4,8 @@ import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { userService } from "@shared/modules/user/user.service";
import { createErrorEmbed } from "@lib/embeds";
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
import type { ItemUsageData } from "@shared/lib/types";
import { UserError } from "@/lib/errors";
import { config } from "@shared/lib/config";
import { withCommandErrorHandling } from "@lib/commandUtils";
import { getGuildConfig } from "@shared/lib/config";
export const use = createCommand({
data: new SlashCommandBuilder()
@@ -19,54 +18,50 @@ export const use = createCommand({
.setAutocomplete(true)
),
execute: async (interaction) => {
await interaction.deferReply();
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 itemId = interaction.options.getNumber("item", true);
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
if (!user) {
await interaction.editReply({ embeds: [createErrorEmbed("Failed to load user data.")] });
return;
}
try {
const result = await inventoryService.useItem(user.id.toString(), itemId);
const result = await inventoryService.useItem(user.id.toString(), itemId);
const usageData = result.usageData;
if (usageData) {
for (const effect of usageData.effects) {
if (effect.type === 'TEMP_ROLE' || effect.type === 'COLOR_ROLE') {
try {
const member = await interaction.guild?.members.fetch(user.id.toString());
if (member) {
if (effect.type === 'TEMP_ROLE') {
await member.roles.add(effect.roleId);
} else if (effect.type === 'COLOR_ROLE') {
// Remove existing color roles
const rolesToRemove = config.colorRoles.filter(r => member.roles.cache.has(r));
if (rolesToRemove.length > 0) await member.roles.remove(rolesToRemove);
await member.roles.add(effect.roleId);
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)");
}
} 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 });
}
const embed = getItemUseResultEmbed(result.results, result.item);
await interaction.editReply({ embeds: [embed] });
} catch (error: any) {
if (error instanceof UserError) {
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
} else {
console.error("Error using item:", error);
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred while using the item.")] });
}
}
);
},
autocomplete: async (interaction) => {
const focusedValue = interaction.options.getFocused();

View File

@@ -1,25 +1,83 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, MessageFlags } from "discord.js";
import { questService } from "@shared/modules/quest/quest.service";
import { createWarningEmbed } from "@lib/embeds";
import { getQuestListEmbed } from "@/modules/quest/quest.view";
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 quests"),
.setDescription("View your active and available quests"),
execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const response = await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const userQuests = await questService.getUserQuests(interaction.user.id);
const userId = interaction.user.id;
if (!userQuests || userQuests.length === 0) {
await interaction.editReply({ embeds: [createWarningEmbed("You have no active quests.", "Quest Log")] });
return;
}
const updateView = async (viewType: 'active' | 'available') => {
const userQuests = await questService.getUserQuests(userId);
const availableQuests = await questService.getAvailableQuests(userId);
const embed = getQuestListEmbed(userQuests);
const containers = viewType === 'active'
? getQuestListComponents(userQuests)
: getAvailableQuestsComponents(availableQuests);
await interaction.editReply({ embeds: [embed] });
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,20 +1,26 @@
import { Events } from "discord.js";
import type { Event } from "@shared/lib/types";
import { config } from "@shared/lib/config";
import { getGuildConfig } from "@shared/lib/config";
import { userService } from "@shared/modules/user/user.service";
// Visitor role
const event: Event<Events.GuildMemberAdd> = {
name: Events.GuildMemberAdd,
execute: async (member) => {
console.log(`👤 New member joined: ${member.user.tag} (${member.id})`);
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}`);
await member.roles.remove(config.visitorRole);
await member.roles.add(config.studentRole);
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);
@@ -22,8 +28,10 @@ const event: Event<Events.GuildMemberAdd> = {
}
console.log(`Restored student role to ${member.user.tag}`);
} else {
await member.roles.add(config.visitorRole);
console.log(`Assigned visitor role to ${member.user.tag}`);
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) {

View File

@@ -9,9 +9,7 @@ const event: Event<Events.ClientReady> = {
console.log(`Ready! Logged in as ${c.user.tag}`);
schedulerService.start();
// Handle post-update tasks
const { UpdateService } = await import("@shared/modules/admin/update.service");
await UpdateService.handlePostRestart(c);
},
};

View File

@@ -1,8 +1,12 @@
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 "../web/src/server";
import { startWebServerFromRoot } from "../api/src/server";
// Initialize config from database
await initializeConfig();
// Load commands & events
await AuroraClient.loadCommands();
@@ -14,7 +18,7 @@ console.log("🌐 Starting web server...");
let shuttingDown = false;
const webProjectPath = join(import.meta.dir, "../web");
const webProjectPath = join(import.meta.dir, "../api");
const webPort = Number(process.env.WEB_PORT) || 3000;
const webHost = process.env.HOST || "0.0.0.0";

View File

@@ -20,7 +20,8 @@ mock.module("discord.js", () => ({
Routes: {
applicationGuildCommands: () => 'guild_route',
applicationCommands: () => 'global_route'
}
},
MessageFlags: {}
}));
// Mock loaders to avoid filesystem access during client init

View File

@@ -1,4 +1,4 @@
import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes } from "discord.js";
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";
@@ -74,6 +74,27 @@ export class Client extends DiscordClient {
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) {
@@ -176,4 +197,4 @@ export class Client extends DiscordClient {
}
}
export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers] });
export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers, GatewayIntentBits.DirectMessages] });

View File

@@ -20,6 +20,9 @@ mock.module("./BotClient", () => ({
commands: {
size: 20,
},
knownCommands: {
size: 20,
},
lastCommandTimestamp: 1641481200000,
},
}));

View File

@@ -23,6 +23,7 @@ export function getClientStats(): 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,

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

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, mock, beforeEach } from "bun:test";
// Mock DrizzleClient
mock.module("./DrizzleClient", () => ({
// Mock DrizzleClient — must match the import path used in db.ts
mock.module("@shared/db/DrizzleClient", () => ({
DrizzleClient: {
transaction: async (cb: any) => cb("MOCK_TX")
}

View File

@@ -1,4 +1,15 @@
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 { Colors, type ColorResolvable, EmbedBuilder } 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,11 +66,13 @@ 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);
}
/**
@@ -65,11 +84,12 @@ export function createInfoEmbed(message: string, title: string = "Info"): EmbedB
*/
export function createBaseEmbed(title?: string, description?: string, color?: ColorResolvable): EmbedBuilder {
const embed = new EmbedBuilder()
.setTimestamp();
.setTimestamp()
.setColor(color ?? BRANDING.COLOR);
if (title) embed.setTitle(title);
if (description) embed.setDescription(description);
if (color) embed.setColor(color);
return embed;
return applyBranding(embed);
}

View File

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

View File

@@ -1,5 +1,6 @@
import { AutocompleteInteraction } from "discord.js";
import { AuroraClient } from "@/lib/BotClient";
import { logger } from "@shared/lib/logger";
/**
@@ -16,7 +17,7 @@ export class AutocompleteHandler {
try {
await command.autocomplete(interaction);
} catch (error) {
console.error(`Error handling autocomplete for ${interaction.commandName}:`, error);
logger.error("bot", `Error handling autocomplete for ${interaction.commandName}`, error);
}
}
}

View File

@@ -1,7 +1,9 @@
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";
/**
@@ -13,7 +15,7 @@ export class CommandHandler {
const command = AuroraClient.commands.get(interaction.commandName);
if (!command) {
console.error(`No command matching ${interaction.commandName} was found.`);
logger.error("bot", `No command matching ${interaction.commandName} was found.`);
return;
}
@@ -24,18 +26,49 @@ export class CommandHandler {
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) {
console.error("Failed to ensure user exists:", error);
logger.error("bot", "Failed to ensure user exists", error);
}
try {
await command.execute(interaction);
AuroraClient.lastCommandTimestamp = Date.now();
} catch (error) {
console.error(String(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) {

View File

@@ -1,7 +1,8 @@
import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction, MessageFlags } from "discord.js";
import { UserError } from "@lib/errors";
import { UserError } from "@shared/lib/errors";
import { createErrorEmbed } from "@lib/embeds";
import { logger } from "@shared/lib/logger";
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
@@ -28,7 +29,7 @@ export class ComponentInteractionHandler {
return;
}
} else {
console.error(`Handler method ${route.method} not found in module`);
logger.error("bot", `Handler method ${route.method} not found in module`);
}
}
}
@@ -52,7 +53,7 @@ export class ComponentInteractionHandler {
// Log system errors (non-user errors) for debugging
if (!isUserError) {
console.error(`Error in ${handlerName}:`, error);
logger.error("bot", `Error in ${handlerName}`, error);
}
const errorEmbed = createErrorEmbed(errorMessage);
@@ -72,7 +73,7 @@ export class ComponentInteractionHandler {
}
} catch (replyError) {
// If we can't send a reply, log it
console.error(`Failed to send error response in ${handlerName}:`, replyError);
logger.error("bot", `Failed to send error response in ${handlerName}`, replyError);
}
}
}

View File

@@ -23,7 +23,7 @@ export const renderWizard = (userId: string, isDraft = true) => {
draft = {
name: "New Item",
description: "No description",
rarity: "Common",
rarity: "C",
type: ItemType.MATERIAL,
price: null,
iconUrl: "",

View File

@@ -87,7 +87,7 @@ export const getDetailsModal = (current: DraftItem) => {
modal.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("name").setLabel("Name").setValue(current.name).setStyle(TextInputStyle.Short).setRequired(true)),
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("desc").setLabel("Description").setValue(current.description).setStyle(TextInputStyle.Paragraph).setRequired(false)),
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("rarity").setLabel("Rarity").setValue(current.rarity).setStyle(TextInputStyle.Short).setPlaceholder("Common, Rare, Legendary...").setRequired(true))
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("rarity").setLabel("Rarity").setValue(current.rarity).setStyle(TextInputStyle.Short).setPlaceholder("C, R, SR, SSR").setRequired(true))
);
return modal;
};

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { ButtonInteraction } from "discord.js";
import { lootdropService } from "@shared/modules/economy/lootdrop.service";
import { UserError } from "@/lib/errors";
import { UserError } from "@shared/lib/errors";
import { getLootdropClaimedMessage } from "./lootdrop.view";
export async function handleLootdropInteraction(interaction: ButtonInteraction) {

View File

@@ -1,7 +1,7 @@
import { ButtonInteraction, MessageFlags } from "discord.js";
import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { userService } from "@shared/modules/user/user.service";
import { UserError } from "@/lib/errors";
import { UserError } from "@shared/lib/errors";
export async function handleShopInteraction(interaction: ButtonInteraction) {
if (!interaction.customId.startsWith("shop_buy_")) return;

View File

@@ -1,20 +1,208 @@
import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
import { createBaseEmbed } from "@/lib/embeds";
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
AttachmentBuilder,
Colors,
ContainerBuilder,
SectionBuilder,
TextDisplayBuilder,
MediaGalleryBuilder,
MediaGalleryItemBuilder,
ThumbnailBuilder,
SeparatorBuilder,
SeparatorSpacingSize,
MessageFlags
} from "discord.js";
import { resolveAssetUrl, isLocalAssetUrl } from "@shared/lib/assets";
import { join } from "path";
import { existsSync } from "fs";
import { LootType, EffectType } from "@shared/lib/constants";
import type { LootTableItem } from "@shared/lib/types";
export function getShopListingMessage(item: { id: number; name: string; description: string | null; formattedPrice: string; iconUrl: string | null; imageUrl: string | null; price: number | bigint }) {
const embed = createBaseEmbed(`Shop: ${item.name}`, item.description || "No description available.", "Green")
.addFields({ name: "Price", value: item.formattedPrice, inline: true })
.setThumbnail(item.iconUrl || null)
.setImage(item.imageUrl || null)
.setFooter({ text: "Click the button below to purchase instantly." });
// Rarity Color Map
const RarityColors: Record<string, number> = {
"C": Colors.LightGrey,
"R": Colors.Blue,
"SR": Colors.Purple,
"SSR": Colors.Gold,
"CURRENCY": Colors.Green,
"XP": Colors.Aqua,
"NOTHING": Colors.DarkButNotBlack
};
const TitleMap: Record<string, string> = {
"C": "📦 Common Items",
"R": "📦 Rare Items",
"SR": "✨ Super Rare Items",
"SSR": "🌟 SSR Items",
"CURRENCY": "💰 Currency",
"XP": "🔮 Experience",
"NOTHING": "💨 Empty"
};
export function getShopListingMessage(
item: {
id: number;
name: string;
description: string | null;
formattedPrice: string;
iconUrl: string | null;
imageUrl: string | null;
price: number | bigint;
usageData?: any;
rarity?: string;
},
context?: { referencedItems: Map<number, { name: string; rarity: string }> }
) {
const files: AttachmentBuilder[] = [];
let thumbnailUrl = resolveAssetUrl(item.iconUrl);
let displayImageUrl = resolveAssetUrl(item.imageUrl);
// Handle local icon
if (item.iconUrl && isLocalAssetUrl(item.iconUrl)) {
const iconPath = join(process.cwd(), "bot/assets/graphics", item.iconUrl.replace(/^\/?assets\//, ""));
if (existsSync(iconPath)) {
const iconName = defaultName(item.iconUrl);
files.push(new AttachmentBuilder(iconPath, { name: iconName }));
thumbnailUrl = `attachment://${iconName}`;
}
}
// Handle local image
if (item.imageUrl && isLocalAssetUrl(item.imageUrl)) {
if (item.imageUrl === item.iconUrl && thumbnailUrl?.startsWith("attachment://")) {
displayImageUrl = thumbnailUrl;
} else {
const imagePath = join(process.cwd(), "bot/assets/graphics", item.imageUrl.replace(/^\/?assets\//, ""));
if (existsSync(imagePath)) {
const imageName = defaultName(item.imageUrl);
if (!files.find(f => f.name === imageName)) {
files.push(new AttachmentBuilder(imagePath, { name: imageName }));
}
displayImageUrl = `attachment://${imageName}`;
}
}
}
const containers: ContainerBuilder[] = [];
// 1. Main Container
const mainContainer = new ContainerBuilder()
.setAccentColor(RarityColors[item.rarity || "C"] || Colors.Green);
// Header Section
const infoSection = new SectionBuilder()
.addTextDisplayComponents(
new TextDisplayBuilder().setContent(`# ${item.name}`),
new TextDisplayBuilder().setContent(item.description || "_No description available._"),
new TextDisplayBuilder().setContent(`### 🏷️ Price: ${item.formattedPrice}`)
);
// Set Thumbnail Accessory if we have an icon
if (thumbnailUrl) {
infoSection.setThumbnailAccessory(new ThumbnailBuilder().setURL(thumbnailUrl));
}
mainContainer.addSectionComponents(infoSection);
// Media Gallery for additional images (if multiple)
const mediaSources: string[] = [];
if (thumbnailUrl) mediaSources.push(thumbnailUrl);
if (displayImageUrl && displayImageUrl !== thumbnailUrl) mediaSources.push(displayImageUrl);
if (mediaSources.length > 1) {
mainContainer.addMediaGalleryComponents(
new MediaGalleryBuilder().addItems(
...mediaSources.map(src => new MediaGalleryItemBuilder().setURL(src))
)
);
}
// 2. Loot Table (if applicable)
if (item.usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX)) {
const lootboxEffect = item.usageData.effects.find((e: any) => e.type === EffectType.LOOTBOX);
const pool = lootboxEffect.pool as LootTableItem[];
const totalWeight = pool.reduce((sum, i) => sum + i.weight, 0);
mainContainer.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
mainContainer.addTextDisplayComponents(new TextDisplayBuilder().setContent("## 🎁 Potential Rewards"));
const groups: Record<string, string[]> = {};
for (const drop of pool) {
const chance = ((drop.weight / totalWeight) * 100).toFixed(1);
let line = "";
let rarity = "C";
switch (drop.type as any) {
case LootType.CURRENCY:
const currAmount = (drop.minAmount != null && drop.maxAmount != null)
? `${drop.minAmount} - ${drop.maxAmount}`
: (Array.isArray(drop.amount) ? `${drop.amount[0]} - ${drop.amount[1]}` : drop.amount || 0);
line = `**${currAmount} 🪙** (${chance}%)`;
rarity = "CURRENCY";
break;
case LootType.XP:
const xpAmount = (drop.minAmount != null && drop.maxAmount != null)
? `${drop.minAmount} - ${drop.maxAmount}`
: (Array.isArray(drop.amount) ? `${drop.amount[0]} - ${drop.amount[1]}` : drop.amount || 0);
line = `**${xpAmount} XP** (${chance}%)`;
rarity = "XP";
break;
case LootType.ITEM:
const referencedItems = context?.referencedItems;
if (drop.itemId && referencedItems?.has(drop.itemId)) {
const i = referencedItems.get(drop.itemId)!;
line = `**${i.name}** x${drop.amount || 1} (${chance}%)`;
rarity = i.rarity;
} else {
line = `**Unknown Item** (${chance}%)`;
rarity = "C";
}
break;
case LootType.NOTHING:
line = `**Nothing** (${chance}%)`;
rarity = "NOTHING";
break;
}
if (line) {
if (!groups[rarity]) groups[rarity] = [];
groups[rarity]!.push(line);
}
}
const order = ["SSR", "SR", "R", "C", "CURRENCY", "XP", "NOTHING"];
for (const rarity of order) {
if (groups[rarity] && groups[rarity]!.length > 0) {
mainContainer.addTextDisplayComponents(
new TextDisplayBuilder().setContent(`### ${TitleMap[rarity] || rarity}`),
new TextDisplayBuilder().setContent(groups[rarity]!.join("\n"))
);
}
}
}
// Purchase Row
const buyButton = new ButtonBuilder()
.setCustomId(`shop_buy_${item.id}`)
.setLabel(`Buy for ${item.price} 🪙`)
.setLabel(`Purchase for ${item.price} 🪙`)
.setStyle(ButtonStyle.Success)
.setEmoji("🛒");
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton);
mainContainer.addActionRowComponents(
new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton)
);
return { embeds: [embed], components: [row] };
containers.push(mainContainer);
return {
components: containers as any,
files,
flags: MessageFlags.IsComponentsV2
};
}
function defaultName(path: string): string {
return path.split("/").pop() || "image.png";
}

View File

@@ -1,10 +1,10 @@
import type { Interaction } from "discord.js";
import { TextChannel, MessageFlags } from "discord.js";
import { config } from "@shared/lib/config";
import { getGuildConfig } from "@shared/lib/config";
import { AuroraClient } from "@/lib/BotClient";
import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view";
import { FEEDBACK_CUSTOM_IDS, type FeedbackType, type FeedbackData } from "./feedback.types";
import { UserError } from "@/lib/errors";
import { UserError } from "@shared/lib/errors";
export const handleFeedbackInteraction = async (interaction: Interaction) => {
// Handle select menu for choosing feedback type
@@ -33,7 +33,13 @@ export const handleFeedbackInteraction = async (interaction: Interaction) => {
throw new UserError("An error occurred processing your feedback. Please try again.");
}
if (!config.feedbackChannelId) {
if (!interaction.guildId) {
throw new UserError("This action can only be performed in a server.");
}
const guildConfig = await getGuildConfig(interaction.guildId);
if (!guildConfig.feedbackChannelId) {
throw new UserError("Feedback channel is not configured. Please contact an administrator.");
}
@@ -52,7 +58,7 @@ export const handleFeedbackInteraction = async (interaction: Interaction) => {
};
// Get feedback channel
const channel = await AuroraClient.channels.fetch(config.feedbackChannelId).catch(() => null) as TextChannel | null;
const channel = await AuroraClient.channels.fetch(guildConfig.feedbackChannelId).catch(() => null) as TextChannel | null;
if (!channel) {
throw new UserError("Feedback channel not found. Please contact an administrator.");

View File

@@ -1,7 +1,8 @@
import { levelingService } from "@shared/modules/leveling/leveling.service";
import { economyService } from "@shared/modules/economy/economy.service";
import { userTimers } from "@db/schema";
import type { EffectHandler } from "./types";
import type { EffectHandler, ValidatedEffectPayload } from "./effect.types";
import { EffectType } from "@shared/lib/constants";
import type { LootTableItem } from "@shared/lib/types";
import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { inventory, items } from "@db/schema";
@@ -15,21 +16,21 @@ const getDuration = (effect: any): number => {
return effect.durationSeconds || 60; // Default to 60s if nothing provided
};
export const handleAddXp: EffectHandler = async (userId, effect, txFn) => {
export const handleAddXp: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.ADD_XP }>, txFn) => {
await levelingService.addXp(userId, BigInt(effect.amount), txFn);
return `Gained ${effect.amount} XP`;
};
export const handleAddBalance: EffectHandler = async (userId, effect, txFn) => {
export const handleAddBalance: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.ADD_BALANCE }>, txFn) => {
await economyService.modifyUserBalance(userId, BigInt(effect.amount), TransactionType.ITEM_USE, `Used Item`, null, txFn);
return `Gained ${effect.amount} 🪙`;
};
export const handleReplyMessage: EffectHandler = async (_userId, effect, _txFn) => {
export const handleReplyMessage: EffectHandler = async (_userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.REPLY_MESSAGE }>, _txFn) => {
return effect.message;
};
export const handleXpBoost: EffectHandler = async (userId, effect, txFn) => {
export const handleXpBoost: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.XP_BOOST }>, txFn) => {
const boostDuration = getDuration(effect);
const expiresAt = new Date(Date.now() + boostDuration * 1000);
await txFn.insert(userTimers).values({
@@ -45,7 +46,7 @@ export const handleXpBoost: EffectHandler = async (userId, effect, txFn) => {
return `XP Boost (${effect.multiplier}x) active for ${Math.floor(boostDuration / 60)}m`;
};
export const handleTempRole: EffectHandler = async (userId, effect, txFn) => {
export const handleTempRole: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.TEMP_ROLE }>, txFn) => {
const roleDuration = getDuration(effect);
const roleExpiresAt = new Date(Date.now() + roleDuration * 1000);
await txFn.insert(userTimers).values({
@@ -62,11 +63,11 @@ export const handleTempRole: EffectHandler = async (userId, effect, txFn) => {
return `Temporary Role granted for ${Math.floor(roleDuration / 60)}m`;
};
export const handleColorRole: EffectHandler = async (_userId, _effect, _txFn) => {
export const handleColorRole: EffectHandler = async (_userId, _effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.COLOR_ROLE }>, _txFn) => {
return "Color Role Equipped";
};
export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
export const handleLootbox: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.LOOTBOX }>, txFn) => {
const pool = effect.pool as LootTableItem[];
if (!pool || pool.length === 0) return "The box is empty...";
@@ -86,7 +87,11 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
// Process Winner
if (winner.type === LootType.NOTHING) {
return winner.message || "You found nothing inside.";
return {
type: 'LOOTBOX_RESULT',
rewardType: 'NOTHING',
message: winner.message || "You found nothing inside."
};
}
if (winner.type === LootType.CURRENCY) {
@@ -96,7 +101,12 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
}
if (amount > 0) {
await economyService.modifyUserBalance(userId, BigInt(amount), TransactionType.LOOTBOX, 'Lootbox Reward', null, txFn);
return winner.message || `You found ${amount} 🪙!`;
return {
type: 'LOOTBOX_RESULT',
rewardType: 'CURRENCY',
amount: amount,
message: winner.message || `You found ${amount} 🪙!`
};
}
}
@@ -107,7 +117,12 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
}
if (amount > 0) {
await levelingService.addXp(userId, BigInt(amount), txFn);
return winner.message || `You gained ${amount} XP!`;
return {
type: 'LOOTBOX_RESULT',
rewardType: 'XP',
amount: amount,
message: winner.message || `You gained ${amount} XP!`
};
}
}
@@ -123,7 +138,18 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
where: (items: any, { eq }: any) => eq(items.id, winner.itemId!)
});
if (item) {
return winner.message || `You found ${quantity > 1 ? quantity + 'x ' : ''}**${item.name}**!`;
return {
type: 'LOOTBOX_RESULT',
rewardType: 'ITEM',
amount: Number(quantity),
item: {
name: item.name,
rarity: item.rarity,
description: item.description,
image: item.imageUrl || item.iconUrl
},
message: winner.message || `You found ${quantity > 1 ? quantity + 'x ' : ''}**${item.name}**!`
};
}
} catch (e) {
console.error("Failed to fetch item name for lootbox message", e);

View File

@@ -0,0 +1,41 @@
import {
handleAddXp,
handleAddBalance,
handleReplyMessage,
handleXpBoost,
handleTempRole,
handleColorRole,
handleLootbox
} from "./effect.handlers";
import type { EffectHandler, ValidatedEffectPayload } from "./effect.types";
import { EffectPayloadSchema } from "./effect.types";
import { UserError } from "@shared/lib/errors";
import type { Transaction } from "@shared/lib/types";
export const effectHandlers: Record<string, EffectHandler> = {
'ADD_XP': handleAddXp,
'ADD_BALANCE': handleAddBalance,
'REPLY_MESSAGE': handleReplyMessage,
'XP_BOOST': handleXpBoost,
'TEMP_ROLE': handleTempRole,
'COLOR_ROLE': handleColorRole,
'LOOTBOX': handleLootbox
};
export async function validateAndExecuteEffect(
effect: unknown,
userId: string,
tx: Transaction
) {
const result = EffectPayloadSchema.safeParse(effect);
if (!result.success) {
throw new UserError(`Invalid effect configuration: ${result.error.message}`);
}
const handler = effectHandlers[result.data.type];
if (!handler) {
throw new UserError(`Unknown effect type: ${result.data.type}`);
}
return handler(userId, result.data, tx);
}

View File

@@ -0,0 +1,71 @@
import type { Transaction } from "@shared/lib/types";
import { z } from "zod";
import { EffectType, LootType } from "@shared/lib/constants";
// Helper Schemas
const LootTableItemSchema = z.object({
type: z.nativeEnum(LootType),
weight: z.number(),
amount: z.number().optional(),
itemId: z.number().optional(),
minAmount: z.number().optional(),
maxAmount: z.number().optional(),
message: z.string().optional(),
});
const DurationSchema = z.object({
durationSeconds: z.number().optional(),
durationMinutes: z.number().optional(),
durationHours: z.number().optional(),
});
// Effect Schemas
const AddXpSchema = z.object({
type: z.literal(EffectType.ADD_XP),
amount: z.number().positive(),
});
const AddBalanceSchema = z.object({
type: z.literal(EffectType.ADD_BALANCE),
amount: z.number(),
});
const ReplyMessageSchema = z.object({
type: z.literal(EffectType.REPLY_MESSAGE),
message: z.string(),
});
const XpBoostSchema = DurationSchema.extend({
type: z.literal(EffectType.XP_BOOST),
multiplier: z.number(),
});
const TempRoleSchema = DurationSchema.extend({
type: z.literal(EffectType.TEMP_ROLE),
roleId: z.string(),
});
const ColorRoleSchema = z.object({
type: z.literal(EffectType.COLOR_ROLE),
roleId: z.string(),
});
const LootboxSchema = z.object({
type: z.literal(EffectType.LOOTBOX),
pool: z.array(LootTableItemSchema),
});
// Union Schema
export const EffectPayloadSchema = z.discriminatedUnion('type', [
AddXpSchema,
AddBalanceSchema,
ReplyMessageSchema,
XpBoostSchema,
TempRoleSchema,
ColorRoleSchema,
LootboxSchema,
]);
export type ValidatedEffectPayload = z.infer<typeof EffectPayloadSchema>;
export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<any>;

View File

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

View File

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

View File

@@ -1,6 +1,9 @@
import { EmbedBuilder } from "discord.js";
import { EmbedBuilder, AttachmentBuilder } from "discord.js";
import type { ItemUsageData } from "@shared/lib/types";
import { EffectType } from "@shared/lib/constants";
import { resolveAssetUrl, isLocalAssetUrl } from "@shared/lib/assets";
import { join } from "path";
import { existsSync } from "fs";
/**
* Inventory entry with item details
@@ -31,24 +34,107 @@ export function getInventoryEmbed(items: InventoryEntry[], username: string): Em
/**
* Creates an embed showing the results of using an item
*/
export function getItemUseResultEmbed(results: string[], item?: { name: string, iconUrl: string | null, usageData: any }): EmbedBuilder {
const description = results.map(r => `${r}`).join("\n");
export function getItemUseResultEmbed(results: any[], item?: { name: string, iconUrl: string | null, usageData: any }): { embed: EmbedBuilder, files: AttachmentBuilder[] } {
const embed = new EmbedBuilder();
const files: AttachmentBuilder[] = [];
const otherMessages: string[] = [];
let lootResult: any = null;
// Check if it was a lootbox
const isLootbox = item?.usageData?.effects?.some((e: any) => e.type === EffectType.LOOTBOX);
const embed = new EmbedBuilder()
.setDescription(description)
.setColor(isLootbox ? 0xFFD700 : 0x2ecc71); // Gold for lootbox, Green otherwise
if (isLootbox && item) {
embed.setTitle(`🎁 ${item.name} Opened!`);
if (item.iconUrl) {
embed.setThumbnail(item.iconUrl);
for (const res of results) {
if (typeof res === 'object' && res.type === 'LOOTBOX_RESULT') {
lootResult = res;
} else {
otherMessages.push(typeof res === 'string' ? `${res}` : `${JSON.stringify(res)}`);
}
} else {
embed.setTitle(item ? `✅ Used ${item.name}` : "✅ Item Used!");
}
return embed;
// Default Configuration
const isLootbox = item?.usageData?.effects?.some((e: any) => e.type === EffectType.LOOTBOX);
embed.setColor(isLootbox ? 0xFFD700 : 0x2ecc71); // Gold for lootbox, Green otherwise by default
embed.setTimestamp();
if (lootResult) {
embed.setTitle(`🎁 ${item?.name || "Lootbox"} Opened!`);
if (lootResult.rewardType === 'ITEM' && lootResult.item) {
const i = lootResult.item;
const amountStr = lootResult.amount > 1 ? `x${lootResult.amount}` : '';
// Rarity Colors
const rarityColors: Record<string, number> = {
'C': 0x95A5A6, // Gray
'R': 0x3498DB, // Blue
'SR': 0x9B59B6, // Purple
'SSR': 0xF1C40F // Gold
};
const rarityKey = i.rarity || 'C';
if (rarityKey in rarityColors) {
embed.setColor(rarityColors[rarityKey] ?? 0x95A5A6);
} else {
embed.setColor(0x95A5A6);
}
if (i.image) {
if (isLocalAssetUrl(i.image)) {
const imagePath = join(process.cwd(), "bot/assets/graphics", i.image.replace(/^\/?assets\//, ""));
if (existsSync(imagePath)) {
const imageName = defaultName(i.image);
if (!files.find(f => f.name === imageName)) {
files.push(new AttachmentBuilder(imagePath, { name: imageName }));
}
embed.setImage(`attachment://${imageName}`);
}
} else {
const imgUrl = resolveAssetUrl(i.image);
if (imgUrl) embed.setImage(imgUrl);
}
}
embed.setDescription(`**You found ${i.name} ${amountStr}!**\n${i.description || '_'}`);
embed.addFields({ name: 'Rarity', value: rarityKey, inline: true });
} else if (lootResult.rewardType === 'CURRENCY') {
embed.setColor(0xF1C40F);
embed.setDescription(`**You found ${lootResult.amount.toLocaleString()} 🪙 AU!**`);
} else if (lootResult.rewardType === 'XP') {
embed.setColor(0x2ECC71); // Green
embed.setDescription(`**You gained ${lootResult.amount.toLocaleString()} XP!**`);
} else {
// Nothing or Message
embed.setDescription(lootResult.message);
embed.setColor(0x95A5A6); // Gray
}
} else {
// Standard item usage
embed.setTitle(item ? `✅ Used ${item.name}` : "✅ Item Used!");
embed.setDescription(otherMessages.join("\n") || "Effect applied.");
if (isLootbox && item && item.iconUrl) {
if (isLocalAssetUrl(item.iconUrl)) {
const iconPath = join(process.cwd(), "bot/assets/graphics", item.iconUrl.replace(/^\/?assets\//, ""));
if (existsSync(iconPath)) {
const iconName = defaultName(item.iconUrl);
if (!files.find(f => f.name === iconName)) {
files.push(new AttachmentBuilder(iconPath, { name: iconName }));
}
embed.setThumbnail(`attachment://${iconName}`);
}
} else {
const resolvedIconUrl = resolveAssetUrl(item.iconUrl);
if (resolvedIconUrl) embed.setThumbnail(resolvedIconUrl);
}
}
}
if (otherMessages.length > 0 && lootResult) {
embed.addFields({ name: "Other Effects", value: otherMessages.join("\n") });
}
return { embed, files };
}
function defaultName(path: string): string {
return path.split("/").pop() || "image.png";
}

View File

@@ -1,4 +1,13 @@
import { EmbedBuilder } from "discord.js";
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ContainerBuilder,
TextDisplayBuilder,
SeparatorBuilder,
SeparatorSpacingSize,
MessageFlags
} from "discord.js";
/**
* Quest entry with quest details and progress
@@ -7,12 +16,33 @@ interface QuestEntry {
progress: number | null;
completedAt: Date | null;
quest: {
id: number;
name: string;
description: string | null;
triggerEvent: string;
requirements: any;
rewards: any;
};
}
/**
* Available quest interface
*/
interface AvailableQuest {
id: number;
name: string;
description: string | null;
rewards: any;
requirements: any;
}
// Color palette for containers
const COLORS = {
ACTIVE: 0x3498db, // Blue - in progress
AVAILABLE: 0x2ecc71, // Green - available
COMPLETED: 0xf1c40f // Gold - completed
};
/**
* Formats quest rewards object into a human-readable string
*/
@@ -20,35 +50,169 @@ function formatQuestRewards(rewards: { xp?: number, balance?: number }): string
const rewardStr: string[] = [];
if (rewards?.xp) rewardStr.push(`${rewards.xp} XP`);
if (rewards?.balance) rewardStr.push(`${rewards.balance} 🪙`);
return rewardStr.join(", ");
return rewardStr.join(" ") || "None";
}
/**
* Returns the quest status display string
* Renders a simple progress bar
*/
function getQuestStatus(completedAt: Date | null): string {
return completedAt ? "✅ Completed" : "📝 In Progress";
function renderProgressBar(current: number, total: number, size: number = 10): string {
const percentage = Math.min(current / total, 1);
const progress = Math.round(size * percentage);
const empty = size - progress;
const progressText = "▰".repeat(progress);
const emptyText = "▱".repeat(empty);
return `${progressText}${emptyText} ${Math.round(percentage * 100)}%`;
}
/**
* Creates an embed displaying a user's quest log
* Creates Components v2 containers for the quest list (active quests only)
*/
export function getQuestListEmbed(userQuests: QuestEntry[]): EmbedBuilder {
const embed = new EmbedBuilder()
.setTitle("📜 Quest Log")
.setColor(0x3498db); // Blue
export function getQuestListComponents(userQuests: QuestEntry[]): ContainerBuilder[] {
// Filter to only show in-progress quests (not completed)
const activeQuests = userQuests.filter(entry => entry.completedAt === null);
const container = new ContainerBuilder()
.setAccentColor(COLORS.ACTIVE)
.addTextDisplayComponents(
new TextDisplayBuilder().setContent("# 📜 Quest Log"),
new TextDisplayBuilder().setContent("-# Your active quests")
);
if (activeQuests.length === 0) {
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
container.addTextDisplayComponents(
new TextDisplayBuilder().setContent("*You have no active quests. Check available quests!*")
);
return [container];
}
activeQuests.forEach((entry) => {
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
userQuests.forEach(entry => {
const status = getQuestStatus(entry.completedAt);
const rewards = entry.quest.rewards as { xp?: number, balance?: number };
const rewardsText = formatQuestRewards(rewards);
embed.addFields({
name: `${entry.quest.name} (${status})`,
value: `${entry.quest.description}\n**Rewards:** ${rewardsText}\n**Progress:** ${entry.progress}%`,
inline: false
});
const requirements = entry.quest.requirements as { target?: number };
const target = requirements?.target || 1;
const progress = entry.progress || 0;
const progressBar = renderProgressBar(progress, target);
container.addTextDisplayComponents(
new TextDisplayBuilder().setContent(`**${entry.quest.name}**`),
new TextDisplayBuilder().setContent(entry.quest.description || "*No description*"),
new TextDisplayBuilder().setContent(`📊 ${progressBar} \`${progress}/${target}\` • 🎁 ${rewardsText}`)
);
});
return embed;
return [container];
}
/**
* Creates Components v2 containers for available quests with inline accept buttons
*/
export function getAvailableQuestsComponents(availableQuests: AvailableQuest[]): ContainerBuilder[] {
const container = new ContainerBuilder()
.setAccentColor(COLORS.AVAILABLE)
.addTextDisplayComponents(
new TextDisplayBuilder().setContent("# 🗺️ Available Quests"),
new TextDisplayBuilder().setContent("-# Quests you can accept")
);
if (availableQuests.length === 0) {
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
container.addTextDisplayComponents(
new TextDisplayBuilder().setContent("*No new quests available at the moment.*")
);
return [container];
}
// Limit to 10 quests (5 action rows max with 2 added for navigation)
const questsToShow = availableQuests.slice(0, 10);
questsToShow.forEach((quest) => {
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
const rewards = quest.rewards as { xp?: number, balance?: number };
const rewardsText = formatQuestRewards(rewards);
const requirements = quest.requirements as { target?: number };
const target = requirements?.target || 1;
container.addTextDisplayComponents(
new TextDisplayBuilder().setContent(`**${quest.name}**`),
new TextDisplayBuilder().setContent(quest.description || "*No description*"),
new TextDisplayBuilder().setContent(`🎯 Goal: \`${target}\` • 🎁 ${rewardsText}`)
);
// Add accept button inline within the container
container.addActionRowComponents(
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(`quest_accept:${quest.id}`)
.setLabel("Accept Quest")
.setStyle(ButtonStyle.Success)
.setEmoji("✅")
)
);
});
return [container];
}
/**
* Returns action rows for navigation only
*/
export function getQuestActionRows(viewType: 'active' | 'available'): ActionRowBuilder<ButtonBuilder>[] {
// Navigation row
const navRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId("quest_view_active")
.setLabel("📜 Active")
.setStyle(viewType === 'active' ? ButtonStyle.Primary : ButtonStyle.Secondary)
.setDisabled(viewType === 'active'),
new ButtonBuilder()
.setCustomId("quest_view_available")
.setLabel("🗺️ Available")
.setStyle(viewType === 'available' ? ButtonStyle.Primary : ButtonStyle.Secondary)
.setDisabled(viewType === 'available')
);
return [navRow];
}
/**
* Creates Components v2 celebratory message for quest completion
*/
export function getQuestCompletionComponents(quest: any, rewards: { xp: bigint, balance: bigint }): ContainerBuilder[] {
const rewardsText = formatQuestRewards({
xp: Number(rewards.xp),
balance: Number(rewards.balance)
});
const container = new ContainerBuilder()
.setAccentColor(COLORS.COMPLETED)
.addTextDisplayComponents(
new TextDisplayBuilder().setContent("# 🎉 Quest Completed!"),
new TextDisplayBuilder().setContent(`Congratulations! You've completed **${quest.name}**`)
)
.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small))
.addTextDisplayComponents(
new TextDisplayBuilder().setContent(`📝 ${quest.description || "No description provided."}`),
new TextDisplayBuilder().setContent(`🎁 **Rewards Earned:** ${rewardsText}`)
);
return [container];
}
/**
* Gets MessageFlags and allowedMentions for Components v2 messages
*/
export function getComponentsV2MessageFlags() {
return {
flags: MessageFlags.IsComponentsV2,
allowedMentions: { parse: [] as const }
};
}

View File

@@ -1,4 +1,5 @@
import { temporaryRoleService } from "@shared/modules/system/temp-role.service";
import { terminalService } from "@shared/modules/terminal/terminal.service";
export const schedulerService = {
start: () => {
@@ -10,7 +11,6 @@ export const schedulerService = {
}, 60 * 1000);
// 2. Terminal Update Loop (every 60s)
const { terminalService } = require("@shared/modules/terminal/terminal.service");
setInterval(() => {
terminalService.update();
}, 60 * 1000);

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