221 Commits

Author SHA1 Message Date
syntaxbullet
222f32d98f Improve panel layout overflow on small screens
- Prevent horizontal overflow in the main layout
- Stack game room controls vertically on narrow viewports
- Truncate long room and user labels to keep cards stable
2026-04-10 12:13:03 +02:00
syntaxbullet
454ded8b26 fix: remove pico files. 2026-04-10 12:02:37 +02:00
syntaxbullet
9e85ba1fa4 Refresh waiting room cleanup on activity
- Extend waiting rooms while players or spectators are active
- Make cleanup time configurable for tests and defaults
- Tweak lobby layout for smaller screens
2026-04-10 12:00:59 +02:00
syntaxbullet
2fb8d559a6 Streamline game lobby copy and room facts
- Tighten lobby messaging and empty-state copy
- Remove the obsolete "What Changed" sidebar
- Build room facts inline instead of with `useMemo`
2026-04-10 11:49:36 +02:00
syntaxbullet
b0a103d8ce Show default chess control detail in room summary
- Use the selected chess time control detail when available
- Fall back to a balanced default description when no match is found
2026-04-10 11:36:32 +02:00
syntaxbullet
cb056e010f Redesign game lobby and room creation flow
- Split chess and blackjack setup into guided creation steps
- Add chess time control presets and reusable lobby room metrics
- Improve room filtering, ordering, and live connection state
2026-04-10 11:34:12 +02:00
syntaxbullet
de15cb4206 Show explicit blackjack settlements across the stack
- Replace round payout multipliers with per-player settlement amounts
- Update blackjack panel to display wager, payout, and net results
2026-04-10 11:03:58 +02:00
syntaxbullet
f796cac6be Add chess premoves and time control metadata
- pass chess room time control to the client
- add premove handling and richer chess board UI
- update join result typing for room options
2026-04-10 10:19:33 +02:00
syntaxbullet
31580df919 update example readme with session secret 2026-04-09 22:15:58 +02:00
syntaxbullet
9a17209db2 Add CI and deploy workflow
- Run typecheck, panel build, and integration tests in Gitea
- Deploy main branch to VPS over SSH and verify the health endpoint
2026-04-09 22:04:23 +02:00
syntaxbullet
04656790d2 Use isolated test runner in deploy workflow
- Update deploy CI to invoke `shared/scripts/test-isolated.sh`
- Refresh the inline note for the test environment setup
2026-04-09 21:46:53 +02:00
syntaxbullet
25a0bd3431 Sign panel sessions and isolate test runs
- Replace in-memory auth sessions with signed cookies and signed OAuth state
- Add auth route coverage and update panel/web server wiring
- Switch test script to per-file Bun processes and clean up type checks
2026-04-09 21:44:05 +02:00
syntaxbullet
6abbd4652a Refresh repository documentation
- Rewrite AGENTS and README files to match the current app layout
- Document API routes, trivia UI, and the active panel design language
2026-04-09 21:10:10 +02:00
syntaxbullet
8369d10bab Add auth checks for user routes and dashboard state
- tighten route authorization and schema handling
- update user route tests and server coverage
- refresh player dashboard behavior
2026-04-09 20:42:32 +02:00
syntaxbullet
bdfe0d1594 chore: fix blackjack UI overflow and table sizing
- Remove max-w-4xl constraint to allow single-player tables to expand
- Add responsive container sizing: 95vw on mobile, up to 6xl on large screens
- Add horizontal scrolling for split hands with overflow-x-auto
- Increase spacing between split hands on larger screens (gap-1.5 -> sm:gap-2)
- Add padding to container for better spacing on small screens
2026-04-06 15:18:15 +02:00
syntaxbullet
034f2ead1c fix: correct blackjack PnL calculation and enhance UI
- Fix incorrect PnL calculations where multipliers were treated as amounts
- Add proper net profit calculation: (multiplier × bet) - bet
- Update UI with gradient backgrounds, icons, and improved animations
- Add slide-in and pulse-slow keyframe animations
- Enhance dealer area with status-based styling
- Improve action buttons with colored gradients and hover effects
- Revise round result banner with better visual hierarchy
2026-04-06 15:11:47 +02:00
syntaxbullet
06c3891045 fix: Correct PnL calculation by using betAmount in net profit computation
- Add betAmount field to BlackjackState to track the base bet
- Fix finishPlayerTurns: multiply hand.bet by state.betAmount for actual money bets
- Fix GameServer: roundPayouts are already gross payouts (not multipliers)
- Update cumulative PnL calculation to correctly subtract actual bet amount
- Add betAmount support to riggedState test helper
2026-04-06 14:50:40 +02:00
syntaxbullet
f09cbe6939 fix: Restore myBetPlaced variable that was incorrectly removed
- Re-added myBetPlaced variable which is used for betting phase UI rendering
2026-04-06 14:44:24 +02:00
syntaxbullet
cd9e1e7242 fix: Correct PnL calculation to show net profit instead of gross payout
- GameServer.ts: Calculate netProfit = grossPayout - betAmount and send as 'net'
  instead of sending gross payout labeled as net
- BlackjackGame.tsx: Fix PnL calculations to use net profit correctly
  - Hand win/blackjack now shows net profit (payout minus bet)
  - Lose correctly shows negative bet amount
  - Round result banner displays roundNet (net profit) with appropriate colors
- Remove dead code (myPnl variable that was calculated but never used)
- Update color coding: green for profit, red for loss, blue for zero balance
2026-04-06 14:42:18 +02:00
syntaxbullet
966bad98d3 Add cumulative PnL tracking to Blackjack game
- Added cumulativePnl field to PlayerSeat and PlayerSeatView types
- Added myCumulativePnl to PlayerView for UI display
- Track net profit/loss across rounds in the game state
- Update round result banner to show both round net and total balance
- Add player seat PnL indicator with color coding (green/red)
- Preserve cumulativePnl when players stay seated through rounds
- Initialize new players with cumulativePnl = 0
- Added comprehensive tests for cumulative PnL tracking
2026-04-06 14:31:58 +02:00
syntaxbullet
2b89fb7ede docs: rename CLAUDE.md to AGENTS.md across the project 2026-04-06 14:18:56 +02:00
syntaxbullet
0fc88323ea fix(panel): add GAME_BET and GAME_WIN to transaction type config
Missing TYPE_CONFIG entries caused both transaction types to fall back to
sign: "+", making bet deductions appear as credits in the admin panel.
The actual economy values were correct (negative for bets).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:59:41 +02:00
syntaxbullet
96eba8270c fix(games): skip upfront bet deduction for per-round betting games
Games with getActionCost (like blackjack) handle bet deductions per-round
via place_bet actions. The old deductBetsAndStart was also charging at game
start, causing double-deduction: wins netted zero and losses doubled.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:54:21 +02:00
syntaxbullet
a36c05994c feat(games): refactor blackjack for continuous play, split/double, and table UI
Transform blackjack from single-round to continuous-play table sessions with
round lifecycle (betting → playing → resolved → betting), split/double down
actions, per-hand bet tracking, leave/join table mid-session, and a responsive
felt-style table UI with arc-positioned player seats.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:41:49 +02:00
syntaxbullet
ef78a85b9c feat(games): implement blackjack game plugin with manual start and custom payouts
Adds a full blackjack game with dealer AI, hit/stand/double-down actions,
and per-player payout multipliers (house-edge model). Extends the game
framework with manualStart support and a START_GAME WebSocket message so
hosts can begin when ready. Generalizes bet settlement transaction
descriptions from chess-specific to game-agnostic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 18:48:25 +02:00
syntaxbullet
f368da9e73 feat(games): add solo mode to room creation and AU currency betting
Solo mode is now a toggle in the chess room creation modal, available
to all users instead of admin-only. Betting lets players wager AU on
games with preset amounts, async deduction on game start, and automatic
payout/refund on game end.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 18:09:03 +02:00
syntaxbullet
4f89ed3082 fix(games): stop spectator broadcast from overwriting player state
Players subscribe to the room pub/sub channel and also receive direct
GAME_STATE messages. The GAME_STARTED and GAME_UPDATE broadcasts carry
the spectator view (no myColor/legalMoves), and were blindly overwriting
gameState — making isPlayerView() return false and disabling all
interaction. Now these broadcast handlers only update gameState for
spectators; players rely exclusively on the direct GAME_STATE message.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:30:45 +02:00
syntaxbullet
0d8152914a refactor(games): inline SVG pieces instead of <img> elements
Replace external SVG files loaded via <img> tags with inline React SVG
components. The <img> approach was a DOM-level problem — replaced elements
sit outside React's tree and interfere with dnd-kit's pointer event
pipeline. Inline SVGs are native JSX nodes that participate correctly
in event bubbling, matching how react-chessboard's default pieces work.

Removes panel/public/pieces/ (12 SVG files) in favor of a single
pieces.tsx module with the same cburnett artwork as JSX.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:27:20 +02:00
syntaxbullet
12809623c1 fix(games): fix chess piece interaction and solo mode
Two bugs fixed:
- Piece <img> elements were intercepting pointer events, preventing
  dnd-kit drag handlers and square click handlers from firing. Added
  pointerEvents: "none" so events pass through to the board framework.
- Solo test mode (fillRoom with duplicate player IDs) always resolved
  to "white" in colorOfPlayer, making black moves impossible. Now
  detects duplicate IDs and returns the current turn's color instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:23:16 +02:00
syntaxbullet
a29bb63a1d feat(games): implement chess game plugin with full UI
Add chess as the first game plugin using the existing multiplayer framework.
Server-side game logic uses chess.js with server-authoritative clock management.
Client uses react-chessboard v5 with cburnett piece set, drag-and-drop + click-to-move,
configurable time controls (bullet/blitz/rapid/classical/none), draw offers,
resignation, and timeout detection. Extends the game framework with room creation
options to support per-game configuration. Includes 57 tests covering all code paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:59:26 +02:00
syntaxbullet
9e95194627 fix(panel): double negative sign on transaction amounts
formatAmount now strips the sign since the renderer already prepends
+/- based on TYPE_CONFIG. Raw DB values like "-180" were getting a
second "-" prefix, showing as "--180".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:28:40 +02:00
syntaxbullet
451fb206a6 fix(panel): transaction types showing wrong sign for trivia, trades, etc.
TYPE_CONFIG only had 6 of 14 transaction types. Missing types fell back
to sign: null which rendered as negative. Added all TransactionType enum
values and derived the filter dropdown from TYPE_CONFIG keys.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:25:25 +02:00
syntaxbullet
e3c49effdb feat(panel): replace all placeholder pages with real admin views
- Classes: full CRUD with list + detail panel
- Quests: CRUD with search, trigger events and reward fields
- Lootdrops: stat cards, spawn form, filter tabs, cancel action
- Moderation: case list with filters, detail panel, create + resolve
- Transactions: color-coded amounts, type/user filters, pagination
- Rename player dashboard currency label from Gold to AU (Astral Units)
- Remove unused placeholders map from App.tsx

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:19:23 +02:00
syntaxbullet
5c40249a18 feat(panel): implement player leaderboards page
Replaces the placeholder with a real leaderboard view showing top 10
players by level, wealth, and net worth. Reuses the existing /api/stats
endpoint which players already have access to.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:02:43 +02:00
syntaxbullet
b645f55f57 fix(panel): player inventory not loading due to API response mismatch
Frontend expected { items } but API returns { inventory } with nested
item objects. Fixes response key, aligns InventoryEntry type to actual
API shape, and separates error handling so a failed inventory fetch
shows an error instead of silently displaying "No items yet".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:55:25 +02:00
syntaxbullet
838fbe1b50 feat(panel): group sidebar nav so admins see both admin and player views
Introduces NavGroup structure with labeled sections in the sidebar.
Admins see "Administration" and "Player" groups; players see a flat
list unchanged. Extracts SidebarNavItem, SidebarNavSection, and
SidebarUserProfile components from the monolithic sidebarContent blob.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:45:04 +02:00
syntaxbullet
94d259e92a fix(panel): guild settings not pre-filling from database
Guild draft was initialized with defaults before the API response
arrived, then never updated because the !guildDraft guard prevented
overwriting. Gate initialization on !loading so saved values are used.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:28:40 +02:00
syntaxbullet
56db5bc998 refactor(games): rework room lifecycle events and remove chess plugin
Consolidate room leave/delete event handling into RoomManager emitter,
remove redundant PLAYER_LEFT publishes from GameServer, and delete the
chess game plugin (board, types, tests) in favor of the new plugin
architecture. Add per-module CLAUDE.md files for leveling, guild-settings,
feature-flags, db, api, and panel to improve agent navigability.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:19:51 +02:00
syntaxbullet
ebac1ad6cc fix(chess): migrate ChessBoard to react-chessboard v5 API
react-chessboard v5 moved all props into an `options` object and
renamed several callbacks/style props. The v4-style props were silently
ignored, causing pieces to snap back, no legal-move highlights, and no
WS events on drop. Also adds a custom promotion dialog since v5 removed
the built-in one.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 19:26:24 +02:00
syntaxbullet
abca1922f2 chore: change styling 2026-04-02 19:05:36 +02:00
syntaxbullet
e0dcfe6abe fix: (chess) new styling 2026-04-02 17:28:50 +02:00
syntaxbullet
132f92d2d9 fix(chess): optimistic moves and forfeit UI feedback
- Add localFen/localFenRef in ChessBoard for optimistic piece placement,
  preventing snap-back while awaiting server confirmation
- Sync localFen from server state on each chess.fen update
- Guard GAME_STATE handler in useGameRoom from overwriting a finished
  roomStatus, fixing the race where GAME_ENDED (pub/sub) arrives before
  GAME_STATE (direct ws.send)
- Reset confirmForfeit immediately on forfeit dispatch for instant UI feedback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 17:06:58 +02:00
syntaxbullet
70a149ab82 refactor(games): overhaul WS game system with improved UX and solo test support
Backend:
- Fix session never being attached to ws.data at upgrade time
- Add GameServer class: connection registry, per-connection room tracking,
  automatic room cleanup on disconnect via ws.data.rooms
- Replace ws-handler.ts with typed event-driven architecture using mitt
- Remove redundant subscription tracking from RoomManager
- Add JOIN_RESULT with player/spectator lists replacing error-as-control-flow
- Add SESSION_REPLACED for multi-tab same-account detection
- Add FILL_ROOM command for admin solo testing (fills empty slots with host)
- Fix dual-schema routing; remove game types from WsMessageSchema
- Per-player personalized views sent directly after each action

Chess plugin:
- Allow same-player (solo) mode: skip color/turn ownership checks
- Fix forfeit and disconnect handling in solo mode (winner: null)

Frontend:
- Click-to-move with legal move dots and last-move highlight
- Auto-scroll move history, forfeit confirmation, turn-reactive board border
- JOIN_RESULT initialises player/spectator lists immediately on join
- Contextual connecting state, player slot cards in waiting room
- Copy-invite button with Copied! flash, Back to Lobby CTA on finish
- Session-replaced warning banner with Rejoin here action
- Lobby passes preferAs intent through route state
- Admin waiting room shows Start Solo Test button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 16:41:13 +02:00
syntaxbullet
26a0e532f6 fix(chess): prevent duplicate players and fix move detection
- Prevent same player from joining as both white and black
- Add validation to reject duplicate players in RoomManager
- Fix spectator status not resetting when joining as player
- Use ref to track latest chess state in ChessBoard for accurate move validation
2026-04-02 15:36:05 +02:00
syntaxbullet
e521d3086f fix(chess): admin users and move registration
- Add role field to JOIN_ROOM message schema
- Allow admin users to join rooms and be added as players
- Update panel to pass user role when joining game rooms
- Fix chess move coordinates in tests (algebraic notation)
- Ensure admin users can make moves for both sides
2026-04-02 15:27:56 +02:00
syntaxbullet
9c4da51cfb fix(chess): send game updates to move sender + responsive mobile redesign
Bun's ws.publish() excludes the sender, so the player making a move never
received the GAME_UPDATE with the new FEN — causing pieces to snap back.
Added ctx.send() alongside ctx.publish() for GAME_UPDATE and GAME_ENDED.

Also redesigned the panel for mobile: hamburger drawer sidebar, responsive
chess board sizing via ResizeObserver, stacked layouts on small screens,
and touch-friendly modals/controls across lobby and game pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:58:40 +02:00
syntaxbullet
24211dca14 fix(chess): robust client-side validation and admin self-play support
Wrap chess.js move validation in try-catch for invalid moves. Fix admin
self-play by detecting when both players share the same ID and allowing
either color's pieces to be dragged on the current turn.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:27:33 +02:00
syntaxbullet
87b66cd65d feat(games): allow admins to play against themselves
Skip the duplicate-player check when the joining user has the admin
role, so admins can test games solo by joining their own room as both
players.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:24:23 +02:00
syntaxbullet
0dadc82f84 feat(chess): replace custom engine with chess.js and react-chessboard
Swap the custom move validation and Unicode piece grid for chess.js
(full rules engine with check/checkmate/castling/en passant/promotion)
and react-chessboard (drag-and-drop SVG board). Board styled to match
the purple dark theme and auto-orients to the player's color.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:22:27 +02:00
syntaxbullet
5527981fff fix(panel): keep WebSocket alive across route transitions
Disconnecting and reconnecting the WebSocket on every route change caused
a race condition: the old socket's async onclose handler would null out
globalWs after the new socket was created, causing JOIN_ROOM messages to
be silently dropped. The WebSocket is a global singleton — keep it alive
and let the built-in reconnection logic handle actual disconnects.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:03:35 +02:00
syntaxbullet
9f105ada5e fix(games): send room state directly to joining client
Bun's ws.publish() excludes the sender, so the joining client never
received the PLAYER_JOINED message and stayed stuck on the connecting
spinner. Now sends the message directly to the joiner via ctx.send
in addition to publishing to other room subscribers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:56:24 +02:00
syntaxbullet
cac9fae142 fix(games): allow room creator to rejoin without error
The room creator was added as a player during createRoom, but when
GameRoom mounted it sent JOIN_ROOM which failed with "Already in room",
leaving the UI stuck on the loading spinner. Now treats duplicate joins
as successful rejoins, which also fixes reconnection after page refresh.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:52:59 +02:00
syntaxbullet
b832723d6b fix(panel): show login screen instead of enrollment page for unauthenticated users
/auth/me was returning enrolled: false for all unauthenticated requests,
causing the frontend to show the NotEnrolled page before login. Enrollment
is already enforced server-side during the OAuth callback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:45:37 +02:00
syntaxbullet
0c3b289ba0 feat(panel): implement GameLobby and GameRoom pages
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:31:12 +02:00
syntaxbullet
f4b36a745e feat(panel): implement player dashboard with stats and inventory
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:31:09 +02:00
syntaxbullet
3b53c9cb5f feat(games): register chess plugin on server startup
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:30:57 +02:00
syntaxbullet
3bdb720e4a feat(panel): migrate to React Router, role-based layout and routing
Replace useState-based page switching with react-router-dom Routes.
Layout now renders admin or player nav items based on user.role.
Add stub pages for PlayerDashboard, GameLobby, and GameRoom.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:29:14 +02:00
syntaxbullet
f290eeeb8a feat(panel): add game UI registry and chess board component
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:29:13 +02:00
syntaxbullet
4b3f6590cc feat(panel): add useGameRoom hook for per-room game state
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:28:42 +02:00
syntaxbullet
069c0b93ef feat(games): integrate game WS handler into server
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:28:41 +02:00
syntaxbullet
33a1848096 feat(games): implement RoomManager with room lifecycle and tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:26:26 +02:00
syntaxbullet
55df982a0b feat(games): implement chess plugin with tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:25:54 +02:00
syntaxbullet
eb7dfaf6f5 feat(panel): add shared useWebSocket hook with reconnection
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:25:52 +02:00
syntaxbullet
aa145592c5 feat(panel): add react-router-dom, update auth hook with roles, add NotEnrolled page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:23:39 +02:00
syntaxbullet
37fa5fc3c8 feat(auth): add enrollment check, role-based sessions, and player access
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:23:35 +02:00
syntaxbullet
db10ebe220 feat(games): add room types and game WS message schemas
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:23:11 +02:00
syntaxbullet
a5478dce2b feat(games): add GamePlugin interface and registry
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:22:48 +02:00
syntaxbullet
29b6153777 docs: add web games platform implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:58:07 +02:00
syntaxbullet
d3e83bac66 docs: remove ambiguous options field from CREATE_ROOM message
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:49:42 +02:00
syntaxbullet
40ae93f68b docs: add web games platform design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:49:21 +02:00
syntaxbullet
1e978dff58 refactor(panel): extract page sub-components from mega-files
Split ItemStudio (1863->388), Settings (1445->355), and Users
(1062->164) into focused sub-components under pages/components/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:36:39 +02:00
syntaxbullet
3c256ba0b2 refactor: centralize custom interaction IDs into constants
Replace all hardcoded custom ID strings with module-level constants.
Each module now has *_CUSTOM_IDS in its types file, using functions
for dynamic IDs and PREFIX for startsWith matching.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:36:35 +02:00
syntaxbullet
70d59a091a docs: fix drift in docs/main.md
Fix web/ -> api/, add missing panel/modules/graphics sections,
expand module and utility listings to match actual codebase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:36:26 +02:00
syntaxbullet
9569972cd6 docs: document interaction routing flow in CLAUDE.md
Add routing table mapping custom ID prefixes to handler files and
describe the ComponentInteractionHandler dispatch mechanism.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:36:22 +02:00
syntaxbullet
5bd390b4ee docs: add JSDoc to service public methods
One-line JSDoc on 82 methods across 11 service files for quick
scanning without reading full implementations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:36:18 +02:00
syntaxbullet
5f8819bb46 docs: add subdirectory CLAUDE.md files for key domain modules
Provide non-obvious business rules and constraints for economy,
inventory, quest, moderation, trade, and trivia modules to reduce
context-gathering overhead for AI tools.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:36:12 +02:00
syntaxbullet
b8cf136ff7 fix: fix wording for non-purchasable items 2026-03-31 16:56:41 +02:00
syntaxbullet
5188d86d61 fix(inventory): address code review findings
- Replace setTimeout race in use-item flow with explicit Back button
- Fix collector end handler to re-render current view instead of blanking
- Add appendUseBackButton helper to attach navigation to use results
- Remove unused isInventoryInteraction import
- Fix rarity test type assertions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 17:49:12 +01:00
syntaxbullet
6a1498813f feat(inventory): rewrite command with CV2 pagination and detail view
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 17:42:50 +01:00
syntaxbullet
e4f7c03005 feat(inventory): add inventory interaction handler utilities
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 17:37:30 +01:00
syntaxbullet
38098a02ea feat(inventory): rewrite inventory view with CV2 list and detail builders
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 17:32:44 +01:00
syntaxbullet
fa09ef25e2 feat(inventory): add squareEmoji to RARITY_CONFIG
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 17:04:07 +01:00
syntaxbullet
ba8afd144e docs: add inventory display redesign implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 17:01:09 +01:00
syntaxbullet
8ef1873410 docs: add ownership protection to inventory spec
Viewing another user's inventory is read-only — Use and Discard
buttons only render when viewer is the inventory owner, with a
server-side guard in the interaction handler.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:58:08 +01:00
syntaxbullet
289044e26f docs: add inventory display redesign spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:56:45 +01:00
syntaxbullet
47ea6d8620 feat: add quest settings tab to admin panel
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:33:44 +01:00
syntaxbullet
21b5fedfc9 chore: add migration for quest config column
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:29:42 +01:00
syntaxbullet
912ce5b942 feat: enforce active quest limit in assignQuest
Adds a limit check to assignQuest that reads maxActiveQuests from game
settings and throws a UserError when the user has reached their active
quest limit. Completed quests are excluded from the count.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:39:23 +01:00
syntaxbullet
4ead7e60b1 feat: wire QuestConfig into game settings service 2026-03-28 15:37:38 +01:00
syntaxbullet
e64ffdc4cb feat: add QuestConfig interface and column to game settings schema 2026-03-28 15:36:43 +01:00
syntaxbullet
1d601febcf fix: wire quest progress tracking for transfer, daily, trivia, and exam events
These domain events were only connected to dashboard recording but never
called questService.handleEvent(), so quests with triggers TRANSFER_OUT,
DAILY_REWARD, TRIVIA_WIN, and EXAM_REWARD never tracked progress. Added
userId and tx to event payloads and switched from emit to emitAsync for
transaction atomicity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:24:30 +01:00
syntaxbullet
3edda1d707 fix: reduce quests per page to 5 to stay within Discord's 40 total component limit
Discord counts all nested components (buttons inside action rows)
toward the message-level 40 component cap. 7 per page exceeded this
when pagination buttons were included.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 14:24:53 +01:00
syntaxbullet
e56e133a69 fix: add pagination to quest list to stay within Discord component limits
The available quests view was exceeding Discord's 40-component container
limit when many quests existed, causing an API error. Paginate both
active and available quest views at 7 quests per page with prev/next
navigation buttons.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 14:20:53 +01:00
syntaxbullet
0f871026eb docs: add impersonate panel design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:08:23 +01:00
syntaxbullet
782a138fd8 fix: strip query params from asset URLs before filesystem lookup
Icon URLs stored with cache-busting query params (e.g. ?v=123) caused
existsSync to fail, preventing Discord attachment fallback and leaving
unresolvable localhost URLs as thumbnails.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:48:19 +01:00
syntaxbullet
58d07a02fd fix: prefill item names in lootbox pool entries when editing
Resolve ITEM-type pool entry names from the API when loading an
existing lootbox for editing, so the ItemSearchPicker displays
the selected item instead of showing an empty default.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:38:11 +01:00
syntaxbullet
01bb73f6a2 chore: bump version 2026-03-26 15:21:28 +01:00
syntaxbullet
602147e961 feat: cap daily reward at 500 AU
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:21:01 +01:00
syntaxbullet
9e6bb8b148 fix: add non-null assertion for default rarity config fallback
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:15:53 +01:00
syntaxbullet
305a0b0553 fix: collapse double find() and add <0.1% guard for tiny drop rates
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:01:05 +01:00
syntaxbullet
023ff9fb1b feat: rework shop loot table into two-container Components V2 layout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 21:58:28 +01:00
syntaxbullet
56353a7756 fix: fix double newline in item description and add TODO comment on type cast
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 21:56:37 +01:00
syntaxbullet
86142cba6c feat: rewrite lootbox pull results with Components V2 and separate icon/image URLs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 21:51:28 +01:00
syntaxbullet
0517cd638c fix: add JSDoc header and null input test for rarity config 2026-03-18 21:49:10 +01:00
syntaxbullet
b8303a7e28 feat: add shared rarity config and helpers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 21:46:21 +01:00
syntaxbullet
d259c0c6a6 docs: add lootbox UX overhaul implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 21:44:17 +01:00
syntaxbullet
8b9ab2cd29 docs: add lootbox UX overhaul design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 21:37:10 +01:00
syntaxbullet
5d832c9601 fix: update trivia test to mock events instead of dashboardService
The trivia service now emits domain events via systemEvents instead
of directly calling dashboardService.recordEvent. Updated the test
mock and assertions to match.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:37:31 +01:00
syntaxbullet
968cc09c98 fix: replace setup-bun action with curl install for Gitea runner compatibility
oven-sh/setup-bun@v2 now requires node24 runtime, which Gitea Act
runner v0.2.11 does not support. Using direct curl install instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:33:55 +01:00
syntaxbullet
2bddab001a fix: verify receiver has no transaction records in insufficient funds test
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:26:50 +01:00
syntaxbullet
fc058effd5 feat: add integration test for economy transfer flow
Tests the full transfer cycle against a real database: debit/credit,
transaction records, insufficient funds rejection, self-transfer
rejection, non-positive amounts, and sequential transfers.

Uses *.integration.test.ts convention — excluded from default test
runs, included with --integration flag in CI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:24:49 +01:00
syntaxbullet
3f99a77446 test: add integration test for economy transfer flow
Covers the critical financial transfer path against a real database,
catching schema mismatches, constraint violations, and transaction
atomicity bugs that mocked unit tests cannot detect.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 13:21:31 +01:00
syntaxbullet
abe25e0ceb refactor: extract Discord.js code from shared services into bot layer
Move terminal.service.ts and prune.service.ts entirely to bot/modules/
since they are Discord-specific. Split lootdrop.service.ts: pure logic
(activity tracking, DB ops, claim) stays in shared/, Discord operations
(message sending, channel interactions) move to bot/modules/economy/
lootdrop.handler.ts. Move effect registry/handlers/types from bot/ to
shared/modules/inventory/ since they contain no Discord.js imports and
are needed by inventory.service.ts in shared.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:15:29 +01:00
syntaxbullet
5a20ed23f4 fix: guard against undefined username in trivia won event
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:06:33 +01:00
syntaxbullet
0142508eb5 fix: add type safety and error handling to event bus
- Add DomainEventPayloads interface to events.ts for typed event payloads
- Wrap dashboard listeners with fireAndForget() to prevent unhandled promise rejections
- Type all listener parameters explicitly using DomainEventPayloads
- Add idempotency guard to registerDomainEventListeners to prevent double registration on hot-reload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 13:04:32 +01:00
syntaxbullet
5863418ae9 refactor: replace dynamic imports with event bus pattern
Replace 12 dynamic `await import()` calls with domain events emitted
through the existing systemEvents bus, breaking circular dependencies
between services (economy/inventory/leveling -> quest, * -> dashboard).

- Add `emitAsync` to SystemEventEmitter for sequential listener awaiting,
  preserving DB transaction atomicity for quest progress tracking
- Add DOMAIN event constants (BALANCE_CHANGED, XP_GAINED, ITEM_COLLECTED,
  ITEM_USED, TRANSFER_COMPLETED, DAILY_CLAIMED, TRIVIA_*, EXAM_PASSED)
- Create shared/lib/eventWiring.ts to register all domain event listeners
- Convert quest event calls to `await systemEvents.emitAsync()` (5 calls)
- Convert dashboard event calls to `systemEvents.emit()` fire-and-forget (5 calls)
- Convert exam.service.ts userService import to static import (1 call)
- Convert dashboard.service.ts events import to static import (1 call)
- Leave inventory.service.ts validateAndExecuteEffect import unchanged (Task 3)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 12:59:15 +01:00
syntaxbullet
a96c6caa49 fix: standardize error classes in shared service modules
Replace raw `Error` with `UserError` for user-facing conditions (invalid trade state, user not found, permission/channel type checks) and `SystemError` for internal failures (DB insert failures, external API errors, missing config). Improves Discord UX by ensuring user-facing errors are surfaced cleanly via withCommandErrorHandling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 12:51:16 +01:00
syntaxbullet
22e446ff28 chore: add .worktrees/ to .gitignore
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 12:47:17 +01:00
syntaxbullet
10c84a8478 fix: fix Y - UV coordinate issue flipping the preview in chroma key. 2026-02-21 13:14:58 +01:00
syntaxbullet
9eba64621a feat: add ability to edit items. 2026-02-19 15:53:13 +01:00
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
400 changed files with 54648 additions and 12523 deletions

7
.citrine Normal file
View File

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

39
.dockerignore Normal file
View File

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

View File

@@ -1,12 +1,34 @@
# =============================================================================
# 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
SESSION_SECRET=change-me-to-a-random-string

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"

View File

@@ -0,0 +1,132 @@
name: CI / Deploy
on:
push:
pull_request:
workflow_dispatch:
jobs:
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
run: |
curl -fsSL https://bun.sh/install | bash
echo "$HOME/.bun/bin" >> "$GITHUB_PATH"
- 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: Typecheck
run: bunx tsc --noEmit
- name: Build Panel
run: bun run panel:build
- name: Setup Test Database
run: bun run db:push:local
env:
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/aurora_test
DISCORD_BOT_TOKEN: test_token
DISCORD_CLIENT_ID: "123"
DISCORD_GUILD_ID: "123"
- name: Run Tests
run: |
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"
DISCORD_CLIENT_SECRET="test-client-secret"
SESSION_SECRET="test-session-secret"
ADMIN_TOKEN="admin_token_123"
LOG_LEVEL="error"
EOF
bash shared/scripts/test-isolated.sh --integration
env:
NODE_ENV: test
deploy:
needs: test
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Configure SSH
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
VPS_HOST: ${{ secrets.VPS_HOST }}
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts
- name: Deploy on VPS
env:
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }}
VPS_PROJECT_PATH: ${{ secrets.VPS_PROJECT_PATH }}
run: |
set -euo pipefail
REMOTE_DIR="${VPS_PROJECT_PATH:-~/Aurora}"
ssh -o BatchMode=yes "$VPS_USER@$VPS_HOST" "cd $REMOTE_DIR && bash shared/scripts/deploy.sh"
- name: Post-deploy Health Check
env:
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }}
VPS_PROJECT_PATH: ${{ secrets.VPS_PROJECT_PATH }}
run: |
set -euo pipefail
REMOTE_DIR="${VPS_PROJECT_PATH:-~/Aurora}"
ssh -o BatchMode=yes "$VPS_USER@$VPS_HOST" "cd $REMOTE_DIR && curl -fsS http://127.0.0.1:3000/api/health >/dev/null"

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
run: |
curl -fsSL https://bun.sh/install | bash
echo "$HOME/.bun/bin" >> $GITHUB_PATH
- 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 the isolated test runner / 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-isolated.sh --integration
env:
NODE_ENV: test

8
.gitignore vendored
View File

@@ -3,6 +3,7 @@ node_modules
docker-compose.override.yml
shared/db-logs
shared/db/data
shared/db/backups
shared/db/loga
.cursor
# dependencies (bun install)
@@ -45,4 +46,9 @@ 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
.worktrees/
.superpowers/

293
AGENTS.md
View File

@@ -1,238 +1,135 @@
# AGENTS.md - AI Coding Agent Guidelines
# AGENTS.md
## Project Overview
This file documents the current implementation shape of the Aurora repository.
AuroraBot is a Discord bot with a web dashboard built using Bun, Discord.js, React, and PostgreSQL with Drizzle ORM.
## Build/Lint/Test Commands
## Commands
```bash
# Development
bun --watch bot/index.ts # Run bot with hot reload
bun --hot web/src/index.ts # Run web dashboard with hot reload
# App
bun run dev # bot + API in one Bun process with watch mode
docker compose up # app + db
docker compose up app # app only
docker compose up db # database only
# 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
bun test # Bun's native runner
bun run test # repo test wrapper script
bun run test:ci # include CI/integration path
# 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
bun run db:push # drizzle-kit push via Docker
bun run db:push:local # drizzle-kit push locally
bun run db:generate # drizzle-kit generate via Docker
bun run db:migrate # drizzle-kit migrate via Docker
bun run db:studio # local Drizzle Studio on :4983
# Web Dashboard
cd web && bun run build # Build production web assets
cd web && bun run dev # Development server
# Panel
bun run panel:dev # Vite dev server on :5173
bun run panel:build # build panel/dist
```
## Project Structure
## Architecture
```
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
Aurora is a single-process Bun application:
shared/ # Shared between bot and web
├── db/ # Database schema and migrations
├── lib/ # Utils, config, errors, types
└── modules/ # Domain services (economy, user, etc.)
- `bot/index.ts` boots shared config, registers domain listeners, starts the API server, then logs into Discord.
- `api/src/server.ts` hosts REST routes, WebSocket traffic, and built panel assets.
- `shared/modules/*` contains the business logic used by both the bot and the API.
- `shared/games/*` contains reusable game plugins; `api/src/games/*` runs rooms and WebSocket orchestration.
web/ # React dashboard
├── src/pages/ # React pages
├── src/components/ # UI components (ShadCN/Radix)
└── src/hooks/ # React hooks
Current high-level layout:
```text
bot/ Discord commands, events, views, interactions
api/ Bun HTTP + WebSocket server
panel/ React dashboard
shared/db/ Drizzle client and schema
shared/lib/ config, env, errors, logger, events, constants
shared/modules/ domain services
shared/games/ game plugins shared by API and panel
```
## Import Conventions
## Import conventions
Use path aliases defined in tsconfig.json:
Use path aliases from the repo `tsconfig.json`:
```typescript
// External packages first
import { SlashCommandBuilder } from "discord.js";
import { eq } from "drizzle-orm";
- `@/*` -> `bot/*`
- `@commands/*` -> `bot/commands/*`
- `@db/*` -> `shared/db/*`
- `@lib/*` -> `bot/lib/*`
- `@modules/*` -> `bot/modules/*`
- `@shared/*` -> `shared/*`
// 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";
Import order in the repo is generally:
// Relative imports last
import { localHelper } from "./helper";
```
1. external packages
2. aliases
3. relative imports
**Available Aliases:**
- `@/*` - bot/
- `@shared/*` - shared/
- `@db/*` - shared/db/
- `@lib/*` - bot/lib/
- `@modules/*` - bot/modules/
- `@commands/*` - bot/commands/
## File patterns
## Naming Conventions
- `*.service.ts`: domain/business logic, usually in `shared/modules/*`
- `*.view.ts`: Discord message/view construction
- `*.interaction.ts`: component interaction handlers
- `*.types.ts`: local types and custom ID helpers
- `*.handler.ts`: bot-side orchestration around services/views
- `*.test.ts`: colocated tests
| 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_` |
## Runtime config
## Code Patterns
- Global game settings live in `game_settings` and are loaded into `shared/lib/config.ts`.
- Guild-specific settings live in `guild_settings`; `getGuildConfig()` adds a 60-second cache on top of DB reads.
- Most numeric DB values exposed through runtime config are converted to `bigint` in `shared/lib/config.ts`.
### Command Definition
## Interaction routing
```typescript
export const commandName = createCommand({
data: new SlashCommandBuilder()
.setName("commandname")
.setDescription("Description"),
execute: async (interaction) => {
await interaction.deferReply();
// Implementation
}
});
```
Global component routing is defined in `bot/lib/interaction.routes.ts` and consumed by `ComponentInteractionHandler`.
### Service Pattern (Singleton Object)
Current route table:
```typescript
export const serviceName = {
methodName: async (params: ParamType): Promise<ReturnType> => {
return await withTransaction(async (tx) => {
// Database operations
});
},
};
```
- `trade_` and `amount` -> `bot/modules/trade/trade.interaction.ts`
- `shop_buy_` -> `bot/modules/economy/shop.interaction.ts`
- `lootdrop_` -> `bot/modules/economy/lootdrop.interaction.ts`
- `trivia_` -> `bot/modules/trivia/trivia.interaction.ts`
- `createitem_` -> `bot/modules/admin/item_wizard.ts`
- `enrollment` -> `bot/modules/user/enrollment.interaction.ts`
- `feedback_` -> `bot/modules/feedback/feedback.interaction.ts`
### Module File Organization
Some features still use local collectors instead of the global route table, notably inventory.
- `*.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)
## Commands and access control
## Error Handling
- Slash command execution is centralized in `bot/lib/handlers/CommandHandler.ts`.
- `withCommandErrorHandling()` is the normal command wrapper for defer/reply/error behavior.
- Beta commands rely on `featureFlagsService.hasAccess()`.
- `ADMIN_USER_IDS` controls admin panel access, not Discord permissions inside command code.
### Custom Error Classes
## API and panel
```typescript
import { UserError, SystemError } from "@shared/lib/errors";
- API routes are prefix-matched in `api/src/routes/index.ts`.
- `/auth/*` and `/api/health` are public.
- Players may access `/api/stats`, `/api/health`, `/api/me`, and `/api/me/inventory`.
- Remaining `/api/*` routes are admin-only.
- The panel dev server proxies back to the Bun server; the integrated server serves `panel/dist` when built.
// User-facing errors (shown to user)
throw new UserError("You don't have enough coins!");
## Database notes
// System errors (logged, generic message shown)
throw new SystemError("Database connection failed");
```
### Standard Error Pattern
```typescript
try {
const result = await service.method();
await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
} catch (error) {
if (error instanceof UserError) {
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
} else {
console.error("Unexpected error:", error);
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
}
}
```
## 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 location: `shared/db/schema.ts`
- Docker Compose uses PostgreSQL 17.
- Discord IDs and currency/xp values are stored as `bigint`.
- `withTransaction()` lives in `bot/lib/db.ts` and is the normal way shared services compose DB work.
## Testing
### Test File Structure
- Tests use `bun:test`.
- Mock modules before importing the unit under test.
- Most service tests stub `DrizzleClient` or `withTransaction()` rather than hitting the real database.
```typescript
import { describe, it, expect, mock, beforeEach } from "bun:test";
## Key entrypoints
// 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:** React 19 + Bun HTTP Server
- **Database:** PostgreSQL 16+ with Drizzle ORM
- **UI:** Tailwind CSS v4 + ShadCN/Radix
- **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` |
- `bot/index.ts`
- `bot/lib/BotClient.ts`
- `api/src/server.ts`
- `api/src/routes/index.ts`
- `shared/lib/config.ts`
- `shared/db/DrizzleClient.ts`
- `shared/db/schema/index.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"]

271
README.md
View File

@@ -1,160 +1,181 @@
# Aurora
> A comprehensive, feature-rich Discord RPG bot built with modern technologies.
Aurora is a Discord RPG bot, admin/player panel, and REST/WebSocket API that run as one Bun application. The Discord bot and HTTP server share the same database client, config, services, and domain events.
![Version](https://img.shields.io/badge/version-1.0.0-blue.svg)
![Bun](https://img.shields.io/badge/Bun-1.0+-black)
![Discord.js](https://img.shields.io/badge/Discord.js-14.x-5865F2)
![Drizzle ORM](https://img.shields.io/badge/Drizzle_ORM-0.30+-C5F74F)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-336791)
![React](https://img.shields.io/badge/React-19-61DAFB)
## What exists today
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.
- Discord slash commands for economy, inventory, quests, moderation, feedback, user profiles, and admin tooling.
- A Bun HTTP API under `/api/*`, Discord OAuth under `/auth/*`, and a WebSocket endpoint at `/ws`.
- A React panel for both admins and enrolled players.
- Shared domain services in `shared/modules/*` and reusable game plugins in `shared/games/*`.
- Built-in real-time games: chess and blackjack.
**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.
## Architecture
## ✨ Features
```text
bot/ Discord bot entrypoint, commands, events, Discord-facing views/interactions
api/ Bun HTTP server, route modules, WebSocket/game room server
panel/ React 19 + Vite + Tailwind v4 dashboard
shared/ Shared DB schema, services, config, events, utilities, game plugins
docs/ Product and design notes
```
### Discord Bot
* **Class System**: Users can join different classes.
* **Economy**: Complete economy system with balance, transactions, and daily rewards.
* **Inventory & Items**: Sophisticated item system with rarities, types (Material, Consumable, etc.), and inventory management.
* **Leveling**: XP-based leveling system to track user activity and progress.
* **Quests**: Quest system with requirements and rewards.
* **Trading**: Secure trading system between users.
* **Lootdrops**: Random loot drops in channels to engage users.
* **Admin Tools**: Administrative commands for server management.
Important points:
### Web Dashboard
* **Live Analytics**: View real-time activity charts (commands, transactions).
* **Configuration Management**: Update bot settings without restarting.
* **Database Inspection**: Integrated Drizzle Studio access.
* **State Monitoring**: View internal bot state (Lootdrops, etc.).
- `bot/index.ts` initializes DB-backed config, wires domain events, starts the API server, then logs into Discord.
- The API server also serves built panel assets from `panel/dist` when they exist.
- Bot commands, API routes, and the panel all rely on the same service layer in `shared/modules/*`.
- Runtime game config is loaded from the `game_settings` table into `shared/lib/config.ts`.
## 🏗️ Architecture
Aurora uses a **Single Process Monolith** architecture to maximize performance and simplify resource sharing.
* **Unified Runtime**: Both the Discord Client and the Web Dashboard run within the same Bun process.
* **Shared State**: This allows the Dashboard to access live bot memory (caches, gateways) directly without complex inter-process communication (IPC).
* **Simplified Deployment**: You only need to deploy a single Docker container.
## 🛠️ Tech Stack
* **Runtime**: [Bun](https://bun.sh/)
* **Bot Framework**: [Discord.js](https://discord.js.org/)
* **Web Framework**: [React 19](https://react.dev/) + [Vite](https://vitejs.dev/) (served via Bun)
* **Styling**: [Tailwind CSS v4](https://tailwindcss.com/) + [Radix UI](https://www.radix-ui.com/)
* **Database**: [PostgreSQL](https://www.postgresql.org/)
* **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
* **Validation**: [Zod](https://zod.dev/)
* **Containerization**: [Docker](https://www.docker.com/)
## 🚀 Getting Started
## Getting started
### Prerequisites
* [Bun](https://bun.sh/) (latest version)
* [Docker](https://www.docker.com/) & Docker Compose
- Bun
- Docker and Docker Compose
- A Discord application with bot token, client ID, and client secret
### Installation
### Setup
1. **Clone the repository**
```bash
git clone <repository-url>
cd aurora
```
1. Install dependencies.
2. **Install dependencies**
```bash
bun install
```
```bash
bun install
```
3. **Environment Setup**
Copy the example environment file and configure it:
```bash
cp .env.example .env
```
Edit `.env` with your Discord bot token, Client ID, and database credentials.
2. Create your environment file.
> **Note**: The `DATABASE_URL` in `.env.example` is pre-configured for Docker.
```bash
cp .env.example .env
```
4. **Start the Database**
Run the database service using Docker Compose:
```bash
docker compose up -d db
```
3. Start PostgreSQL.
5. **Run Migrations**
```bash
bun run migrate
```
OR
```bash
bun run db:push
```
```bash
docker compose up -d db
```
### Running the Bot & Dashboard
4. Initialize the schema.
```bash
bun run db:push:local
```
If you prefer running schema changes through Docker:
```bash
bun run migrate
```
5. Start the bot and API.
**Development Mode** (with hot reload):
```bash
bun run dev
```
* Bot: Online in Discord
* Dashboard: http://localhost:3000
**Production Mode**:
Build and run with Docker (recommended):
The Bun server listens on `http://localhost:3000`.
### Panel development
The Bun server can serve a built panel, but day-to-day panel work is done with Vite:
```bash
docker compose up -d app
bun run panel:dev
```
### 🔐 Accessing Production Services (SSH Tunnel)
The panel dev server runs on `http://localhost:5173` and proxies `/api`, `/auth`, `/assets`, and `/ws` to `http://localhost:3000`.
For security, the Production Database and Dashboard are **not exposed** to the public internet by default. They are only accessible via localhost on the server.
To build the panel for the integrated Bun server:
To access them from your local machine, use the included SSH tunnel script.
1. Add your VPS details to your local `.env` file:
```env
VPS_USER=root
VPS_HOST=123.45.67.89
```
2. Run the remote connection script:
```bash
bun run remote
```
This will establish secure tunnels for:
* **Dashboard**: http://localhost:3000
* **Drizzle Studio**: http://localhost:4983
## 📜 Scripts
* `bun run dev`: Start the bot and dashboard in watch mode.
* `bun run remote`: Open SSH tunnel to production services.
* `bun run generate`: Generate Drizzle migrations.
* `bun run migrate`: Apply migrations (via Docker).
* `bun run db:studio`: Open Drizzle Studio to inspect the database.
* `bun test`: Run tests.
## 📂 Project Structure
```
├── bot # Discord Bot logic & entry point
├── web # React Web Dashboard (Frontend + Server)
├── shared # Shared code (Database, Config, Types)
├── drizzle # Drizzle migration files
├── scripts # Utility scripts
├── docker-compose.yml
└── package.json
```bash
bun run panel:build
```
## 🤝 Contributing
## Useful scripts
Contributions are welcome! Please feel free to submit a Pull Request.
```bash
# App
bun run dev
docker compose up
docker compose up app
docker compose up db
## 📄 License
# Database
bun run db:push
bun run db:push:local
bun run db:generate
bun run db:migrate
bun run db:studio
bun run db:backup
bun run db:restore
This project is licensed under the MIT License.
# Panel
bun run panel:dev
bun run panel:build
# Tests
bun test
bun run test
bun run test:ci
# Ops
bun run remote
bun run deploy
bun run deploy:remote
```
## Environment notes
The main variables you need in `.env` are:
- `DISCORD_BOT_TOKEN`
- `DISCORD_CLIENT_ID`
- `DISCORD_CLIENT_SECRET`
- `DISCORD_GUILD_ID`
- `ADMIN_USER_IDS`
- `SESSION_SECRET`
- `DB_USER`
- `DB_PASSWORD`
- `DB_NAME`
- `DATABASE_URL`
- `PANEL_BASE_URL`
Players can authenticate into the panel only after they exist in the `users` table. Admin access is determined by `ADMIN_USER_IDS`, and panel sessions are stored in signed cookies keyed by `SESSION_SECRET`.
## API and panel summary
- Public routes: `/auth/*`, `/api/health`
- Player-accessible API routes: `/api/stats`, `/api/health`, `/api/me`, `/api/me/inventory`
- Admin-only API routes: the rest of `/api/*`
- WebSocket: `/ws` with cookie-based auth
- Static assets: `/assets/*`
## Project structure
```text
bot/
commands/
events/
lib/
modules/
api/
src/
routes/
games/
panel/
src/
shared/
db/
games/
lib/
modules/
```
## Documentation
- [AGENTS.md](AGENTS.md): repo-wide implementation guidance
- [api/README.md](api/README.md): API surface and auth model
- [docs/new-design/DESIGN.md](docs/new-design/DESIGN.md): current panel design language

View File

130
api/README.md Normal file
View File

@@ -0,0 +1,130 @@
# Aurora API
Aurora's API is a Bun server that runs inside the same process as the Discord bot. It serves REST routes, the authenticated WebSocket endpoint, static assets, and built panel files.
## Runtime model
- Entry point: `api/src/server.ts`
- Route dispatcher: `api/src/routes/index.ts`
- Auth: Discord OAuth with signed session cookies
- WebSocket: `/ws`
- Static assets: `/assets/*`
- Built panel fallback: `panel/dist`
## Access model
Public:
- `GET /api/health`
- `/auth/discord`
- `/auth/callback`
- `POST /auth/logout`
- `GET /auth/me`
Player-accessible API routes:
- `GET /api/stats`
- `GET /api/health`
- `GET /api/me`
- `GET /api/me/inventory`
Admin-only API routes:
- everything else under `/api/*`
Admin vs player is derived from `ADMIN_USER_IDS`. A user must already exist in the `users` table to complete panel login.
## Route summary
### Auth
- `GET /auth/discord`
- `GET /auth/callback`
- `POST /auth/logout`
- `GET /auth/me`
### Dashboard and system
- `GET /api/health`
- `GET /api/stats`
- `GET /api/stats/activity`
- `POST /api/actions/reload-commands`
- `POST /api/actions/clear-cache`
- `POST /api/actions/maintenance-mode`
### Settings
- `GET /api/settings`
- `POST /api/settings`
- `GET /api/settings/meta`
- `GET /api/guilds/:guildId/settings`
- `PUT|PATCH /api/guilds/:guildId/settings`
- `DELETE /api/guilds/:guildId/settings`
### Users, classes, and inventory
- `GET /api/me`
- `GET /api/me/inventory`
- `GET /api/users`
- `GET /api/users/:id`
- `PUT /api/users/:id`
- `GET /api/users/:id/inventory`
- `POST /api/users/:id/inventory`
- `DELETE /api/users/:id/inventory/:itemId`
- `GET /api/classes`
- `POST /api/classes`
- `PUT /api/classes/:id`
- `DELETE /api/classes/:id`
### Game content
- `GET /api/items`
- `POST /api/items`
- `GET /api/items/:id`
- `PUT /api/items/:id`
- `DELETE /api/items/:id`
- `POST /api/items/:id/icon`
- `GET /api/quests`
- `POST /api/quests`
- `PUT /api/quests/:id`
- `DELETE /api/quests/:id`
- `GET /api/lootdrops`
- `POST /api/lootdrops`
- `DELETE /api/lootdrops/:messageId`
### Moderation and economy history
- `GET /api/moderation`
- `POST /api/moderation`
- `GET /api/transactions`
## WebSocket
`/ws` requires a valid `aurora_session` cookie.
Current behavior:
- dashboard clients subscribe to `dashboard`
- game clients also use lobby and room-scoped traffic through `GameServer`
- `PING` from the client returns `PONG`
- dashboard stats are broadcast every 5 seconds while at least one client is connected
- hard limits in `api/src/server.ts`:
- 200 concurrent connections
- 16 KB max payload
- 60 second idle timeout
## Development
Start the backend:
```bash
bun run dev
```
Optional panel dev server:
```bash
bun run panel:dev
```
Panel dev runs on `http://localhost:5173` and proxies API/auth/assets/WebSocket requests to `http://localhost:3000`.

68
api/src/AGENTS.md Normal file
View File

@@ -0,0 +1,68 @@
# API layer
## Server shape
- Aurora uses Bun's native `serve()` API in `api/src/server.ts`.
- Route modules are aggregated in `api/src/routes/index.ts`.
- A route module returns `null` when it does not match so the dispatcher can continue.
- After route handling, the server tries `panel/dist` for SPA/static files.
## Authentication and authorization
- OAuth routes live in `api/src/routes/auth.routes.ts`.
- Sessions are stored in signed `aurora_session` cookies.
- Session TTL is 7 days.
- Login succeeds only for users already present in the `users` table.
- Role is `admin` if the Discord ID is in `ADMIN_USER_IDS`, otherwise `player`.
- Redirects after login are intentionally restricted to localhost or relative paths.
Current access rules from `api/src/routes/index.ts`:
- public: `/auth/*`, `/api/health`
- player allow-list: `/api/stats`, `/api/health`, `/api/me`
- everything else under `/api/*`: admin-only
`/api/me/inventory` is handled by `users.routes.ts` and still depends on a valid session.
## Response conventions
- `jsonResponse()` serializes `bigint` values as strings.
- `errorResponse()` returns `{ error, details? }`.
- `parseBody()` and `parseQuery()` validate with Zod and return a `Response` on failure.
- The API does not use a framework-level middleware stack; each route handles its own parsing and branching.
## WebSocket
- Endpoint: `/ws`
- Requires an authenticated session
- Dashboard channel: `dashboard`
- Lobby channel: `lobby`
- Room-specific messaging is handled inside `GameServer`
- Dashboard broadcasts `STATS_UPDATE` every 5 seconds while clients are connected
- `NEW_EVENT` broadcasts are wired from `shared/lib/events`
Hard limits:
- max connections: 200
- max payload: 16 KB
- idle timeout: 60 seconds
## Static files
- Built panel assets are served from `panel/dist`
- `/assets/*` serves files from `bot/assets/graphics`
- `/api/*`, `/auth/*`, `/ws`, and `/assets/*` bypass the SPA fallback
## Route notes
- `items.routes.ts` supports both JSON and multipart form data for item creation.
- `settings.routes.ts` writes DB-backed game settings and emits the reload-commands event.
- `guild-settings.routes.ts` invalidates the guild config cache after writes.
- `lootdrops.routes.ts` delegates spawning/deletion to bot-side handlers because Discord message creation happens there.
## Gotchas
- Some runtime caches are in-memory only and are lost on restart.
- The server registers game plugins at startup; duplicate registration throws.
- BigInt-safe JSON matters for nearly every domain route.
- The panel's auth flow depends on `PANEL_BASE_URL` matching the OAuth callback origin.

540
api/src/games/GameServer.ts Normal file
View File

@@ -0,0 +1,540 @@
import { RoomManager } from "./RoomManager";
import { GameWsClientSchema } from "./types";
import type { PlayerInfo } from "./types";
import { logger } from "@shared/lib/logger";
import { economyService } from "@shared/modules/economy/economy.service";
import { TransactionType } from "@shared/lib/constants";
import { gameRegistry } from "@shared/games/registry";
import type { Server, ServerWebSocket } from "bun";
export interface WsConnectionData {
session: { discordId: string; username: string; role: string };
rooms: Set<string>;
}
export class GameServer {
readonly roomManager = new RoomManager();
private connections = new Map<string, ServerWebSocket<WsConnectionData>>();
private replacedConnections = new Map<string, ServerWebSocket<WsConnectionData>>();
private bunServer: Server<WsConnectionData> | null = null;
constructor() {
// Subscribe to room events and route them to the right clients
this.roomManager.emitter.on("room:created", ({ roomId, gameSlug }) => {
// The creating connection will subscribe itself; just broadcast room list
this.publishRoomListUpdate();
});
this.roomManager.emitter.on("game:started", ({ roomId, spectatorView, playerViews }) => {
// Send personalised state to each player
for (const [playerId, view] of playerViews) {
this.sendToPlayer(playerId, {
type: "GAME_STATE",
roomId,
state: view,
});
}
// Broadcast started event with spectator view to the room channel
this.publish(`room:${roomId}`, {
type: "GAME_STARTED",
roomId,
state: spectatorView,
});
});
this.roomManager.emitter.on("game:updated", ({ roomId, spectatorView, playerViews }) => {
// Each player gets their personalised view directly
for (const [playerId, view] of playerViews) {
this.sendToPlayer(playerId, {
type: "GAME_STATE",
roomId,
state: view,
});
}
// Spectators/others get the spectator view via pub/sub
this.publish(`room:${roomId}`, {
type: "GAME_UPDATE",
roomId,
state: spectatorView,
});
});
this.roomManager.emitter.on("game:ended", ({ roomId, winner, reason, payouts }) => {
const room = this.roomManager.getRoom(roomId);
const betAmount = room?.betAmount ?? 0;
// Handle bet payouts asynchronously — broadcast happens after settlement
if (betAmount > 0) {
this.settleBets(roomId, winner, betAmount, payouts).then((payout) => {
this.publish(`room:${roomId}`, {
type: "GAME_ENDED",
roomId,
winner,
reason,
payout,
});
this.publishRoomListUpdate();
});
return;
}
this.publish(`room:${roomId}`, {
type: "GAME_ENDED",
roomId,
winner,
reason,
});
this.publishRoomListUpdate();
});
this.roomManager.emitter.on("round:settled", async ({ roomId, roundSettlements }) => {
const room = this.roomManager.getRoom(roomId);
if (!room || room.betAmount <= 0) return;
const gameName = gameRegistry.get(room.gameSlug)?.name ?? "Game";
const settlementDetails: typeof roundSettlements = {};
for (const [playerId, settlement] of Object.entries(roundSettlements)) {
try {
if (settlement.payout > 0) {
await economyService.modifyUserBalance(
playerId,
BigInt(settlement.payout),
TransactionType.GAME_WIN,
`${gameName} round payout (room ${roomId.slice(0, 8)})`,
);
}
settlementDetails[playerId] = settlement;
} catch (err) {
logger.error("web", `Round payout failed for ${playerId} in room ${roomId}: ${err}`);
}
}
if (Object.keys(settlementDetails).length > 0) {
this.publish(`room:${roomId}`, { type: "ROUND_SETTLED", roomId, settlements: settlementDetails });
}
});
this.roomManager.emitter.on("player:left", ({ roomId, playerId }) => {
this.publish(`room:${roomId}`, {
type: "PLAYER_LEFT",
roomId,
playerId,
});
});
this.roomManager.emitter.on("room:deleted", ({ roomId }) => {
const channel = `room:${roomId}`;
for (const [, ws] of this.connections) {
if (ws.data.rooms.has(roomId)) {
ws.send(JSON.stringify({ type: "ERROR", message: "Room not found" }));
ws.unsubscribe(channel);
ws.data.rooms.delete(roomId);
}
}
});
this.roomManager.emitter.on("room:list:changed", () => {
this.publishRoomListUpdate();
});
}
setServer(server: Server<WsConnectionData>): void {
this.bunServer = server;
}
handleOpen(ws: ServerWebSocket<WsConnectionData>): void {
const discordId = ws.data.session.discordId;
const existing = this.connections.get(discordId);
if (existing && existing !== ws) {
this.replacedConnections.set(discordId, existing);
}
this.connections.set(discordId, ws);
ws.send(JSON.stringify({ type: "ROOM_LIST_UPDATE", rooms: this.roomManager.listRooms() }));
}
async handleMessage(ws: ServerWebSocket<WsConnectionData>, raw: unknown): Promise<void> {
const parsed = GameWsClientSchema.safeParse(raw);
if (!parsed.success) {
ws.send(JSON.stringify({ type: "ERROR", message: "Invalid message format" }));
return;
}
const msg = parsed.data;
const { discordId, username, role } = ws.data.session;
switch (msg.type) {
case "CREATE_ROOM": {
// Solo mode forces betAmount to 0
const options = msg.options ? { ...msg.options } : {};
if (options.soloMode) options.betAmount = 0;
const result = this.roomManager.createRoom(msg.gameType, discordId, options);
if (!result.ok) {
ws.send(JSON.stringify({ type: "ERROR", message: result.error }));
return;
}
ws.subscribe(`room:${result.roomId}`);
ws.data.rooms.add(result.roomId);
ws.send(JSON.stringify({ type: "ROOM_CREATED", roomId: result.roomId, gameSlug: msg.gameType }));
logger.info("web", `Room created: ${result.roomId} (${msg.gameType}) by ${discordId}`);
// Solo mode: auto-fill and start immediately
if (options.soloMode) {
const fillResult = this.roomManager.fillRoom(result.roomId, discordId);
if (!fillResult.ok) {
ws.send(JSON.stringify({ type: "ERROR", message: fillResult.error }));
}
// fillRoom with betAmount=0 calls startGame internally
}
// Auto-start if room is immediately full (e.g. maxPlayers: 1) — skip for manualStart games
const plugin = gameRegistry.get(msg.gameType);
const createdRoom = this.roomManager.getRoom(result.roomId);
if (!options.soloMode && plugin && !plugin.manualStart && createdRoom && createdRoom.players.length >= plugin.maxPlayers && createdRoom.status === "waiting") {
if (createdRoom.betAmount > 0) {
this.deductBetsAndStart(result.roomId, createdRoom.betAmount, createdRoom.players, ws);
} else {
this.roomManager.startGame(result.roomId);
}
}
break;
}
case "JOIN_ROOM": {
const result = this.roomManager.joinRoom(msg.roomId, discordId, msg.preferAs, msg.role ?? role);
if (!result.ok) {
ws.send(JSON.stringify({ type: "ERROR", message: result.error }));
return;
}
ws.subscribe(`room:${msg.roomId}`);
ws.data.rooms.add(msg.roomId);
const room = this.roomManager.getRoom(msg.roomId);
const roomStatus = room?.status ?? "waiting";
// Determine the current state to send to this client
let state: unknown = undefined;
if (room && room.status === "playing") {
state = result.joinedAs === "spectator"
? this.roomManager.getSpectatorView(msg.roomId)
: this.roomManager.getPlayerView(msg.roomId, discordId);
}
// Build player/spectator lists for JOIN_RESULT
const resolveInfo = (ids: string[]): PlayerInfo[] =>
ids.map(id => ({
discordId: id,
username: this.connections.get(id)?.data.session.username ?? id,
}));
const players = resolveInfo(room?.players ?? []);
const spectators = resolveInfo(Array.from(room?.spectators ?? new Set()));
// Notify replaced connection in the same room (multi-tab detection)
const replacedWs = this.replacedConnections.get(discordId);
if (replacedWs && replacedWs.data.rooms.has(msg.roomId)) {
replacedWs.send(JSON.stringify({ type: "SESSION_REPLACED", roomId: msg.roomId }));
replacedWs.data.rooms.delete(msg.roomId);
replacedWs.unsubscribe(`room:${msg.roomId}`);
}
this.replacedConnections.delete(discordId);
// Build room options for the client
const roomOptions = room
? {
...(room.betAmount > 0 ? { betAmount: room.betAmount } : {}),
...(typeof room.options?.timeControl === "string" ? { timeControl: room.options.timeControl } : {}),
}
: undefined;
// Respond with JOIN_RESULT
ws.send(JSON.stringify({
type: "JOIN_RESULT",
roomId: msg.roomId,
joinedAs: result.joinedAs,
roomStatus,
players,
spectators,
state,
roomOptions,
}));
// Notify other room members
const playerInfo: PlayerInfo = { discordId, username };
this.publish(`room:${msg.roomId}`, {
type: "PLAYER_JOINED",
roomId: msg.roomId,
player: playerInfo,
joinedAs: result.joinedAs,
});
logger.info("web", `${discordId} joined room ${msg.roomId} as ${result.joinedAs}`);
// Handle async bet deduction when room is ready to start
if (result.readyToStart && room) {
this.deductBetsAndStart(msg.roomId, room.betAmount, room.players, ws);
}
break;
}
case "LEAVE_ROOM": {
this.roomManager.leaveRoom(msg.roomId, discordId);
ws.unsubscribe(`room:${msg.roomId}`);
ws.data.rooms.delete(msg.roomId);
break;
}
case "GAME_ACTION": {
// Action cost pre-check: deduct bet before processing split/double/place_bet
const actionRoom = this.roomManager.getRoom(msg.roomId);
if (actionRoom && actionRoom.betAmount > 0 && actionRoom.state) {
const actionPlugin = gameRegistry.get(actionRoom.gameSlug);
if (actionPlugin?.getActionCost) {
const cost = actionPlugin.getActionCost(actionRoom.state, msg.action, discordId);
if (cost > 0) {
const amount = actionRoom.betAmount * cost;
const gameName = actionPlugin.name ?? actionRoom.gameSlug;
try {
await economyService.modifyUserBalance(
discordId,
-BigInt(amount),
TransactionType.GAME_BET,
`${gameName} action bet (room ${msg.roomId.slice(0, 8)})`,
);
} catch {
ws.send(JSON.stringify({ type: "ERROR", message: "Insufficient funds for this action" }));
return;
}
}
}
}
const result = this.roomManager.handleAction(msg.roomId, discordId, msg.action);
if (!result.ok) {
ws.send(JSON.stringify({ type: "ERROR", message: result.error }));
return;
}
// game:updated event handler will dispatch views to players and spectators
break;
}
case "START_GAME": {
const room = this.roomManager.getRoom(msg.roomId);
if (!room) {
ws.send(JSON.stringify({ type: "ERROR", message: "Room not found" }));
return;
}
if (room.host !== discordId) {
ws.send(JSON.stringify({ type: "ERROR", message: "Only the host can start the game" }));
return;
}
if (room.status !== "waiting") {
ws.send(JSON.stringify({ type: "ERROR", message: "Game is not in waiting state" }));
return;
}
const startPlugin = gameRegistry.get(room.gameSlug);
if (startPlugin && room.players.length < startPlugin.minPlayers) {
ws.send(JSON.stringify({ type: "ERROR", message: `Need at least ${startPlugin.minPlayers} player(s) to start` }));
return;
}
if (room.betAmount > 0) {
this.deductBetsAndStart(msg.roomId, room.betAmount, room.players, ws);
} else {
const startResult = this.roomManager.startGame(msg.roomId);
if (!startResult.ok) {
ws.send(JSON.stringify({ type: "ERROR", message: startResult.error }));
}
}
logger.info("web", `Host ${discordId} started game in room ${msg.roomId}`);
break;
}
case "FILL_ROOM": {
if (role !== "admin") {
ws.send(JSON.stringify({ type: "ERROR", message: "Only admins can fill a room for solo testing" }));
return;
}
const fillResult = this.roomManager.fillRoom(msg.roomId, discordId);
if (!fillResult.ok) {
ws.send(JSON.stringify({ type: "ERROR", message: fillResult.error }));
return;
}
if (fillResult.readyToStart) {
const room = this.roomManager.getRoom(msg.roomId);
if (room) this.deductBetsAndStart(msg.roomId, room.betAmount, room.players, ws);
}
logger.info("web", `Admin ${discordId} filled room ${msg.roomId} for solo testing`);
break;
}
}
}
handleClose(ws: ServerWebSocket<WsConnectionData>): void {
// If this is a replaced (displaced) connection, just clean up the registry and stop
for (const [id, prevWs] of this.replacedConnections) {
if (prevWs === ws) {
this.replacedConnections.delete(id);
return;
}
}
for (const roomId of ws.data.rooms) {
this.roomManager.leaveRoom(roomId, ws.data.session.discordId);
}
this.connections.delete(ws.data.session.discordId);
}
/**
* Deduct bet amounts from all players, then start the game.
* If any player can't afford the bet, refund already-deducted players
* and remove the failing player from the room.
*/
private async deductBetsAndStart(
roomId: string,
betAmount: number,
playerIds: string[],
triggeringWs: ServerWebSocket<WsConnectionData>,
): Promise<void> {
const room = this.roomManager.getRoom(roomId);
if (!room || room.betsPending) return;
// Games with getActionCost handle per-round betting themselves (e.g., blackjack).
// Skip the upfront deduction — just start the game.
const plugin = gameRegistry.get(room.gameSlug);
if (plugin?.getActionCost) {
const startResult = this.roomManager.startGame(roomId);
if (!startResult.ok) {
triggeringWs.send(JSON.stringify({ type: "ERROR", message: startResult.error }));
}
return;
}
room.betsPending = true;
const uniquePlayers = [...new Set(playerIds)];
const deducted: string[] = [];
try {
const gameName = gameRegistry.get(room.gameSlug)?.name ?? room.gameSlug;
for (const pid of uniquePlayers) {
await economyService.modifyUserBalance(
pid,
-BigInt(betAmount),
TransactionType.GAME_BET,
`${gameName} wager (room ${roomId.slice(0, 8)})`,
);
deducted.push(pid);
}
// All deductions succeeded — start the game
const startResult = this.roomManager.startGame(roomId);
if (!startResult.ok) {
// Shouldn't happen, but refund if it does
await this.refundPlayers(deducted, betAmount, roomId);
}
} catch (err) {
// Refund anyone already deducted
await this.refundPlayers(deducted, betAmount, roomId);
// Find the player who couldn't afford the bet
const failedPlayer = uniquePlayers.find(p => !deducted.includes(p));
if (failedPlayer) {
this.roomManager.removePlayer(roomId, failedPlayer);
this.sendToPlayer(failedPlayer, {
type: "ERROR",
message: "Insufficient funds for the bet. You have been removed from the room.",
});
this.publish(`room:${roomId}`, {
type: "PLAYER_LEFT",
roomId,
playerId: failedPlayer,
});
}
logger.warn("web", `Bet deduction failed for room ${roomId}: ${err}`);
} finally {
if (room) room.betsPending = false;
}
}
/** Pay out winnings or refund bets on game end. */
private async settleBets(
roomId: string,
winner: string | null,
betAmount: number,
payouts?: Record<string, number>,
): Promise<{ amount: number; refunded?: boolean }> {
const room = this.roomManager.getRoom(roomId);
const uniquePlayers = [...new Set(room?.players ?? [])];
const gameName = gameRegistry.get(room?.gameSlug ?? "")?.name ?? "Game";
try {
// Custom payouts override default pot logic (used by house-edge games like blackjack)
if (payouts) {
let totalPaid = 0;
for (const [playerId, multiplier] of Object.entries(payouts)) {
if (multiplier <= 0) continue;
const amount = Math.floor(betAmount * multiplier);
await economyService.modifyUserBalance(
playerId,
BigInt(amount),
TransactionType.GAME_WIN,
`${gameName} payout (room ${roomId.slice(0, 8)})`,
);
totalPaid = Math.max(totalPaid, amount);
}
const isRefund = !winner && totalPaid === betAmount;
return { amount: totalPaid, refunded: isRefund };
}
// Default pot logic: winner takes all, draw refunds everyone
const pot = betAmount * uniquePlayers.length;
if (winner) {
await economyService.modifyUserBalance(
winner,
BigInt(pot),
TransactionType.GAME_WIN,
`${gameName} wager won (room ${roomId.slice(0, 8)})`,
);
return { amount: pot };
} else {
await this.refundPlayers(uniquePlayers, betAmount, roomId, gameName);
return { amount: betAmount, refunded: true };
}
} catch (err) {
logger.error("web", `Bet settlement failed for room ${roomId}: ${err}`);
return { amount: 0 };
}
}
private async refundPlayers(playerIds: string[], betAmount: number, roomId: string, gameName = "Game"): Promise<void> {
for (const pid of playerIds) {
try {
await economyService.modifyUserBalance(
pid,
BigInt(betAmount),
TransactionType.GAME_WIN,
`${gameName} wager refund (room ${roomId.slice(0, 8)})`,
);
} catch (err) {
logger.error("web", `Failed to refund ${pid} for room ${roomId}: ${err}`);
}
}
}
private publish(channel: string, message: unknown): void {
this.bunServer?.publish(channel, JSON.stringify(message));
}
private sendToPlayer(discordId: string, message: unknown): void {
this.connections.get(discordId)?.send(JSON.stringify(message));
}
private publishRoomListUpdate(): void {
this.publish("lobby", { type: "ROOM_LIST_UPDATE", rooms: this.roomManager.listRooms() });
}
}
export const gameServer = new GameServer();

View File

@@ -0,0 +1,187 @@
import { describe, it, expect, beforeEach } from "bun:test";
import { RoomManager } from "./RoomManager";
import { gameRegistry } from "@shared/games/registry";
import type { GamePlugin } from "@shared/games/types";
// Minimal stub plugin for testing the room system
const stubPlugin: GamePlugin<{ turn: number }, { type: string }> = {
slug: "stub",
name: "Stub Game",
minPlayers: 2,
maxPlayers: 2,
createInitialState: (players) => ({ turn: 0 }),
handleAction: (state, action, playerId) => ({ ok: true, state: { ...state, turn: state.turn + 1 } }),
getPlayerView: (state) => state,
getSpectatorView: (state) => state,
};
if (!gameRegistry.get("stub")) {
gameRegistry.register(stubPlugin);
}
describe("RoomManager", () => {
let manager: RoomManager;
beforeEach(() => {
manager = new RoomManager();
});
describe("createRoom", () => {
it("should create a room and return its id", () => {
const result = manager.createRoom("stub", "player1");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.roomId).toBeDefined();
expect(typeof result.roomId).toBe("string");
}
});
it("should reject unknown game type", () => {
const result = manager.createRoom("unknown-game", "player1");
expect(result.ok).toBe(false);
});
it("should add creator as first player", () => {
const result = manager.createRoom("stub", "player1");
if (result.ok) {
const room = manager.getRoom(result.roomId);
expect(room?.players).toContain("player1");
expect(room?.host).toBe("player1");
expect(room?.status).toBe("waiting");
}
});
});
describe("joinRoom", () => {
it("should add a player to a waiting room", () => {
const create = manager.createRoom("stub", "player1");
if (!create.ok) throw new Error("Failed to create room");
const join = manager.joinRoom(create.roomId, "player2", "player");
expect(join.ok).toBe(true);
if (join.ok) {
expect(join.joinedAs).toBe("player");
}
});
it("should auto-start when room reaches maxPlayers", () => {
const create = manager.createRoom("stub", "player1");
if (!create.ok) throw new Error("Failed to create room");
manager.joinRoom(create.roomId, "player2", "player");
const room = manager.getRoom(create.roomId);
expect(room?.status).toBe("playing");
expect(room?.state).toBeDefined();
});
it("should allow joining as spectator when game is playing", () => {
const create = manager.createRoom("stub", "player1");
if (!create.ok) throw new Error("Failed to create room");
manager.joinRoom(create.roomId, "player2", "player");
const spec = manager.joinRoom(create.roomId, "spectator1", "spectator");
expect(spec.ok).toBe(true);
});
it("should downgrade to spectator when joining full room as player", () => {
const create = manager.createRoom("stub", "player1");
if (!create.ok) throw new Error("Failed to create room");
manager.joinRoom(create.roomId, "player2", "player");
const result = manager.joinRoom(create.roomId, "player3", "player");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.joinedAs).toBe("spectator");
}
});
it("should reject joining nonexistent room", () => {
const result = manager.joinRoom("fake-id", "player1", "player");
expect(result.ok).toBe(false);
});
});
describe("handleAction", () => {
it("should apply a valid game action", () => {
const create = manager.createRoom("stub", "player1");
if (!create.ok) throw new Error("Failed to create room");
manager.joinRoom(create.roomId, "player2", "player");
const result = manager.handleAction(create.roomId, "player1", { type: "action" });
expect(result.ok).toBe(true);
});
it("should reject action from spectator", () => {
const create = manager.createRoom("stub", "player1");
if (!create.ok) throw new Error("Failed to create room");
manager.joinRoom(create.roomId, "player2", "player");
manager.joinRoom(create.roomId, "spectator1", "spectator");
const result = manager.handleAction(create.roomId, "spectator1", { type: "action" });
expect(result.ok).toBe(false);
});
});
describe("leaveRoom", () => {
it("should remove a player from the room", () => {
const create = manager.createRoom("stub", "player1");
if (!create.ok) throw new Error("Failed to create room");
manager.leaveRoom(create.roomId, "player1");
const room = manager.getRoom(create.roomId);
expect(room).toBeUndefined();
});
it("should remove a spectator from the room", () => {
const create = manager.createRoom("stub", "player1");
if (!create.ok) throw new Error("Failed to create room");
manager.joinRoom(create.roomId, "player2", "player");
manager.joinRoom(create.roomId, "spec1", "spectator");
manager.leaveRoom(create.roomId, "spec1");
const room = manager.getRoom(create.roomId);
expect(room?.spectators.has("spec1")).toBe(false);
});
});
describe("listRooms", () => {
it("should return summaries of all rooms", () => {
manager.createRoom("stub", "player1");
manager.createRoom("stub", "player2");
const rooms = manager.listRooms();
expect(rooms.length).toBe(2);
expect(rooms[0].gameSlug).toBe("stub");
expect(rooms[0].status).toBe("waiting");
});
it("should filter by game type", () => {
manager.createRoom("stub", "player1");
const rooms = manager.listRooms("stub");
expect(rooms.length).toBe(1);
const empty = manager.listRooms("blackjack");
expect(empty.length).toBe(0);
});
});
describe("waiting room cleanup", () => {
it("should remove waiting rooms after the configured timeout", async () => {
const shortLivedManager = new RoomManager({ WAITING_CLEANUP_MS: 20 });
const create = shortLivedManager.createRoom("stub", "player1");
if (!create.ok) throw new Error("Failed to create room");
await new Promise(resolve => setTimeout(resolve, 35));
expect(shortLivedManager.getRoom(create.roomId)).toBeUndefined();
});
it("should refresh the waiting room timeout when the room is active", async () => {
const shortLivedManager = new RoomManager({ WAITING_CLEANUP_MS: 25 });
const create = shortLivedManager.createRoom("stub", "player1");
if (!create.ok) throw new Error("Failed to create room");
await new Promise(resolve => setTimeout(resolve, 15));
const spectatorJoin = shortLivedManager.joinRoom(create.roomId, "spectator1", "spectator");
expect(spectatorJoin.ok).toBe(true);
expect(shortLivedManager.getRoom(create.roomId)).toBeDefined();
await new Promise(resolve => setTimeout(resolve, 15));
expect(shortLivedManager.getRoom(create.roomId)).toBeDefined();
await new Promise(resolve => setTimeout(resolve, 20));
expect(shortLivedManager.getRoom(create.roomId)).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,327 @@
import mitt from "mitt";
import { gameRegistry } from "@shared/games/registry";
import type { Room, RoomSummary } from "./types";
import type { RoundSettlement } from "@shared/games/types";
const DEFAULT_ROOM_CONFIG = {
WAITING_CLEANUP_MS: 15 * 60_000,
FINISHED_CLEANUP_MS: 60_000,
PLAYING_MAX_MS: 30 * 60_000, // 30 minutes — safety net for stuck games
} as const;
type RoomManagerConfig = typeof DEFAULT_ROOM_CONFIG;
type ActionResult =
| { ok: true; state: unknown; gameOver: { winner: string | null; reason: string } | null; roundSettlements?: Record<string, RoundSettlement> }
| { ok: false; error: string };
type CreateResult = { ok: true; roomId: string } | { ok: false; error: string };
type JoinResult =
| { ok: true; joinedAs: "player" | "spectator"; started: boolean; readyToStart?: boolean }
| { ok: false; error: string };
type FillResult = { ok: true; readyToStart?: boolean } | { ok: false; error: string };
type RoomEvents = {
"room:created": { roomId: string; gameSlug: string; hostId: string };
"player:joined": { roomId: string; playerId: string; username: string; joinedAs: "player" | "spectator" };
"game:started": { roomId: string; spectatorView: unknown; playerViews: Map<string, unknown> };
"game:updated": { roomId: string; spectatorView: unknown; playerViews: Map<string, unknown> };
"game:ended": { roomId: string; winner: string | null; reason: string; payouts?: Record<string, number> };
"round:settled": { roomId: string; roundSettlements: Record<string, RoundSettlement> };
"player:left": { roomId: string; playerId: string };
"room:deleted": { roomId: string };
"room:list:changed": void;
};
export class RoomManager {
private rooms = new Map<string, Room>();
private cleanupTimers = new Map<string, Timer>();
private readonly config: RoomManagerConfig;
readonly emitter = mitt<RoomEvents>();
constructor(config: Partial<RoomManagerConfig> = {}) {
this.config = { ...DEFAULT_ROOM_CONFIG, ...config };
}
createRoom(gameSlug: string, hostId: string, options?: Record<string, unknown>): CreateResult {
const plugin = gameRegistry.get(gameSlug);
if (!plugin) return { ok: false, error: `Unknown game type: ${gameSlug}` };
const id = crypto.randomUUID();
const betAmount = typeof options?.betAmount === "number" && options.betAmount > 0 ? options.betAmount : 0;
const room: Room = {
id,
gameSlug,
host: hostId,
players: [hostId],
spectators: new Set(),
state: null,
status: "waiting",
createdAt: Date.now(),
options,
betAmount,
};
this.rooms.set(id, room);
this.refreshWaitingCleanup(id, room);
this.emitter.emit("room:created", { roomId: id, gameSlug, hostId });
this.emitter.emit("room:list:changed");
return { ok: true, roomId: id };
}
joinRoom(roomId: string, playerId: string, preferAs: "player" | "spectator", role?: string): JoinResult {
const room = this.rooms.get(roomId);
if (!room) return { ok: false, error: "Room not found" };
// Reconnecting player: must be checked before the in-progress spectator guard.
if (preferAs !== "spectator" && room.players.includes(playerId)) {
room.spectators.delete(playerId);
this.refreshWaitingCleanup(roomId, room);
return { ok: true, joinedAs: "player", started: room.status === "playing" };
}
if (preferAs === "spectator" || room.status !== "waiting") {
room.spectators.add(playerId);
this.refreshWaitingCleanup(roomId, room);
return { ok: true, joinedAs: "spectator", started: room.status === "playing" };
}
const plugin = gameRegistry.get(room.gameSlug)!;
const isAdmin = role === "admin";
if (room.players.length >= plugin.maxPlayers && !isAdmin) {
// Downgrade to spectator — room is full but still waiting, so game hasn't started
room.spectators.add(playerId);
return { ok: true, joinedAs: "spectator", started: false };
}
room.players.push(playerId);
if (room.players.length >= plugin.maxPlayers && !plugin.manualStart) {
// Defer start when bets are involved — GameServer handles async deduction first
if (room.betAmount > 0) {
this.refreshWaitingCleanup(roomId, room);
this.emitter.emit("room:list:changed");
return { ok: true, joinedAs: "player", started: false, readyToStart: true };
}
this.startGame(roomId);
return { ok: true, joinedAs: "player", started: true };
}
this.refreshWaitingCleanup(roomId, room);
this.emitter.emit("room:list:changed");
return { ok: true, joinedAs: "player", started: false };
}
handleAction(roomId: string, playerId: string, action: unknown): ActionResult {
const room = this.rooms.get(roomId);
if (!room) return { ok: false, error: "Room not found" };
if (room.status !== "playing") return { ok: false, error: "Game is not in progress" };
const plugin = gameRegistry.get(room.gameSlug)!;
// Spectator-to-player promotion for actions like "sit_down"
if (!room.players.includes(playerId)) {
if (room.spectators.has(playerId) && plugin.isSpectatorAction?.(action as any)) {
room.spectators.delete(playerId);
room.players.push(playerId);
} else {
return { ok: false, error: "You are not a player in this game" };
}
}
const result = plugin.handleAction(room.state, action, playerId);
if (!result.ok) return result;
room.state = result.state;
const gameOver = plugin.isGameOver?.(room.state) ?? null;
if (gameOver) {
room.status = "finished";
this.scheduleCleanup(roomId, this.config.FINISHED_CLEANUP_MS);
}
const spectatorView = plugin.getSpectatorView(room.state);
const playerViews = new Map<string, unknown>();
for (const pid of new Set(room.players)) {
playerViews.set(pid, plugin.getPlayerView(room.state, pid));
}
this.emitter.emit("game:updated", { roomId, spectatorView, playerViews });
// Emit round payouts for mid-game settlement (continuous-play games)
if (result.roundSettlements && !gameOver) {
this.emitter.emit("round:settled", { roomId, roundSettlements: result.roundSettlements });
}
if (gameOver) {
this.emitter.emit("game:ended", { roomId, winner: gameOver.winner, reason: gameOver.reason, payouts: gameOver.payouts });
this.emitter.emit("room:list:changed");
}
return { ok: true, state: room.state, gameOver, roundSettlements: result.roundSettlements };
}
leaveRoom(roomId: string, playerId: string): void {
const room = this.rooms.get(roomId);
if (!room) return;
room.spectators.delete(playerId);
const playerIdx = room.players.indexOf(playerId);
if (playerIdx !== -1) {
room.players.splice(playerIdx, 1);
if (room.status === "playing") {
const plugin = gameRegistry.get(room.gameSlug)!;
if (plugin.onPlayerDisconnect) {
room.state = plugin.onPlayerDisconnect(room.state, playerId);
const gameOver = plugin.isGameOver?.(room.state) ?? null;
if (gameOver) {
room.status = "finished";
this.scheduleCleanup(roomId, this.config.FINISHED_CLEANUP_MS);
this.emitter.emit("game:ended", { roomId, winner: gameOver.winner, reason: gameOver.reason, payouts: gameOver.payouts });
}
}
}
if (room.players.length === 0 && room.status === "waiting") {
this.deleteRoom(roomId);
return;
}
}
this.refreshWaitingCleanup(roomId, room);
this.emitter.emit("player:left", { roomId, playerId });
this.emitter.emit("room:list:changed");
}
/**
* Fills empty seats with the admin's own ID for solo testing.
* This means `createInitialState` will receive duplicate player IDs
* (e.g. ["alice", "alice"]). Plugin authors should be aware that
* solo-test mode produces non-unique player arrays.
*/
fillRoom(roomId: string, adminId: string): FillResult {
const room = this.rooms.get(roomId);
if (!room) return { ok: false, error: "Room not found" };
if (room.status !== "waiting") return { ok: false, error: "Game is not in waiting state" };
if (!room.players.includes(adminId)) return { ok: false, error: "You are not in this room" };
const plugin = gameRegistry.get(room.gameSlug)!;
while (room.players.length < plugin.maxPlayers) {
room.players.push(adminId);
}
// Defer start when bets are involved
if (room.betAmount > 0) {
this.refreshWaitingCleanup(roomId, room);
this.emitter.emit("room:list:changed");
return { ok: true, readyToStart: true };
}
this.startGame(roomId);
return { ok: true };
}
/** Initialize game state and transition room to playing. */
startGame(roomId: string): { ok: true } | { ok: false; error: string } {
const room = this.rooms.get(roomId);
if (!room) return { ok: false, error: "Room not found" };
if (room.status !== "waiting") return { ok: false, error: "Game is not in waiting state" };
const plugin = gameRegistry.get(room.gameSlug)!;
room.state = plugin.createInitialState(room.players, room.options);
room.status = "playing";
this.scheduleCleanup(roomId, this.config.PLAYING_MAX_MS);
const spectatorView = plugin.getSpectatorView(room.state);
const playerViews = new Map<string, unknown>();
for (const pid of new Set(room.players)) {
playerViews.set(pid, plugin.getPlayerView(room.state, pid));
}
this.emitter.emit("game:started", { roomId, spectatorView, playerViews });
this.emitter.emit("room:list:changed");
return { ok: true };
}
/** Remove a player from a waiting room (used when bet deduction fails). */
removePlayer(roomId: string, playerId: string): void {
const room = this.rooms.get(roomId);
if (!room || room.status !== "waiting") return;
const idx = room.players.indexOf(playerId);
if (idx !== -1) room.players.splice(idx, 1);
if (room.players.length === 0) {
this.deleteRoom(roomId);
return;
}
this.refreshWaitingCleanup(roomId, room);
this.emitter.emit("room:list:changed");
}
getRoom(roomId: string): Room | undefined {
return this.rooms.get(roomId);
}
listRooms(gameSlug?: string): RoomSummary[] {
const summaries: RoomSummary[] = [];
for (const room of this.rooms.values()) {
if (gameSlug && room.gameSlug !== gameSlug) continue;
const plugin = gameRegistry.get(room.gameSlug);
summaries.push({
id: room.id,
gameSlug: room.gameSlug,
gameName: plugin?.name ?? room.gameSlug,
host: room.host,
playerCount: room.players.length,
maxPlayers: plugin?.maxPlayers ?? 0,
spectatorCount: room.spectators.size,
status: room.status,
betAmount: room.betAmount,
});
}
return summaries;
}
getPlayerView(roomId: string, playerId: string): unknown {
const room = this.rooms.get(roomId);
if (!room || !room.state) return null;
const plugin = gameRegistry.get(room.gameSlug)!;
return plugin.getPlayerView(room.state, playerId);
}
getSpectatorView(roomId: string): unknown {
const room = this.rooms.get(roomId);
if (!room || !room.state) return null;
const plugin = gameRegistry.get(room.gameSlug)!;
return plugin.getSpectatorView(room.state);
}
private scheduleCleanup(roomId: string, ms: number): void {
this.clearCleanup(roomId);
const timer = setTimeout(() => this.deleteRoom(roomId), ms);
this.cleanupTimers.set(roomId, timer);
}
private refreshWaitingCleanup(roomId: string, room: Room): void {
if (room.status !== "waiting") return;
this.scheduleCleanup(roomId, this.config.WAITING_CLEANUP_MS);
}
private clearCleanup(roomId: string): void {
const existing = this.cleanupTimers.get(roomId);
if (existing) {
clearTimeout(existing);
this.cleanupTimers.delete(roomId);
}
}
private deleteRoom(roomId: string): void {
this.clearCleanup(roomId);
if (this.rooms.delete(roomId)) {
this.emitter.emit("room:deleted", { roomId });
this.emitter.emit("room:list:changed");
}
}
}

65
api/src/games/types.ts Normal file
View File

@@ -0,0 +1,65 @@
import type { RoundSettlement } from "@shared/games/types";
import { z } from "zod";
export interface Room {
id: string;
gameSlug: string;
host: string;
players: string[];
spectators: Set<string>;
state: unknown;
status: "waiting" | "playing" | "finished";
createdAt: number;
options?: Record<string, unknown>;
betAmount: number;
/** Guard against double bet-deduction when two joins race */
betsPending?: boolean;
}
export interface RoomSummary {
id: string;
gameSlug: string;
gameName: string;
host: string;
playerCount: number;
maxPlayers: number;
spectatorCount: number;
status: "waiting" | "playing" | "finished";
betAmount: number;
}
export interface PlayerInfo {
discordId: string;
username: string;
}
export const GameWsClientSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("CREATE_ROOM"), gameType: z.string(), options: z.looseObject({}).optional() }),
z.object({
type: z.literal("JOIN_ROOM"),
roomId: z.string(),
preferAs: z.enum(["player", "spectator"]),
role: z.enum(["player", "admin"]).optional(),
}),
z.object({ type: z.literal("LEAVE_ROOM"), roomId: z.string() }),
// Use looseObject for GAME_ACTION to avoid Zod bug with record()
z.object({ type: z.literal("GAME_ACTION"), roomId: z.string(), action: z.looseObject({}, { message: "Invalid action" }) }),
z.object({ type: z.literal("FILL_ROOM"), roomId: z.string() }),
z.object({ type: z.literal("START_GAME"), roomId: z.string() }),
]);
export type GameWsClientMessage = z.infer<typeof GameWsClientSchema>;
export type GameWsServerMessage =
| { type: "ROOM_LIST_UPDATE"; rooms: RoomSummary[] }
| { type: "GAME_STATE"; roomId: string; state: unknown }
| { type: "GAME_UPDATE"; roomId: string; state: unknown }
| { type: "PLAYER_JOINED"; roomId: string; player: PlayerInfo; joinedAs: "player" | "spectator" }
| { type: "PLAYER_LEFT"; roomId: string; playerId: string }
| { type: "GAME_STARTED"; roomId: string; state: unknown }
| { type: "GAME_ENDED"; roomId: string; winner: string | null; reason: string; payout?: { amount: number; refunded?: boolean } }
| { type: "ROOM_CREATED"; roomId: string; gameSlug: string }
| { type: "JOIN_RESULT"; roomId: string; joinedAs: "player" | "spectator"; roomStatus: "waiting" | "playing" | "finished"; players: PlayerInfo[]; spectators: PlayerInfo[]; state?: unknown; roomOptions?: { betAmount?: number; timeControl?: string } }
| { type: "ROUND_SETTLED"; roomId: string; settlements: Record<string, RoundSettlement> }
| { type: "SESSION_REPLACED"; roomId: string }
| { type: "ERROR"; message: string };

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": "no-cache",
}
});
}
return new Response("Not found", { status: 404 });
}
return null;
}
export const assetsRoutes: RouteModule = {
name: "assets",
handler
};

View File

@@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
const findFirst = mock(async () => ({ id: 123n }));
mock.module("@shared/db/DrizzleClient", () => ({
DrizzleClient: {
query: {
users: {
findFirst,
},
},
},
}));
mock.module("@shared/lib/logger", () => ({
logger: {
error: () => { },
info: () => { },
warn: () => { },
debug: () => { },
},
}));
import { authRoutes, getSession } from "./auth.routes";
describe("Auth Routes", () => {
let fetchSpy: ReturnType<typeof spyOn> | null = null;
beforeEach(() => {
process.env.DISCORD_CLIENT_ID = "client-id";
process.env.DISCORD_CLIENT_SECRET = "client-secret";
process.env.SESSION_SECRET = "session-secret";
process.env.PANEL_BASE_URL = "http://localhost:3000";
process.env.ADMIN_USER_IDS = "123";
findFirst.mockClear();
});
afterEach(() => {
fetchSpy?.mockRestore();
fetchSpy = null;
});
it("creates a signed session cookie during OAuth callback", async () => {
const loginUrl = new URL("http://localhost/auth/discord?return_to=http://localhost:5173/admin");
const loginRes = await authRoutes.handler({
req: new Request(loginUrl, { method: "GET" }),
url: loginUrl,
method: "GET",
pathname: "/auth/discord",
});
expect(loginRes?.status).toBe(302);
const redirectLocation = loginRes?.headers.get("Location");
expect(redirectLocation).not.toBeNull();
const state = new URL(redirectLocation!).searchParams.get("state");
expect(state).not.toBeNull();
fetchSpy = spyOn(globalThis, "fetch");
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ access_token: "discord-token" }), { status: 200 }));
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
id: "123",
username: "aurora-admin",
avatar: null,
}), { status: 200 }));
const callbackUrl = new URL(`http://localhost/auth/callback?code=oauth-code&state=${encodeURIComponent(state!)}`);
const callbackRes = await authRoutes.handler({
req: new Request(callbackUrl, { method: "GET" }),
url: callbackUrl,
method: "GET",
pathname: "/auth/callback",
});
expect(callbackRes?.status).toBe(302);
expect(callbackRes?.headers.get("Location")).toBe("/admin");
const setCookie = callbackRes?.headers.get("Set-Cookie");
expect(setCookie).toContain("aurora_session=");
const sessionCookie = setCookie!.split(";")[0]!;
const session = getSession(new Request("http://localhost/api/me", {
headers: { cookie: sessionCookie },
}));
expect(session).toEqual({
discordId: "123",
username: "aurora-admin",
avatar: null,
role: "admin",
expiresAt: expect.any(Number),
});
});
it("rejects tampered session cookies", () => {
const session = getSession(new Request("http://localhost/api/me", {
headers: { cookie: "aurora_session=not-a-valid-token" },
}));
expect(session).toBeNull();
});
});

View File

@@ -0,0 +1,349 @@
/**
* @fileoverview Discord OAuth2 authentication routes for the admin panel.
* Handles login flow, callback, logout, and session management.
*/
import { Buffer } from "node:buffer";
import { createHmac, timingSafeEqual } from "node:crypto";
import type { RouteContext, RouteModule } from "./types";
import { jsonResponse, errorResponse } from "./utils";
import { logger } from "@shared/lib/logger";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { users } from "@shared/db/schema";
import { eq } from "drizzle-orm";
// Signed session payload stored in the aurora_session cookie.
export interface Session {
discordId: string;
username: string;
avatar: string | null;
role: "admin" | "player";
expiresAt: number;
}
interface SessionTokenPayload extends Session {
v: 1;
}
interface OAuthStatePayload {
exp: number;
returnTo: string;
v: 1;
}
const COOKIE_NAME = "aurora_session";
const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
const OAUTH_STATE_MAX_AGE = 10 * 60 * 1000; // 10 minutes
const TOKEN_NAMESPACE = "aurora.auth";
const TOKEN_VERSION = "v1";
function getEnv(key: string): string {
const val = process.env[key];
if (!val) throw new Error(`Missing env: ${key}`);
return val;
}
function getSessionSecret(required: boolean = false): string | null {
const secret = process.env.SESSION_SECRET ?? process.env.DISCORD_CLIENT_SECRET ?? null;
if (!secret && required) {
throw new Error("Missing env: SESSION_SECRET or DISCORD_CLIENT_SECRET");
}
return secret;
}
function requireSessionSecret(): string {
return getSessionSecret(true)!;
}
function getAdminIds(): string[] {
const raw = process.env.ADMIN_USER_IDS ?? "";
return raw.split(",").map(s => s.trim()).filter(Boolean);
}
function encodeBase64Url(value: string): string {
return Buffer.from(value, "utf8").toString("base64url");
}
function decodeBase64Url(value: string): string {
return Buffer.from(value, "base64url").toString("utf8");
}
function signValue(kind: string, encodedPayload: string, secret: string): string {
return createHmac("sha256", secret)
.update(`${TOKEN_NAMESPACE}.${kind}.${encodedPayload}`)
.digest("base64url");
}
function serializeSignedToken(kind: string, payload: SessionTokenPayload | OAuthStatePayload, secret: string): string {
const encodedPayload = encodeBase64Url(JSON.stringify(payload));
const signature = signValue(kind, encodedPayload, secret);
return `${TOKEN_VERSION}.${encodedPayload}.${signature}`;
}
function parseSignedToken<T>(token: string | undefined, kind: string): T | null {
if (!token) return null;
const secret = getSessionSecret();
if (!secret) return null;
const parts = token.split(".");
if (parts.length !== 3) return null;
const version = parts[0];
const encodedPayload = parts[1];
const providedSignature = parts[2];
if (version !== TOKEN_VERSION) return null;
if (!encodedPayload || !providedSignature) return null;
const expectedSignature = signValue(kind, encodedPayload, secret);
const providedBuffer = Buffer.from(providedSignature);
const expectedBuffer = Buffer.from(expectedSignature);
if (providedBuffer.length !== expectedBuffer.length) return null;
if (!timingSafeEqual(providedBuffer, expectedBuffer)) return null;
try {
return JSON.parse(decodeBase64Url(encodedPayload)) as T;
} catch {
return null;
}
}
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;
}
function sanitizeReturnTo(rawReturnTo: string | null, baseUrl: string): string {
if (!rawReturnTo || rawReturnTo.length > 1024) return "/";
try {
if (rawReturnTo.startsWith("/") && !rawReturnTo.startsWith("//")) {
return rawReturnTo;
}
const parsed = new URL(rawReturnTo, baseUrl);
const allowedBase = new URL(baseUrl);
const isLocalhostRedirect = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
if (parsed.origin === allowedBase.origin || isLocalhostRedirect) {
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
}
} catch {
return "/";
}
return "/";
}
function buildCookieAttributes(maxAgeSeconds?: number): string {
const attrs = [
"Path=/",
"HttpOnly",
"SameSite=Lax",
];
try {
if (new URL(getBaseUrl()).protocol === "https:") {
attrs.push("Secure");
}
} catch {
// Ignore invalid PANEL_BASE_URL here; handlers that need it will fail explicitly.
}
if (typeof maxAgeSeconds === "number") {
attrs.push(`Max-Age=${maxAgeSeconds}`);
}
return attrs.join("; ");
}
/** Get session from request cookie */
export function getSession(req: Request): Session | null {
const cookies = parseCookies(req.headers.get("cookie"));
const payload = parseSignedToken<SessionTokenPayload>(cookies[COOKIE_NAME], "session");
if (!payload || payload.v !== 1) return null;
if (Date.now() > payload.expiresAt) return null;
return {
discordId: payload.discordId,
username: payload.username,
avatar: payload.avatar,
role: payload.role,
expiresAt: payload.expiresAt,
};
}
/** 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";
const secret = requireSessionSecret();
// Store return_to URL in signed OAuth state
const returnTo = sanitizeReturnTo(ctx.url.searchParams.get("return_to"), baseUrl);
const state = serializeSignedToken("oauth", {
exp: Date.now() + OAUTH_STATE_MAX_AGE,
returnTo,
v: 1,
}, secret);
const url = `https://discord.com/oauth2/authorize?client_id=${clientId}&response_type=code&redirect_uri=${redirectUri}&scope=${scope}&state=${encodeURIComponent(state)}`;
return new Response(null, {
status: 302,
headers: {
Location: url,
},
});
} 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`;
const secret = requireSessionSecret();
const statePayload = parseSignedToken<OAuthStatePayload>(ctx.url.searchParams.get("state") ?? undefined, "oauth");
if (!statePayload || statePayload.v !== 1 || Date.now() > statePayload.exp) {
return errorResponse("Invalid OAuth state", 400);
}
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 enrollment — user must exist in the users table
const dbUser = await DrizzleClient.query.users.findFirst({
where: eq(users.id, BigInt(user.id)),
});
if (!dbUser) {
logger.info("auth", `Non-enrolled login attempt by ${user.username} (${user.id})`);
return new Response(
`<html><body><h1>Not Enrolled</h1><p>You need to use the Aurora bot in Discord before you can access this panel.</p><a href="/">Go back</a></body></html>`,
{ status: 403, headers: { "Content-Type": "text/html" } }
);
}
// Determine role
const adminIds = getAdminIds();
const role: "admin" | "player" = adminIds.includes(user.id) ? "admin" : "player";
// Create signed session cookie
const sessionToken = serializeSignedToken("session", {
discordId: user.id,
username: user.username,
avatar: user.avatar,
role,
expiresAt: Date.now() + SESSION_MAX_AGE,
v: 1,
}, secret);
logger.info("auth", `Login: ${user.username} (${user.id}) as ${role}`);
// Redirect to panel with session cookie
return new Response(null, {
status: 302,
headers: {
Location: sanitizeReturnTo(statePayload.returnTo, baseUrl),
"Set-Cookie": `${COOKIE_NAME}=${sessionToken}; ${buildCookieAttributes(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") {
return new Response(null, {
status: 200,
headers: {
"Set-Cookie": `${COOKIE_NAME}=; ${buildCookieAttributes(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, enrolled: true });
return jsonResponse({
authenticated: true,
enrolled: true,
user: {
discordId: session.discordId,
username: session.username,
avatar: session.avatar,
role: session.role,
},
});
}
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
};

View File

@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, mock } from "bun:test";
let currentSession: { discordId: string; username: string; role: "admin" | "player"; expiresAt: number } | null = null;
mock.module("./auth.routes", () => ({
authRoutes: { name: "auth", handler: () => null },
getSession: () => currentSession,
}));
mock.module("./health.routes", () => ({
healthRoutes: {
name: "health",
handler: ({ pathname }: { pathname: string }) =>
pathname === "/api/health"
? Response.json({ status: "ok" }, { status: 200 })
: null,
},
}));
mock.module("./stats.routes", () => ({
statsRoutes: {
name: "stats",
handler: ({ pathname }: { pathname: string }) =>
pathname === "/api/stats"
? Response.json({ ok: true }, { status: 200 })
: null,
},
}));
mock.module("./actions.routes", () => ({ actionsRoutes: { name: "actions", handler: () => null } }));
mock.module("./quests.routes", () => ({ questsRoutes: { name: "quests", handler: () => null } }));
mock.module("./settings.routes", () => ({ settingsRoutes: { name: "settings", handler: () => null } }));
mock.module("./guild-settings.routes", () => ({ guildSettingsRoutes: { name: "guild-settings", handler: () => null } }));
mock.module("./items.routes", () => ({ itemsRoutes: { name: "items", handler: () => null } }));
mock.module("./classes.routes", () => ({ classesRoutes: { name: "classes", handler: () => null } }));
mock.module("./moderation.routes", () => ({ moderationRoutes: { name: "moderation", handler: () => null } }));
mock.module("./transactions.routes", () => ({ transactionsRoutes: { name: "transactions", handler: () => null } }));
mock.module("./lootdrops.routes", () => ({ lootdropsRoutes: { name: "lootdrops", handler: () => null } }));
mock.module("./assets.routes", () => ({ assetsRoutes: { name: "assets", handler: () => null } }));
mock.module("@shared/modules/user/user.service", () => ({
userService: {
getUserById: async (id: string) => ({ id, username: `user-${id}` }),
},
}));
mock.module("@shared/modules/inventory/inventory.service", () => ({
inventoryService: {
getInventory: async (id: string) => [{ userId: id, itemId: 1, quantity: 1n }],
},
}));
mock.module("@shared/lib/logger", () => ({
logger: {
error: () => { },
info: () => { },
warn: () => { },
debug: () => { },
},
}));
import { handleRequest } from "./index";
describe("Route Authorization", () => {
beforeEach(() => {
currentSession = null;
});
it("rejects unauthenticated protected API requests", async () => {
const url = new URL("http://localhost/api/users/123");
const res = await handleRequest(new Request(url, { method: "GET" }), url);
expect(res?.status).toBe(401);
});
it("blocks players from admin user routes", async () => {
currentSession = {
discordId: "123",
username: "player",
role: "player",
expiresAt: Date.now() + 60_000,
};
const url = new URL("http://localhost/api/users/456");
const res = await handleRequest(new Request(url, { method: "GET" }), url);
expect(res?.status).toBe(403);
});
it("allows players to access self-service API routes", async () => {
currentSession = {
discordId: "123",
username: "player",
role: "player",
expiresAt: Date.now() + 60_000,
};
const url = new URL("http://localhost/api/me/inventory");
const res = await handleRequest(new Request(url, { method: "GET" }), url);
expect(res?.status).toBe(200);
});
it("allows admins to access admin user routes", async () => {
currentSession = {
discordId: "1",
username: "admin",
role: "admin",
expiresAt: Date.now() + 60_000,
};
const url = new URL("http://localhost/api/users/456");
const res = await handleRequest(new Request(url, { method: "GET" }), url);
expect(res?.status).toBe(200);
});
});

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

@@ -0,0 +1,102 @@
/**
* @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, getSession } 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/")) {
const session = getSession(req);
if (!session) {
return errorResponse("Unauthorized", 401);
}
// Player routes are explicitly allow-listed. Everything else is admin-only.
const playerAllowedPrefixes = ["/api/stats", "/api/health", "/api/me"];
const isPlayerAllowed = playerAllowedPrefixes.some(p => ctx.pathname.startsWith(p));
if (session.role === "player" && !isPlayerAllowed) {
return errorResponse("Admin access required", 403);
}
}
// 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}?v=${Date.now()}`;
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}?v=${Date.now()}`;
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 { spawnLootdrop } = await import("../../../bot/modules/economy/lootdrop.handler");
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 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 { deleteLootdrop } = await import("../../../bot/modules/economy/lootdrop.handler");
const success = await 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()]).nullable().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,140 @@
import { beforeEach, describe, expect, it, mock } from "bun:test";
let currentSession: { discordId: string; username: string; role: "admin" | "player"; expiresAt: number } | null = null;
const getUserById = mock(async (id: string) => ({
id,
username: id === "123" ? "player-one" : "user",
level: 5,
xp: 100n,
balance: 250n,
className: null,
}));
const updateUser = mock(async (id: string, data: Record<string, unknown>) => ({
id,
...data,
}));
const getInventory = mock(async (id: string) => [{ userId: id, itemId: 1, quantity: 2n }]);
const addItem = mock(async (userId: string, itemId: number, quantity: bigint) => ({ userId, itemId, quantity }));
const removeItem = mock(async () => undefined);
mock.module("./auth.routes", () => ({
getSession: () => currentSession,
}));
mock.module("@shared/modules/user/user.service", () => ({
userService: {
getUserById,
updateUser,
},
}));
mock.module("@shared/modules/inventory/inventory.service", () => ({
inventoryService: {
getInventory,
addItem,
removeItem,
},
}));
mock.module("@shared/lib/logger", () => ({
logger: {
error: () => { },
info: () => { },
warn: () => { },
debug: () => { },
},
}));
import { usersRoutes } from "./users.routes";
describe("Users Routes", () => {
beforeEach(() => {
currentSession = {
discordId: "123",
username: "player",
role: "player",
expiresAt: Date.now() + 60_000,
};
getUserById.mockClear();
updateUser.mockClear();
getInventory.mockClear();
addItem.mockClear();
removeItem.mockClear();
});
it("serves the authenticated user through /api/me", async () => {
const url = new URL("http://localhost/api/me");
const res = await usersRoutes.handler({
req: new Request(url, { method: "GET" }),
url,
method: "GET",
pathname: "/api/me",
});
expect(res?.status).toBe(200);
expect(getUserById).toHaveBeenCalledWith("123");
});
it("serves the authenticated user's inventory through /api/me/inventory", async () => {
const url = new URL("http://localhost/api/me/inventory");
const res = await usersRoutes.handler({
req: new Request(url, { method: "GET" }),
url,
method: "GET",
pathname: "/api/me/inventory",
});
expect(res?.status).toBe(200);
expect(getInventory).toHaveBeenCalledWith("123");
});
it("validates user updates before calling the service", async () => {
const url = new URL("http://localhost/api/users/123");
const res = await usersRoutes.handler({
req: new Request(url, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ level: -1 }),
}),
url,
method: "PUT",
pathname: "/api/users/123",
});
expect(res?.status).toBe(400);
expect(updateUser).not.toHaveBeenCalled();
});
it("validates inventory additions before calling the service", async () => {
const url = new URL("http://localhost/api/users/123/inventory");
const res = await usersRoutes.handler({
req: new Request(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ itemId: 1, quantity: 0 }),
}),
url,
method: "POST",
pathname: "/api/users/123/inventory",
});
expect(res?.status).toBe(400);
expect(addItem).not.toHaveBeenCalled();
});
it("validates inventory removal query params before calling the service", async () => {
const url = new URL("http://localhost/api/users/123/inventory/1?amount=0");
const res = await usersRoutes.handler({
req: new Request(url, { method: "DELETE" }),
url,
method: "DELETE",
pathname: "/api/users/123/inventory/1",
});
expect(res?.status).toBe(400);
expect(removeItem).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,302 @@
/**
* @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,
parseQuery,
parseStringIdFromPath,
withErrorHandling
} from "./utils";
import { InventoryAddSchema, InventoryRemoveQuerySchema, UpdateUserSchema, UserQuerySchema } from "./schemas";
import { getSession } from "./auth.routes";
/**
* Users routes handler.
*
* Endpoints:
* - GET /api/me - Get current authenticated user
* - GET /api/me/inventory - Get current authenticated user's inventory
* - 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")) {
if (pathname === "/api/me" && method === "GET") {
return withErrorHandling(async () => {
const session = getSession(req);
if (!session) {
return errorResponse("Unauthorized", 401);
}
const { userService } = await import("@shared/modules/user/user.service");
const user = await userService.getUserById(session.discordId);
if (!user) {
return errorResponse("User not found", 404);
}
return jsonResponse(user);
}, "fetch current user");
}
if (pathname === "/api/me/inventory" && method === "GET") {
return withErrorHandling(async () => {
const session = getSession(req);
if (!session) {
return errorResponse("Unauthorized", 401);
}
const { inventoryService } = await import("@shared/modules/inventory/inventory.service");
const inventory = await inventoryService.getInventory(session.discordId);
return jsonResponse({ inventory });
}, "fetch current user inventory");
}
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 queryParams = parseQuery(url, UserQuerySchema);
if (queryParams instanceof Response) {
return queryParams;
}
const { search, sortBy, sortOrder, limit, offset } = queryParams;
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 parsed = await parseBody(req, UpdateUserSchema);
if (parsed instanceof Response) {
return parsed;
}
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 (parsed.username !== undefined) updateData.username = parsed.username;
if (parsed.balance !== undefined) updateData.balance = BigInt(parsed.balance);
if (parsed.xp !== undefined) updateData.xp = BigInt(parsed.xp);
if (parsed.level !== undefined) updateData.level = parsed.level;
if (parsed.dailyStreak !== undefined) updateData.dailyStreak = parsed.dailyStreak;
if (parsed.isActive !== undefined) updateData.isActive = parsed.isActive;
if (parsed.settings !== undefined) updateData.settings = parsed.settings;
if (parsed.classId !== undefined) {
updateData.classId = parsed.classId === null ? null : BigInt(parsed.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 parsed = await parseBody(req, InventoryAddSchema);
if (parsed instanceof Response) {
return parsed;
}
const entry = await inventoryService.addItem(id, parsed.itemId, BigInt(parsed.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 queryParams = parseQuery(url, InventoryRemoveQuerySchema);
if (queryParams instanceof Response) {
return queryParams;
}
await inventoryService.removeItem(userId, itemId, BigInt(queryParams.amount));
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,445 @@
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 },
getSession: () => ({ discordId: "123", username: "testuser", role: "admin", 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,26 @@ mock.module("bun", () => {
};
});
// Mock auth (bypass authentication)
mock.module("./routes/auth.routes", () => ({
authRoutes: { name: "auth", handler: () => null },
getSession: () => ({ discordId: "123", username: "testuser", role: "admin", 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 +142,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 +158,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 +186,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 });

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

@@ -0,0 +1,199 @@
import { beforeEach, describe, test, expect, afterAll, mock } from "bun:test";
import type { WebServerInstance } from "./server";
interface MockBotStats {
bot: { name: string; avatarUrl: string | null };
guilds: number;
ping: number;
cachedUsers: number;
commandsRegistered: number;
uptime: number;
lastCommandTimestamp: number | null;
}
// 1. Mock DrizzleClient (dependency of dashboardService)
// Must provide full chainable builder for select().from().leftJoin().groupBy().orderBy().limit()
mock.module("@shared/db/DrizzleClient", () => {
const mockBuilder: Record<string, any> = {};
// Every chainable method returns mock builder; terminal calls return resolved promise
mockBuilder.where = mock(() => Promise.resolve([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]));
mockBuilder.then = (onfulfilled: any) => onfulfilled([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]);
mockBuilder.orderBy = mock(() => mockBuilder);
mockBuilder.limit = mock(() => Promise.resolve([]));
mockBuilder.leftJoin = mock(() => mockBuilder);
mockBuilder.groupBy = mock(() => mockBuilder);
mockBuilder.from = mock(() => mockBuilder);
return {
DrizzleClient: {
select: mock(() => mockBuilder),
query: {
transactions: { findMany: mock(() => Promise.resolve([])) },
moderationCases: { findMany: mock(() => Promise.resolve([])) },
users: {
findFirst: mock(() => Promise.resolve({ username: "test" })),
findMany: mock(() => Promise.resolve([])),
},
lootdrops: { findMany: mock(() => Promise.resolve([])) },
}
},
};
});
// 2. Mock Bot Stats Provider
mock.module("../../bot/lib/clientStats", () => ({
getClientStats: mock((): MockBotStats => ({
bot: { name: "TestBot", avatarUrl: null },
guilds: 5,
ping: 42,
cachedUsers: 100,
commandsRegistered: 10,
uptime: 3600,
lastCommandTimestamp: Date.now(),
})),
}));
// 3. Mock config (used by lootdrop.service.getLootdropState)
mock.module("@shared/lib/config", () => ({
config: {
lootdrop: {
activityWindowMs: 120000,
minMessages: 1,
spawnChance: 1,
cooldownMs: 3000,
reward: { min: 40, max: 150, currency: "Astral Units" }
}
}
}));
let currentSession: { discordId: string; username: string; role: "admin" | "player"; expiresAt: number } | null = {
discordId: "123",
username: "admin-user",
role: "admin",
expiresAt: Date.now() + 3600000,
};
// 4. Mock auth with a mutable session so tests can exercise authz paths.
mock.module("./routes/auth.routes", () => ({
authRoutes: { name: "auth", handler: () => null },
getSession: () => currentSession,
}));
// 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;
beforeEach(() => {
currentSession = {
discordId: "123",
username: "admin-user",
role: "admin",
expiresAt: Date.now() + 3600000,
};
});
afterAll(async () => {
if (serverInstance) {
await serverInstance.stop();
}
});
test("should reject unauthorized websocket requests", async () => {
serverInstance = await createWebServer({ port, hostname });
currentSession = null;
const response = await fetch(`http://${hostname}:${port}/ws`);
const body = await response.text();
expect(response.status).toBe(401);
expect(body).toBe("Unauthorized");
});
test("should accept websocket requests for authenticated sessions", async () => {
if (!serverInstance) {
serverInstance = await createWebServer({ port, hostname });
}
const ws = new WebSocket(`ws://${hostname}:${port}/ws`);
const opened = await new Promise<boolean>((resolve) => {
const timeout = setTimeout(() => resolve(false), 1000);
ws.addEventListener("open", () => {
clearTimeout(timeout);
resolve(true);
});
ws.addEventListener("error", () => {
clearTimeout(timeout);
resolve(false);
});
});
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
ws.close();
}
expect(opened).toBe(true);
});
test("should return 200 for health check", async () => {
if (!serverInstance) {
serverInstance = await createWebServer({ port, hostname });
}
const response = await fetch(`http://${hostname}:${port}/api/health`);
expect(response.status).toBe(200);
const data = (await response.json()) as { status: string };
expect(data.status).toBe("ok");
});
describe("Administrative Actions", () => {
test("should allow administrative actions for admin sessions", async () => {
const response = await fetch(`http://${hostname}:${port}/api/actions/reload-commands`, {
method: "POST"
});
expect(response.status).toBe(200);
});
test("should reject administrative actions for player sessions", async () => {
currentSession = {
discordId: "456",
username: "player-user",
role: "player",
expiresAt: Date.now() + 3600000,
};
const response = await fetch(`http://${hostname}:${port}/api/actions/reload-commands`, {
method: "POST"
});
expect(response.status).toBe(403);
const data = await response.json() as { error: string };
expect(data.error).toBe("Admin access required");
});
test("should reject maintenance mode with invalid payload", async () => {
const response = await fetch(`http://${hostname}:${port}/api/actions/maintenance-mode`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ not_enabled: true }) // Wrong field
});
expect(response.status).toBe(400);
const data = await response.json() as { error: string };
expect(data.error).toBe("Invalid payload");
});
});
});

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

@@ -0,0 +1,242 @@
/**
* @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 type { ServerWebSocket } from "bun";
import { logger } from "@shared/lib/logger";
import { handleRequest } from "./routes";
import { getFullDashboardStats } from "./routes/stats.helper";
import { join } from "path";
import { gameServer } from "./games/GameServer";
import type { WsConnectionData } from "./games/GameServer";
import { getSession } from "./routes/auth.routes";
import { GameWsClientSchema } from "./games/types";
// Register game plugins
import { gameRegistry } from "@shared/games/registry";
import { chessPlugin } from "@shared/games/chess/chess.plugin";
import { blackjackPlugin } from "@shared/games/blackjack/blackjack.plugin";
gameRegistry.register(chessPlugin);
gameRegistry.register(blackjackPlugin);
const WS_CONFIG = {
MAX_CONNECTIONS: 200,
MAX_PAYLOAD_BYTES: 16384,
IDLE_TIMEOUT_SECONDS: 60,
STATS_BROADCAST_INTERVAL_MS: 5000,
} as const;
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",
};
export interface WebServerConfig {
port?: number;
hostname?: string;
}
export interface WebServerInstance {
server: ReturnType<typeof serve>;
stop: () => Promise<void>;
url: string;
}
/**
* 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;
let activeConnections = 0;
let statsBroadcastInterval: Timer | undefined;
const server = serve<WsConnectionData>({
port,
hostname,
async fetch(req, server) {
const url = new URL(req.url);
if (url.pathname === "/ws") {
if (activeConnections >= WS_CONFIG.MAX_CONNECTIONS) {
logger.warn("web", `Connection rejected: limit reached (${activeConnections}/${WS_CONFIG.MAX_CONNECTIONS})`);
return new Response("Connection limit reached", { status: 429 });
}
const session = getSession(req);
if (!session) {
return new Response("Unauthorized", { status: 401 });
}
const success = server.upgrade(req, {
data: {
session: {
discordId: session.discordId,
username: session.username,
role: session.role,
},
rooms: new Set<string>(),
},
});
if (success) return undefined;
return new Response("WebSocket upgrade failed", { status: 400 });
}
const response = await handleRequest(req, url);
if (response) return response;
const panelDistDir = join(import.meta.dir, "../../panel/dist");
const staticResponse = await servePanelStatic(url.pathname, panelDistDir);
if (staticResponse) return staticResponse;
return new Response("Not Found", { status: 404 });
},
websocket: {
open(ws: ServerWebSocket<WsConnectionData>) {
activeConnections++;
ws.subscribe("dashboard");
ws.subscribe("lobby");
logger.debug("web", `Client connected: ${ws.data.session.discordId}. Total: ${activeConnections}`);
getFullDashboardStats().then(stats => {
ws.send(JSON.stringify({ type: "STATS_UPDATE", data: stats }));
});
gameServer.handleOpen(ws);
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);
}
}, WS_CONFIG.STATS_BROADCAST_INTERVAL_MS);
}
},
async message(ws: ServerWebSocket<WsConnectionData>, message) {
try {
const messageStr = message.toString();
if (messageStr.length > WS_CONFIG.MAX_PAYLOAD_BYTES) {
logger.error("web", "Payload exceeded maximum limit");
return;
}
const rawData = JSON.parse(messageStr);
// Handle dashboard-level messages (PING, etc.)
if (rawData && typeof rawData === "object" && rawData.type === "PING") {
ws.send(JSON.stringify({ type: "PONG" }));
return;
}
// Route game messages — try to parse as a game client message
const gameCheck = GameWsClientSchema.safeParse(rawData);
if (gameCheck.success) {
gameServer.handleMessage(ws, rawData).catch(err =>
logger.error("web", `Game message handler error: ${err}`),
);
return;
}
// Fall back to full WsMessageSchema for dashboard messages that aren't PING
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);
}
// Nothing else to do for PONG/STATS_UPDATE/NEW_EVENT from clients
} catch (e) {
logger.error("web", "Failed to handle message", e);
}
},
close(ws: ServerWebSocket<WsConnectionData>) {
activeConnections--;
ws.unsubscribe("dashboard");
ws.unsubscribe("lobby");
logger.debug("web", `Client disconnected: ${ws.data.session.discordId}. Total remaining: ${activeConnections}`);
gameServer.handleClose(ws);
if (activeConnections === 0 && statsBroadcastInterval) {
clearInterval(statsBroadcastInterval);
statsBroadcastInterval = undefined;
}
},
maxPayloadLength: WS_CONFIG.MAX_PAYLOAD_BYTES,
idleTimeout: WS_CONFIG.IDLE_TIMEOUT_SECONDS,
},
});
// Wire gameServer to Bun server for pub/sub publishing
gameServer.setServer(server);
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);
},
};
}

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 "@shared/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.editReply({ embeds: [createErrorEmbed(error.message)] });
} else {
console.error("Error creating listing:", error);
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
}
}
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 "@modules/moderation/prune.service";
import {
getConfirmationMessage,
getProgressEmbed,
@@ -10,6 +10,8 @@ import {
getPruneWarningEmbed,
getCancelledEmbed
} from "@/modules/moderation/prune.view";
import { withCommandErrorHandling } from "@lib/commandUtils";
import { PRUNE_CUSTOM_IDS } from "@modules/moderation/prune.types";
export const prune = createCommand({
data: new SlashCommandBuilder()
@@ -38,142 +40,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 === PRUNE_CUSTOM_IDS.CANCEL) {
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

@@ -1,8 +1,9 @@
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 { terminalService } from "@modules/system/terminal.service";
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,177 +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,
buildWebAssets: requirements.needsWebBuild,
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,35 +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 "@shared/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) => {
await interaction.deferReply();
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.editReply({ embeds: [embed] });
} catch (error: any) {
if (error instanceof UserError) {
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
} else {
console.error("Error claiming daily:", error);
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
await interaction.editReply({ embeds: [embed] });
}
}
);
}
});

View File

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

View File

@@ -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 "@shared/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

@@ -6,6 +6,7 @@ import { createErrorEmbed } from "@lib/embeds";
import { UserError } from "@shared/lib/errors";
import { config } from "@shared/lib/config";
import { TriviaCategory } from "@shared/lib/constants";
import { withCommandErrorHandling } from "@lib/commandUtils";
export const trivia = createCommand({
data: new SlashCommandBuilder()
@@ -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

@@ -1,22 +1,83 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js";
import { SlashCommandBuilder, MessageFlags, ComponentType } from "discord.js";
import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { userService } from "@shared/modules/user/user.service";
import { createWarningEmbed } from "@lib/embeds";
import { getInventoryEmbed } from "@/modules/inventory/inventory.view";
import { createWarningEmbed, createErrorEmbed } from "@lib/embeds";
import {
getInventoryListMessage,
getEmptyInventoryMessage,
getItemDetailMessage,
getDiscardConfirmMessage,
appendUseBackButton,
sortInventoryItems,
ITEMS_PER_PAGE,
type InventoryEntry,
} from "@/modules/inventory/inventory.view";
import { getLootboxResultMessage } from "@/modules/inventory/inventory.view";
import {
parseInventoryCustomId,
executeItemUse,
} from "@/modules/inventory/inventory.interaction";
import { UserError } from "@shared/lib/errors";
export const inventory = createCommand({
data: new SlashCommandBuilder()
.setName("inventory")
.setDescription("View your or another user's inventory")
.addUserOption(option =>
option.setName("user")
.setDescription("User to view")
.setRequired(false)
.addSubcommand(sub =>
sub.setName("list")
.setDescription("View your or another user's inventory")
.addUserOption(option =>
option.setName("user")
.setDescription("User to view")
.setRequired(false)
)
)
.addSubcommand(sub =>
sub.setName("view")
.setDescription("View details of a specific item")
.addNumberOption(option =>
option.setName("item")
.setDescription("The item to view")
.setRequired(true)
.setAutocomplete(true)
)
),
execute: async (interaction) => {
await interaction.deferReply();
const viewerId = interaction.user.id;
const subcommand = interaction.options.getSubcommand();
if (subcommand === "view") {
// Direct item detail view
const itemId = interaction.options.getNumber("item", true);
const user = await userService.getOrCreateUser(viewerId, interaction.user.username);
if (!user) {
await interaction.editReply({ embeds: [createWarningEmbed("Failed to load user data.", "Error")] });
return;
}
const entries = await inventoryService.getInventory(user.id.toString());
const entry = entries.find((e: any) => e.item.id === itemId);
if (!entry) {
await interaction.editReply({ embeds: [createWarningEmbed("Item not found in your inventory.", "Not Found")] });
return;
}
const ownerId = user.id.toString();
let currentPage = 0;
let selectedItemId: number | null = itemId;
const response = await interaction.editReply(
getItemDetailMessage(entry as InventoryEntry, viewerId, ownerId) as any
);
await setupCollector(interaction, response, viewerId, ownerId, user.username, currentPage, selectedItemId);
return;
}
// "list" subcommand
const targetUser = interaction.options.getUser("user") || interaction.user;
if (targetUser.bot) {
@@ -30,15 +91,232 @@ export const inventory = createCommand({
return;
}
const items = await inventoryService.getInventory(user.id.toString());
const ownerId = user.id.toString();
const entries = await inventoryService.getInventory(ownerId);
if (!items || items.length === 0) {
await interaction.editReply({ embeds: [createWarningEmbed("Inventory is empty.", `${user.username}'s Inventory`)] });
if (!entries || entries.length === 0) {
await interaction.editReply(getEmptyInventoryMessage(user.username) as any);
return;
}
const embed = getInventoryEmbed(items, user.username);
let currentPage = 0;
let selectedItemId: number | null = null;
await interaction.editReply({ embeds: [embed] });
}
const response = await interaction.editReply(
getInventoryListMessage(entries as InventoryEntry[], user.username, currentPage, viewerId, ownerId) as any
);
await setupCollector(interaction, response, viewerId, ownerId, user.username, currentPage, selectedItemId);
},
autocomplete: async (interaction) => {
const focusedValue = interaction.options.getFocused();
const userId = interaction.user.id;
const results = await inventoryService.getAutocompleteItems(userId, focusedValue);
await interaction.respond(results);
},
});
async function setupCollector(
interaction: any,
response: any,
viewerId: string,
ownerId: string,
username: string,
initialPage: number,
initialItemId: number | null,
) {
let currentPage = initialPage;
let selectedItemId = initialItemId;
const collector = response.createMessageComponentCollector({
time: 120_000,
});
collector.on("collect", async (i: any) => {
if (i.user.id !== viewerId) return;
const parsed = parseInventoryCustomId(i.customId);
if (!parsed) return;
try {
await i.deferUpdate();
// Re-fetch inventory for fresh data
const entries = await inventoryService.getInventory(ownerId);
const sorted = sortInventoryItems(entries as InventoryEntry[]);
switch (parsed.action) {
case "select": {
const itemId = parseInt(i.values[0]);
const entry = sorted.find(e => e.item.id === itemId);
if (!entry) break;
selectedItemId = itemId;
await interaction.editReply(
getItemDetailMessage(entry, viewerId, ownerId)
);
break;
}
case "prev": {
currentPage = Math.max(0, currentPage - 1);
selectedItemId = null;
await interaction.editReply(
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
);
break;
}
case "next": {
currentPage = currentPage + 1;
selectedItemId = null;
await interaction.editReply(
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
);
break;
}
case "back": {
selectedItemId = null;
if (sorted.length === 0) {
await interaction.editReply(getEmptyInventoryMessage(username));
} else {
await interaction.editReply(
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
);
}
break;
}
case "use": {
if (viewerId !== ownerId || !selectedItemId) break;
try {
const result = await executeItemUse(i, viewerId, selectedItemId);
const message = getLootboxResultMessage(result.results, result.item);
await interaction.editReply(appendUseBackButton(message, viewerId) as any);
} catch (error) {
if (error instanceof UserError) {
await interaction.editReply({
embeds: [createErrorEmbed(error.message)],
components: [],
flags: undefined,
});
} else {
throw error;
}
}
break;
}
case "use_back": {
// Return from use result to detail or list
if (!selectedItemId) break;
const entry = sorted.find(e => e.item.id === selectedItemId);
if (entry) {
await interaction.editReply(
getItemDetailMessage(entry, viewerId, ownerId)
);
} else {
selectedItemId = null;
if (sorted.length === 0) {
await interaction.editReply(getEmptyInventoryMessage(username));
} else {
await interaction.editReply(
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
);
}
}
break;
}
case "discard": {
if (viewerId !== ownerId || !selectedItemId) break;
const entry = sorted.find(e => e.item.id === selectedItemId);
if (!entry) break;
await interaction.editReply(
getDiscardConfirmMessage(entry, viewerId)
);
break;
}
case "discard_confirm": {
if (viewerId !== ownerId || !selectedItemId) break;
try {
await inventoryService.removeItem(ownerId, selectedItemId, 1n);
const freshEntries = await inventoryService.getInventory(ownerId);
const freshSorted = sortInventoryItems(freshEntries as InventoryEntry[]);
const freshEntry = freshSorted.find(e => e.item.id === selectedItemId);
if (freshEntry) {
await interaction.editReply(
getItemDetailMessage(freshEntry, viewerId, ownerId)
);
} else {
selectedItemId = null;
if (freshSorted.length === 0) {
await interaction.editReply(getEmptyInventoryMessage(username));
} else {
await interaction.editReply(
getInventoryListMessage(freshSorted, username, currentPage, viewerId, ownerId)
);
}
}
} catch (error) {
if (error instanceof UserError) {
await interaction.editReply({
embeds: [createErrorEmbed(error.message)],
components: [],
flags: undefined,
});
} else {
throw error;
}
}
break;
}
case "discard_cancel": {
if (!selectedItemId) break;
const entry = sorted.find(e => e.item.id === selectedItemId);
if (!entry) break;
await interaction.editReply(
getItemDetailMessage(entry, viewerId, ownerId)
);
break;
}
}
} catch (error) {
console.error("Inventory interaction error:", error);
}
});
collector.on("end", async () => {
try {
// Re-render current view as static (no interactive components)
const entries = await inventoryService.getInventory(ownerId);
const sorted = sortInventoryItems(entries as InventoryEntry[]);
if (selectedItemId) {
const entry = sorted.find(e => e.item.id === selectedItemId);
if (entry) {
// Show detail view without action buttons
const msg = getItemDetailMessage(entry, viewerId, ownerId);
// Replace components with empty to remove buttons but keep container content
await interaction.editReply(msg);
return;
}
}
if (sorted.length === 0) {
await interaction.editReply(getEmptyInventoryMessage(username));
} else {
await interaction.editReply(
getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId)
);
}
} catch {
// If re-rendering fails, at least try to clear gracefully
interaction.editReply({ components: [] }).catch(() => {});
}
});
}

View File

@@ -3,10 +3,9 @@ import { SlashCommandBuilder } from "discord.js";
import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { userService } from "@shared/modules/user/user.service";
import { createErrorEmbed } from "@lib/embeds";
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
import type { ItemUsageData } from "@shared/lib/types";
import { UserError } from "@shared/lib/errors";
import { config } from "@shared/lib/config";
import { getLootboxResultMessage } from "@/modules/inventory/inventory.view";
import { withCommandErrorHandling } from "@lib/commandUtils";
import { getGuildConfig } from "@shared/lib/config";
export const use = createCommand({
data: new SlashCommandBuilder()
@@ -19,54 +18,49 @@ 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 message = getLootboxResultMessage(result.results, result.item);
await interaction.editReply(message as any);
}
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

@@ -2,11 +2,12 @@ import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, MessageFlags } from "discord.js";
import { questService } from "@shared/modules/quest/quest.service";
import { createSuccessEmbed } from "@lib/embeds";
import {
getQuestListComponents,
getAvailableQuestsComponents,
getQuestActionRows
import {
getQuestListComponents,
getAvailableQuestsComponents,
getQuestActionRows
} from "@/modules/quest/quest.view";
import { QUEST_CUSTOM_IDS } from "@modules/quest/quest.types";
export const quests = createCommand({
data: new SlashCommandBuilder()
@@ -16,16 +17,24 @@ export const quests = createCommand({
const response = await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const userId = interaction.user.id;
let currentView: 'active' | 'available' = 'active';
let currentPage = 0;
const updateView = async (viewType: 'active' | 'available', page: number = 0) => {
currentView = viewType;
currentPage = page;
const updateView = async (viewType: 'active' | 'available') => {
const userQuests = await questService.getUserQuests(userId);
const availableQuests = await questService.getAvailableQuests(userId);
const containers = viewType === 'active'
? getQuestListComponents(userQuests)
: getAvailableQuestsComponents(availableQuests);
const activeQuests = userQuests.filter(entry => entry.completedAt === null);
const totalItems = viewType === 'active' ? activeQuests.length : availableQuests.length;
const actionRows = getQuestActionRows(viewType);
const containers = viewType === 'active'
? getQuestListComponents(userQuests, page)
: getAvailableQuestsComponents(availableQuests, page);
const actionRows = getQuestActionRows(viewType, totalItems, page);
await interaction.editReply({
content: null,
@@ -48,13 +57,19 @@ export const quests = createCommand({
if (i.user.id !== interaction.user.id) return;
try {
if (i.customId === "quest_view_active") {
if (i.customId === QUEST_CUSTOM_IDS.VIEW_ACTIVE) {
await i.deferUpdate();
await updateView('active');
} else if (i.customId === "quest_view_available") {
await updateView('active', 0);
} else if (i.customId === QUEST_CUSTOM_IDS.VIEW_AVAILABLE) {
await i.deferUpdate();
await updateView('available');
} else if (i.customId.startsWith("quest_accept:")) {
await updateView('available', 0);
} else if (i.customId === QUEST_CUSTOM_IDS.PAGE_PREV) {
await i.deferUpdate();
await updateView(currentView, Math.max(0, currentPage - 1));
} else if (i.customId === QUEST_CUSTOM_IDS.PAGE_NEXT) {
await i.deferUpdate();
await updateView(currentView, currentPage + 1);
} else if (i.customId.startsWith(QUEST_CUSTOM_IDS.ACCEPT_PREFIX)) {
const questIdStr = i.customId.split(":")[1];
if (!questIdStr) return;
const questId = parseInt(questIdStr);
@@ -65,7 +80,8 @@ export const quests = createCommand({
flags: MessageFlags.Ephemeral
});
await updateView('active');
// Stay on current view/page but refresh (accepted quest disappears from available)
await updateView(currentView, currentPage);
}
} catch (error) {
console.error("Quest interaction error:", error);

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

@@ -15,7 +15,7 @@ const event: Event<Events.MessageCreate> = {
levelingService.processChatXp(message.author.id);
// Activity Tracking for Lootdrops
import("@shared/modules/economy/lootdrop.service").then(m => m.lootdropService.processMessage(message));
import("@modules/economy/lootdrop.handler").then(m => m.processLootdropMessage(message));
},
};

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,14 @@
import { AuroraClient } from "@/lib/BotClient";
import { env } from "@shared/lib/env";
import { join } from "node:path";
import { initializeConfig } from "@shared/lib/config";
import { registerDomainEventListeners } from "@shared/lib/eventWiring";
import { createWebServer } from "../api/src/server";
import { startWebServerFromRoot } from "../web/src/server";
// Initialize config from database
await initializeConfig();
// Register domain event listeners before loading commands/events
registerDomainEventListeners();
// Load commands & events
await AuroraClient.loadCommands();
@@ -14,12 +20,11 @@ console.log("🌐 Starting web server...");
let shuttingDown = false;
const webProjectPath = join(import.meta.dir, "../web");
const webPort = Number(process.env.WEB_PORT) || 3000;
const webHost = process.env.HOST || "0.0.0.0";
// Start web server in the same process
const webServer = await startWebServerFromRoot(webProjectPath, {
const webServer = await createWebServer({
port: webPort,
hostname: webHost,
});
@@ -46,4 +51,4 @@ const shutdownHandler = async () => {
};
process.on("SIGINT", shutdownHandler);
process.on("SIGTERM", shutdownHandler);
process.on("SIGTERM", shutdownHandler);

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

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

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,6 +1,7 @@
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";
@@ -25,6 +26,37 @@ 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);

View File

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

View File

@@ -3,7 +3,7 @@ import { items } from "@db/schema";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import type { ItemUsageData, ItemEffect } from "@shared/lib/types";
import { getItemWizardEmbed, getItemTypeSelection, getEffectTypeSelection, getDetailsModal, getEconomyModal, getVisualsModal, getEffectConfigModal } from "./item_wizard.view";
import type { DraftItem } from "./item_wizard.types";
import { ITEM_WIZARD_CUSTOM_IDS, type DraftItem } from "./item_wizard.types";
import { ItemType, EffectType } from "@shared/lib/constants";
// --- Types ---
@@ -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: "",
@@ -41,13 +41,13 @@ export const renderWizard = (userId: string, isDraft = true) => {
export const handleItemWizardInteraction = async (interaction: Interaction) => {
// Only handle createitem interactions
if (!interaction.isButton() && !interaction.isStringSelectMenu() && !interaction.isModalSubmit()) return;
if (!interaction.customId.startsWith("createitem_")) return;
if (!interaction.customId.startsWith(ITEM_WIZARD_CUSTOM_IDS.PREFIX)) return;
const userId = interaction.user.id;
let draft = draftSession.get(userId);
// Special case for Cancel - doesn't need draft checks usually, but we want to clear it
if (interaction.customId === "createitem_cancel") {
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.CANCEL) {
draftSession.delete(userId);
if (interaction.isMessageComponent()) {
await interaction.update({ content: "❌ Item creation cancelled.", embeds: [], components: [] });
@@ -59,7 +59,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
if (!draft) {
if (interaction.isMessageComponent()) {
// Create one implicitly to prevent crashes, or warn user
if (interaction.customId === "createitem_start") {
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.START) {
// Allow start
} else {
await interaction.reply({ content: "⚠️ Session expired. Please run `/createitem` again.", ephemeral: true });
@@ -81,7 +81,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
// --- Routing ---
// 1. Details Modal
if (interaction.customId === "createitem_details") {
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.DETAILS) {
if (!interaction.isButton()) return;
const modal = getDetailsModal(draft);
await interaction.showModal(modal);
@@ -89,7 +89,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
}
// 2. Economy Modal
if (interaction.customId === "createitem_economy") {
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.ECONOMY) {
if (!interaction.isButton()) return;
const modal = getEconomyModal(draft);
await interaction.showModal(modal);
@@ -97,7 +97,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
}
// 3. Visuals Modal
if (interaction.customId === "createitem_visuals") {
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.VISUALS) {
if (!interaction.isButton()) return;
const modal = getVisualsModal(draft);
await interaction.showModal(modal);
@@ -105,14 +105,14 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
}
// 4. Type Toggle (Start Select Menu)
if (interaction.customId === "createitem_type_toggle") {
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.TYPE_TOGGLE) {
if (!interaction.isButton()) return;
const { components } = getItemTypeSelection();
await interaction.update({ components }); // Temporary view
return;
}
if (interaction.customId === "createitem_select_type") {
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.SELECT_TYPE) {
if (!interaction.isStringSelectMenu()) return;
const selected = interaction.values[0];
if (selected) {
@@ -125,14 +125,14 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
}
// 5. Add Effect Flow
if (interaction.customId === "createitem_addeffect_start") {
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.ADD_EFFECT_START) {
if (!interaction.isButton()) return;
const { components } = getEffectTypeSelection();
await interaction.update({ components });
return;
}
if (interaction.customId === "createitem_select_effect_type") {
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.SELECT_EFFECT_TYPE) {
if (!interaction.isStringSelectMenu()) return;
const effectType = interaction.values[0];
if (!effectType) return;
@@ -149,7 +149,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
}
// Toggle Consume
if (interaction.customId === "createitem_toggle_consume") {
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.TOGGLE_CONSUME) {
if (!interaction.isButton()) return;
draft.usageData.consume = !draft.usageData.consume;
const payload = renderWizard(userId);
@@ -159,43 +159,43 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
// 6. Handle Modal Submits
if (interaction.isModalSubmit()) {
if (interaction.customId === "createitem_modal_details") {
draft.name = interaction.fields.getTextInputValue("name");
draft.description = interaction.fields.getTextInputValue("desc");
draft.rarity = interaction.fields.getTextInputValue("rarity");
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.MODAL_DETAILS) {
draft.name = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_NAME);
draft.description = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_DESC);
draft.rarity = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_RARITY);
}
else if (interaction.customId === "createitem_modal_economy") {
const price = parseInt(interaction.fields.getTextInputValue("price"));
else if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.MODAL_ECONOMY) {
const price = parseInt(interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_PRICE));
draft.price = isNaN(price) || price === 0 ? null : price;
}
else if (interaction.customId === "createitem_modal_visuals") {
draft.iconUrl = interaction.fields.getTextInputValue("icon");
draft.imageUrl = interaction.fields.getTextInputValue("image");
else if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.MODAL_VISUALS) {
draft.iconUrl = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_ICON);
draft.imageUrl = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_IMAGE);
}
else if (interaction.customId === "createitem_modal_effect") {
else if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.MODAL_EFFECT) {
const type = draft.pendingEffectType;
if (type) {
let effect: ItemEffect | null = null;
if (type === EffectType.ADD_XP || type === EffectType.ADD_BALANCE) {
const amount = parseInt(interaction.fields.getTextInputValue("amount"));
const amount = parseInt(interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_AMOUNT));
if (!isNaN(amount)) effect = { type: type as any, amount };
}
else if (type === EffectType.REPLY_MESSAGE) {
effect = { type: EffectType.REPLY_MESSAGE, message: interaction.fields.getTextInputValue("message") };
effect = { type: EffectType.REPLY_MESSAGE, message: interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_MESSAGE) };
}
else if (type === EffectType.XP_BOOST) {
const multiplier = parseFloat(interaction.fields.getTextInputValue("multiplier"));
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
const multiplier = parseFloat(interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_MULTIPLIER));
const duration = parseInt(interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_DURATION));
if (!isNaN(multiplier) && !isNaN(duration)) effect = { type: EffectType.XP_BOOST, multiplier, durationSeconds: duration };
}
else if (type === EffectType.TEMP_ROLE) {
const roleId = interaction.fields.getTextInputValue("role_id");
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
const roleId = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_ROLE_ID);
const duration = parseInt(interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_DURATION));
if (roleId && !isNaN(duration)) effect = { type: EffectType.TEMP_ROLE, roleId: roleId, durationSeconds: duration };
}
else if (type === EffectType.COLOR_ROLE) {
const roleId = interaction.fields.getTextInputValue("role_id");
const roleId = interaction.fields.getTextInputValue(ITEM_WIZARD_CUSTOM_IDS.FIELD_ROLE_ID);
if (roleId) effect = { type: EffectType.COLOR_ROLE, roleId: roleId };
}
@@ -214,7 +214,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
}
// 7. Save
if (interaction.customId === "createitem_save") {
if (interaction.customId === ITEM_WIZARD_CUSTOM_IDS.SAVE) {
if (!interaction.isButton()) return;
await interaction.deferUpdate(); // Prepare to save

View File

@@ -1,5 +1,36 @@
import type { ItemUsageData } from "@shared/lib/types";
export const ITEM_WIZARD_CUSTOM_IDS = {
PREFIX: "createitem_",
START: "createitem_start",
DETAILS: "createitem_details",
ECONOMY: "createitem_economy",
VISUALS: "createitem_visuals",
TYPE_TOGGLE: "createitem_type_toggle",
SELECT_TYPE: "createitem_select_type",
ADD_EFFECT_START: "createitem_addeffect_start",
SELECT_EFFECT_TYPE: "createitem_select_effect_type",
TOGGLE_CONSUME: "createitem_toggle_consume",
SAVE: "createitem_save",
CANCEL: "createitem_cancel",
MODAL_DETAILS: "createitem_modal_details",
MODAL_ECONOMY: "createitem_modal_economy",
MODAL_VISUALS: "createitem_modal_visuals",
MODAL_EFFECT: "createitem_modal_effect",
// Modal field IDs
FIELD_NAME: "name",
FIELD_DESC: "desc",
FIELD_RARITY: "rarity",
FIELD_PRICE: "price",
FIELD_ICON: "icon",
FIELD_IMAGE: "image",
FIELD_AMOUNT: "amount",
FIELD_MESSAGE: "message",
FIELD_MULTIPLIER: "multiplier",
FIELD_DURATION: "duration",
FIELD_ROLE_ID: "role_id",
} as const;
export interface DraftItem {
name: string;
description: string;

View File

@@ -9,7 +9,7 @@ import {
type MessageActionRowComponentBuilder
} from "discord.js";
import { createBaseEmbed } from "@lib/embeds";
import type { DraftItem } from "./item_wizard.types";
import { ITEM_WIZARD_CUSTOM_IDS, type DraftItem } from "./item_wizard.types";
import { ItemType } from "@shared/lib/constants";
const getItemTypeOptions = () => [
@@ -51,18 +51,18 @@ export const getItemWizardEmbed = (draft: DraftItem) => {
// Components
const row1 = new ActionRowBuilder<MessageActionRowComponentBuilder>()
.addComponents(
new ButtonBuilder().setCustomId("createitem_details").setLabel("Edit Details").setStyle(ButtonStyle.Secondary).setEmoji("📝"),
new ButtonBuilder().setCustomId("createitem_economy").setLabel("Edit Economy").setStyle(ButtonStyle.Secondary).setEmoji("💰"),
new ButtonBuilder().setCustomId("createitem_visuals").setLabel("Edit Visuals").setStyle(ButtonStyle.Secondary).setEmoji("🖼️"),
new ButtonBuilder().setCustomId("createitem_type_toggle").setLabel("Change Type").setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.DETAILS).setLabel("Edit Details").setStyle(ButtonStyle.Secondary).setEmoji("📝"),
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.ECONOMY).setLabel("Edit Economy").setStyle(ButtonStyle.Secondary).setEmoji("💰"),
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.VISUALS).setLabel("Edit Visuals").setStyle(ButtonStyle.Secondary).setEmoji("🖼️"),
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.TYPE_TOGGLE).setLabel("Change Type").setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
);
const row2 = new ActionRowBuilder<MessageActionRowComponentBuilder>()
.addComponents(
new ButtonBuilder().setCustomId("createitem_addeffect_start").setLabel("Add Effect").setStyle(ButtonStyle.Primary).setEmoji("✨"),
new ButtonBuilder().setCustomId("createitem_toggle_consume").setLabel(`Consume: ${draft.usageData.consume ? "ON" : "OFF"}`).setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
new ButtonBuilder().setCustomId("createitem_save").setLabel("Save Item").setStyle(ButtonStyle.Success).setEmoji("💾"),
new ButtonBuilder().setCustomId("createitem_cancel").setLabel("Cancel").setStyle(ButtonStyle.Danger).setEmoji("✖️")
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.ADD_EFFECT_START).setLabel("Add Effect").setStyle(ButtonStyle.Primary).setEmoji("✨"),
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.TOGGLE_CONSUME).setLabel(`Consume: ${draft.usageData.consume ? "ON" : "OFF"}`).setStyle(ButtonStyle.Secondary).setEmoji("🔄"),
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.SAVE).setLabel("Save Item").setStyle(ButtonStyle.Success).setEmoji("💾"),
new ButtonBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.CANCEL).setLabel("Cancel").setStyle(ButtonStyle.Danger).setEmoji("✖️")
);
return { embeds: [embed], components: [row1, row2] };
@@ -70,65 +70,65 @@ export const getItemWizardEmbed = (draft: DraftItem) => {
export const getItemTypeSelection = () => {
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
new StringSelectMenuBuilder().setCustomId("createitem_select_type").setPlaceholder("Select Item Type").addOptions(getItemTypeOptions())
new StringSelectMenuBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.SELECT_TYPE).setPlaceholder("Select Item Type").addOptions(getItemTypeOptions())
);
return { components: [row] };
};
export const getEffectTypeSelection = () => {
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
new StringSelectMenuBuilder().setCustomId("createitem_select_effect_type").setPlaceholder("Select Effect Type").addOptions(getEffectTypeOptions())
new StringSelectMenuBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.SELECT_EFFECT_TYPE).setPlaceholder("Select Effect Type").addOptions(getEffectTypeOptions())
);
return { components: [row] };
};
export const getDetailsModal = (current: DraftItem) => {
const modal = new ModalBuilder().setCustomId("createitem_modal_details").setTitle("Edit Details");
const modal = new ModalBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.MODAL_DETAILS).setTitle("Edit Details");
modal.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("name").setLabel("Name").setValue(current.name).setStyle(TextInputStyle.Short).setRequired(true)),
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("desc").setLabel("Description").setValue(current.description).setStyle(TextInputStyle.Paragraph).setRequired(false)),
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("rarity").setLabel("Rarity").setValue(current.rarity).setStyle(TextInputStyle.Short).setPlaceholder("Common, Rare, Legendary...").setRequired(true))
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_NAME).setLabel("Name").setValue(current.name).setStyle(TextInputStyle.Short).setRequired(true)),
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_DESC).setLabel("Description").setValue(current.description).setStyle(TextInputStyle.Paragraph).setRequired(false)),
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_RARITY).setLabel("Rarity").setValue(current.rarity).setStyle(TextInputStyle.Short).setPlaceholder("C, R, SR, SSR").setRequired(true))
);
return modal;
};
export const getEconomyModal = (current: DraftItem) => {
const modal = new ModalBuilder().setCustomId("createitem_modal_economy").setTitle("Edit Economy");
const modal = new ModalBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.MODAL_ECONOMY).setTitle("Edit Economy");
modal.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("price").setLabel("Price (0 for not for sale)").setValue(current.price?.toString() || "0").setStyle(TextInputStyle.Short).setRequired(true))
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_PRICE).setLabel("Price (0 for not for sale)").setValue(current.price?.toString() || "0").setStyle(TextInputStyle.Short).setRequired(true))
);
return modal;
};
export const getVisualsModal = (current: DraftItem) => {
const modal = new ModalBuilder().setCustomId("createitem_modal_visuals").setTitle("Edit Visuals");
const modal = new ModalBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.MODAL_VISUALS).setTitle("Edit Visuals");
modal.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("icon").setLabel("Icon URL (Emoji or Link)").setValue(current.iconUrl).setStyle(TextInputStyle.Short).setRequired(false)),
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("image").setLabel("Image URL").setValue(current.imageUrl).setStyle(TextInputStyle.Short).setRequired(false))
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_ICON).setLabel("Icon URL (Emoji or Link)").setValue(current.iconUrl).setStyle(TextInputStyle.Short).setRequired(false)),
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_IMAGE).setLabel("Image URL").setValue(current.imageUrl).setStyle(TextInputStyle.Short).setRequired(false))
);
return modal;
};
export const getEffectConfigModal = (effectType: string) => {
let modal = new ModalBuilder().setCustomId("createitem_modal_effect").setTitle(`Config ${effectType}`);
let modal = new ModalBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.MODAL_EFFECT).setTitle(`Config ${effectType}`);
if (effectType === "ADD_XP" || effectType === "ADD_BALANCE") {
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("amount").setLabel("Amount").setStyle(TextInputStyle.Short).setRequired(true).setPlaceholder("100")));
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_AMOUNT).setLabel("Amount").setStyle(TextInputStyle.Short).setRequired(true).setPlaceholder("100")));
} else if (effectType === "REPLY_MESSAGE") {
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("message").setLabel("Message").setStyle(TextInputStyle.Paragraph).setRequired(true)));
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_MESSAGE).setLabel("Message").setStyle(TextInputStyle.Paragraph).setRequired(true)));
} else if (effectType === "XP_BOOST") {
modal.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("multiplier").setLabel("Multiplier (e.g. 1.5)").setStyle(TextInputStyle.Short).setRequired(true)),
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("duration").setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_MULTIPLIER).setLabel("Multiplier (e.g. 1.5)").setStyle(TextInputStyle.Short).setRequired(true)),
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_DURATION).setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
);
} else if (effectType === "TEMP_ROLE") {
modal.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("role_id").setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true)),
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("duration").setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_ROLE_ID).setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true)),
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_DURATION).setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
);
} else if (effectType === "COLOR_ROLE") {
modal.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("role_id").setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true))
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId(ITEM_WIZARD_CUSTOM_IDS.FIELD_ROLE_ID).setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true))
);
}
return modal;

View File

@@ -1,35 +0,0 @@
export interface RestartContext {
channelId: string;
userId: string;
timestamp: number;
runMigrations: boolean;
installDependencies: boolean;
buildWebAssets: boolean;
previousCommit: string;
newCommit: string;
}
export interface UpdateCheckResult {
needsRootInstall: boolean;
needsWebInstall: boolean;
needsWebBuild: 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,356 +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, needsWebBuild, 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 (needsWebBuild) reqs.push("🏗️ Build web dashboard");
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.needsWebBuild) {
steps.push("🏗️ Web dashboard will be rebuilt 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;
webBuildSuccess: boolean;
webBuildOutput: string;
migrationSuccess: boolean;
migrationOutput: string;
ranInstall: boolean;
ranWebBuild: boolean;
ranMigrations: boolean;
previousCommit?: string;
newCommit?: string;
}
export function getPostRestartEmbed(result: PostRestartResult, hasRollback: boolean) {
const isSuccess = result.installSuccess && result.webBuildSuccess && 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.ranWebBuild) {
results.push(result.webBuildSuccess
? "✅ Web dashboard built"
: "❌ Web dashboard build 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.webBuildOutput && !result.webBuildSuccess) {
embed.addFields({
name: "Web Build Output",
value: `\`\`\`\n${truncate(result.webBuildOutput, 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 getBuildingWebEmbed() {
return createInfoEmbed(
"🌐 Building web dashboard assets...\nThis may take a moment.",
"⏳ Building Web Dashboard"
);
}
export interface PostRestartProgress {
installDeps: boolean;
buildWeb: boolean;
runMigrations: boolean;
currentStep: "starting" | "install" | "build" | "migrate" | "done";
installDone?: boolean;
buildDone?: boolean;
migrateDone?: boolean;
}
export function getPostRestartProgressEmbed(progress: PostRestartProgress) {
const steps: string[] = [];
// Installation step
if (progress.installDeps) {
if (progress.currentStep === "install") {
steps.push("⏳ Installing dependencies...");
} else if (progress.installDone) {
steps.push("✅ Dependencies installed");
} else {
steps.push("⬚ Install dependencies");
}
}
// Web build step
if (progress.buildWeb) {
if (progress.currentStep === "build") {
steps.push("⏳ Building web dashboard...");
} else if (progress.buildDone) {
steps.push("✅ Web dashboard built");
} else {
steps.push("⬚ Build web dashboard");
}
}
// Migrations step
if (progress.runMigrations) {
if (progress.currentStep === "migrate") {
steps.push("⏳ Running migrations...");
} else if (progress.migrateDone) {
steps.push("✅ Migrations applied");
} else {
steps.push("⬚ Run migrations");
}
}
if (steps.length === 0) {
steps.push("⚡ Quick restart (no extra steps needed)");
}
return createInfoEmbed(steps.join("\n"), "🔄 Post-Update Tasks");
}
export function getRollbackSuccessEmbed(commit: string) {
return createSuccessEmbed(
`Successfully rolled back to commit \`${commit}\`.\nThe bot will restart now.`,
"↩️ Rollback Complete"
);
}
export function getRollbackFailedEmbed(error: string) {
return createErrorEmbed(
`Could not rollback:\n\`\`\`\n${error}\n\`\`\``,
"❌ Rollback Failed"
);
}

View File

@@ -0,0 +1,10 @@
export const LOOTDROP_CUSTOM_IDS = {
PREFIX: "lootdrop_",
CLAIM: "lootdrop_claim",
CLAIM_DISABLED: "lootdrop_claim_disabled",
} as const;
export const SHOP_CUSTOM_IDS = {
BUY_PREFIX: "shop_buy_",
BUY: (itemId: number) => `shop_buy_${itemId}`,
} as const;

View File

@@ -0,0 +1,60 @@
import { Message, TextChannel } from "discord.js";
import { lootdropService } from "@shared/modules/economy/lootdrop.service";
import { getLootdropMessage } from "./lootdrop.view";
import { terminalService } from "@modules/system/terminal.service";
/**
* Process a Discord message for lootdrop activity tracking.
* Called from messageCreate event handler.
*/
export async function processLootdropMessage(message: Message): Promise<void> {
if (message.author.bot || !message.guild) return;
const { shouldSpawn } = lootdropService.trackActivity(message.channel.id);
if (shouldSpawn) {
await spawnLootdrop(message.channel as TextChannel);
}
}
/**
* Spawn a lootdrop in a Discord channel.
* Used by both bot events and API routes.
*/
export async function spawnLootdrop(
channel: TextChannel,
overrideReward?: number,
overrideCurrency?: string
): Promise<void> {
const { reward, currency } = lootdropService.calculateReward(overrideReward, overrideCurrency);
const { content, files, components } = await getLootdropMessage(reward, currency);
try {
const sentMessage = await channel.send({ content, files, components });
await lootdropService.persistLootdrop(sentMessage.id, channel.id, reward, currency);
terminalService.update(channel.guildId);
} catch (error) {
console.error("Failed to spawn lootdrop:", error);
}
}
/**
* Delete a lootdrop from DB and Discord.
*/
export async function deleteLootdrop(messageId: string): Promise<boolean> {
const result = await lootdropService.removeLootdrop(messageId);
if (!result) return false;
try {
const { AuroraClient } = await import("@/lib/BotClient");
const channel = await AuroraClient.channels.fetch(result.channelId) as TextChannel;
if (channel) {
const message = await channel.messages.fetch(messageId);
if (message) await message.delete();
}
} catch (e) {
console.warn("Could not delete lootdrop message from Discord:", e);
}
return true;
}

View File

@@ -2,9 +2,11 @@ import { ButtonInteraction } from "discord.js";
import { lootdropService } from "@shared/modules/economy/lootdrop.service";
import { UserError } from "@shared/lib/errors";
import { getLootdropClaimedMessage } from "./lootdrop.view";
import { terminalService } from "@modules/system/terminal.service";
import { LOOTDROP_CUSTOM_IDS } from "./economy.types";
export async function handleLootdropInteraction(interaction: ButtonInteraction) {
if (interaction.customId === "lootdrop_claim") {
if (interaction.customId === LOOTDROP_CUSTOM_IDS.CLAIM) {
await interaction.deferReply({ ephemeral: true });
const result = await lootdropService.tryClaim(interaction.message.id, interaction.user.id, interaction.user.username);
@@ -13,6 +15,9 @@ export async function handleLootdropInteraction(interaction: ButtonInteraction)
throw new UserError(result.error || "Failed to claim.");
}
// Update terminal display after successful claim
terminalService.update();
await interaction.editReply({
content: `🎉 You successfully claimed **${result.amount} ${result.currency}**!`
});

View File

@@ -1,12 +1,13 @@
import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
import { generateLootdropCard, generateClaimedLootdropCard } from "@/graphics/lootdrop";
import { LOOTDROP_CUSTOM_IDS } from "./economy.types";
export async function getLootdropMessage(reward: number, currency: string) {
const cardBuffer = await generateLootdropCard(reward, currency);
const attachment = new AttachmentBuilder(cardBuffer, { name: "lootdrop.png" });
const claimButton = new ButtonBuilder()
.setCustomId("lootdrop_claim")
.setCustomId(LOOTDROP_CUSTOM_IDS.CLAIM)
.setLabel("CLAIM REWARD")
.setStyle(ButtonStyle.Secondary) // Changed to Secondary to fit the darker theme better? Or keep Success? Let's try Secondary with custom emoji
.setEmoji("🌠");
@@ -28,7 +29,7 @@ export async function getLootdropClaimedMessage(userId: string, username: string
const newRow = new ActionRowBuilder<ButtonBuilder>()
.addComponents(
new ButtonBuilder()
.setCustomId("lootdrop_claim_disabled")
.setCustomId(LOOTDROP_CUSTOM_IDS.CLAIM_DISABLED)
.setLabel("CLAIMED")
.setStyle(ButtonStyle.Secondary)
.setEmoji("✅")

View File

@@ -2,13 +2,14 @@ import { ButtonInteraction, MessageFlags } from "discord.js";
import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { userService } from "@shared/modules/user/user.service";
import { UserError } from "@shared/lib/errors";
import { SHOP_CUSTOM_IDS } from "./economy.types";
export async function handleShopInteraction(interaction: ButtonInteraction) {
if (!interaction.customId.startsWith("shop_buy_")) return;
if (!interaction.customId.startsWith(SHOP_CUSTOM_IDS.BUY_PREFIX)) return;
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const itemId = parseInt(interaction.customId.replace("shop_buy_", ""));
const itemId = parseInt(interaction.customId.replace(SHOP_CUSTOM_IDS.BUY_PREFIX, ""));
if (isNaN(itemId)) {
throw new UserError("Invalid Item ID.");
}

View File

@@ -1,20 +1,216 @@
import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
import { createBaseEmbed } from "@/lib/embeds";
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
AttachmentBuilder,
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";
import { getRarityConfig, defaultName, stripQuery } from "@shared/lib/rarity";
import { SHOP_CUSTOM_IDS } from "./economy.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." });
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", stripQuery(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", stripQuery(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(getRarityConfig(item.rarity || "C").color);
// 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))
)
);
}
// Create buy button (used in either main or loot container)
const buyButton = new ButtonBuilder()
.setCustomId(`shop_buy_${item.id}`)
.setLabel(`Buy for ${item.price} 🪙`)
.setCustomId(SHOP_CUSTOM_IDS.BUY(item.id))
.setLabel(`Purchase for ${item.price} 🪙`)
.setStyle(ButtonStyle.Success)
.setEmoji("🛒");
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton);
// 2. Loot Table (if applicable) — separate Container with blurple accent
const lootboxEffect = item.usageData?.effects?.find((e: any) => e.type === EffectType.LOOTBOX);
if (lootboxEffect) {
const pool = lootboxEffect.pool as LootTableItem[];
const totalWeight = pool.reduce((sum: number, i: LootTableItem) => sum + i.weight, 0);
return { embeds: [embed], components: [row] };
const lootContainer = new ContainerBuilder().setAccentColor(0x5865F2);
lootContainer.addTextDisplayComponents(
new TextDisplayBuilder().setContent("## 🎁 Loot Table")
);
// Group drops by rarity tier with aggregated percentages
const tiers: Record<string, { items: string[]; totalChance: number }> = {};
for (const drop of pool) {
const chance = (drop.weight / totalWeight) * 100;
let line = "";
let rarity = "C";
switch (drop.type as any) {
case LootType.CURRENCY: {
const amt = (drop.minAmount != null && drop.maxAmount != null)
? `${drop.minAmount} ${drop.maxAmount}`
: (Array.isArray(drop.amount) ? `${drop.amount[0]} ${drop.amount[1]}` : `${drop.amount || 0}`);
line = `${amt} 🪙`;
rarity = "CURRENCY";
break;
}
case LootType.XP: {
const amt = (drop.minAmount != null && drop.maxAmount != null)
? `${drop.minAmount} ${drop.maxAmount}`
: (Array.isArray(drop.amount) ? `${drop.amount[0]} ${drop.amount[1]}` : `${drop.amount || 0}`);
line = `${amt} XP`;
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} ×${drop.amount || 1}`;
rarity = i.rarity;
} else {
line = `Unknown Item`;
rarity = "C";
}
break;
}
case LootType.NOTHING: {
line = "Nothing";
rarity = "NOTHING";
break;
}
}
if (line) {
if (!tiers[rarity]) tiers[rarity] = { items: [], totalChance: 0 };
const tier = tiers[rarity]!;
tier.items.push(line);
tier.totalChance += chance;
}
}
const order = ["SSR", "SR", "R", "C", "CURRENCY", "XP", "NOTHING"];
let isFirst = true;
for (const rarity of order) {
const tier = tiers[rarity];
if (!tier || tier.items.length === 0) continue;
if (!isFirst) {
lootContainer.addSeparatorComponents(
new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)
);
}
isFirst = false;
const config = getRarityConfig(rarity);
const chanceStr = tier.totalChance < 0.1 ? "<0.1" : tier.totalChance.toFixed(1);
lootContainer.addTextDisplayComponents(
new TextDisplayBuilder().setContent(
`${config.emoji} **${config.label}** — ${chanceStr}%`
),
new TextDisplayBuilder().setContent(tier.items.join(", "))
);
}
// Purchase button inside loot table container
lootContainer.addActionRowComponents(
new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton)
);
containers.push(mainContainer);
containers.push(lootContainer);
} else {
// Non-lootbox items: purchase button stays in main container
mainContainer.addActionRowComponents(
new ActionRowBuilder<ButtonBuilder>().addComponents(buyButton)
);
containers.push(mainContainer);
}
return {
components: containers as any,
files,
flags: MessageFlags.IsComponentsV2
};
}

View File

@@ -1,6 +1,6 @@
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";
@@ -8,7 +8,7 @@ import { UserError } from "@shared/lib/errors";
export const handleFeedbackInteraction = async (interaction: Interaction) => {
// Handle select menu for choosing feedback type
if (interaction.isStringSelectMenu() && interaction.customId === "feedback_select_type") {
if (interaction.isStringSelectMenu() && interaction.customId === FEEDBACK_CUSTOM_IDS.SELECT_TYPE) {
const feedbackType = interaction.values[0] as FeedbackType;
if (!feedbackType) {
@@ -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

@@ -16,8 +16,10 @@ export const FEEDBACK_TYPE_LABELS: Record<FeedbackType, string> = {
};
export const FEEDBACK_CUSTOM_IDS = {
PREFIX: "feedback_",
SELECT_TYPE: "feedback_select_type",
MODAL: "feedback_modal",
TYPE_FIELD: "feedback_type",
TITLE_FIELD: "feedback_title",
DESCRIPTION_FIELD: "feedback_description"
DESCRIPTION_FIELD: "feedback_description",
} as const;

View File

@@ -14,7 +14,7 @@ import { FEEDBACK_TYPE_LABELS, FEEDBACK_CUSTOM_IDS, type FeedbackData, type Feed
export function getFeedbackTypeMenu() {
const select = new StringSelectMenuBuilder()
.setCustomId("feedback_select_type")
.setCustomId(FEEDBACK_CUSTOM_IDS.SELECT_TYPE)
.setPlaceholder("Choose feedback type")
.addOptions([
{

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

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