30 Commits

Author SHA1 Message Date
syntaxbullet
9804456257 docs: Remove completed and draft feature tickets from the tickets directory. 2026-01-07 13:49:04 +01:00
syntaxbullet
259b8d6875 feat: replace mock dashboard data with live telemetry 2026-01-07 13:47:02 +01:00
syntaxbullet
a2cb684b71 Merge branch 'feat/web-interface-expansion-mockup' into main 2026-01-07 13:39:41 +01:00
syntaxbullet
9c2098bc46 fix(test): use dynamic port for websocket tests 2026-01-07 13:37:21 +01:00
syntaxbullet
618d973863 feat: expansion of web dashboard with live activity feed and metrics 2026-01-07 13:34:29 +01:00
syntaxbullet
63f55b6dfd feat: implement dashboard mockup and route 2026-01-07 13:29:06 +01:00
syntaxbullet
ac4025e179 feat: implement websocket realtime data streaming 2026-01-07 13:25:41 +01:00
syntaxbullet
ff23f22337 feat: move status to footer and clean up home page 2026-01-07 13:21:36 +01:00
syntaxbullet
292991c605 feat: responsive mobile layout and touch optimizations 2026-01-07 13:08:02 +01:00
syntaxbullet
4640cd11a7 feat: ux enhancements (animations, dynamic backgrounds, micro-interactions) 2026-01-07 13:05:42 +01:00
syntaxbullet
43a003f641 feat: visual design system overhaul (HSL palette, fonts, components) 2026-01-07 13:04:40 +01:00
syntaxbullet
6f4426e49d feat: save progress on web server foundation and add new tickets 2026-01-07 13:02:36 +01:00
syntaxbullet
894cad91a8 feat: Implement secure static file serving with path traversal protection and XSS prevention for template titles. 2026-01-07 12:51:08 +01:00
syntaxbullet
2a1c4e65ae feat(web): implement web server foundation 2026-01-07 12:40:21 +01:00
syntaxbullet
022f748517 feat: implement agent workflows for ticket creation, development, and code review. 2026-01-07 12:12:57 +01:00
syntaxbullet
ca392749e3 refactor: replace cleanup service with focused temp role service and fix daily streaks 2026-01-07 11:04:34 +01:00
syntaxbullet
4a1e72c5f3 chore: add additional stats to terminal 2026-01-06 21:05:51 +01:00
syntaxbullet
d29a1ec2b7 chore: update terminal adding nicer graphics 2026-01-06 20:51:39 +01:00
syntaxbullet
1dd269bf2f chore: update terminal service 2026-01-06 20:36:26 +01:00
syntaxbullet
69186ff3e9 chore: add more options to cleanup command 2026-01-06 19:44:18 +01:00
syntaxbullet
b989e807dc feat: Add /cleanup admin command and enhance lootdrop cleanup service to optionally include claimed items. 2026-01-06 19:27:41 +01:00
syntaxbullet
2e6bdec38c refactor: switch Drizzle ORM from postgres-js to bun-sql driver. 2026-01-06 18:52:25 +01:00
syntaxbullet
a9d5c806ad feat: Migrate Drizzle ORM to postgres.js, exclude test files from command loading, and adjust postgres dependency type. 2026-01-06 18:46:30 +01:00
syntaxbullet
6f73178375 feat: Bind docker-compose database and server ports to localhost. 2026-01-06 18:37:42 +01:00
syntaxbullet
dd62336571 fix(test): resolve typescript undefined errors in inventory service tests 2026-01-06 18:25:18 +01:00
syntaxbullet
8280111b66 feat(inventory): implement item name autocomplete with rarity and case-insensitive search 2026-01-06 18:24:15 +01:00
syntaxbullet
34347f0c63 feat: centralized constants and enums for project-wide use 2026-01-06 18:15:52 +01:00
syntaxbullet
c807fd4fd0 test: fix lint errors in moderation service tests 2026-01-06 18:05:05 +01:00
syntaxbullet
47b980eff1 feat: add moderation unit tests and refactor warning logic 2026-01-06 18:03:36 +01:00
syntaxbullet
bc89ddf7c0 feat: implement scheduled cleanup job for expired data 2026-01-06 17:44:08 +01:00
54 changed files with 2532 additions and 304 deletions

View File

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

View File

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

View File

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

2
.gitignore vendored
View File

@@ -44,4 +44,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
src/db/data src/db/data
src/db/log src/db/log
scratchpad/ scratchpad/
tickets/

View File

@@ -7,7 +7,7 @@ services:
- POSTGRES_PASSWORD=${DB_PASSWORD} - POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=${DB_NAME} - POSTGRES_DB=${DB_NAME}
ports: ports:
- "${DB_PORT}:5432" - "127.0.0.1:${DB_PORT}:5432"
volumes: volumes:
- ./src/db/data:/var/lib/postgresql/data - ./src/db/data:/var/lib/postgresql/data
- ./src/db/log:/var/log/postgresql - ./src/db/log:/var/log/postgresql
@@ -46,7 +46,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
working_dir: /app working_dir: /app
ports: ports:
- "4983:4983" - "127.0.0.1:4983:4983"
volumes: volumes:
- .:/app - .:/app
- /app/node_modules - /app/node_modules

View File

@@ -5,8 +5,7 @@
"private": true, "private": true,
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"drizzle-kit": "^0.31.7", "drizzle-kit": "^0.31.7"
"postgres": "^3.4.7"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5" "typescript": "^5"
@@ -26,6 +25,7 @@
"discord.js": "^14.25.1", "discord.js": "^14.25.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"drizzle-orm": "^0.44.7", "drizzle-orm": "^0.44.7",
"postgres": "^3.4.7",
"zod": "^4.1.13" "zod": "^4.1.13"
} }
} }

View File

@@ -1,6 +1,7 @@
import { createCommand } from "@/lib/utils"; import { createCommand } from "@/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ModerationService } from "@/modules/moderation/moderation.service"; import { ModerationService } from "@/modules/moderation/moderation.service";
import { CaseType } from "@/lib/constants";
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view"; import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
export const note = createCommand({ export const note = createCommand({
@@ -31,7 +32,7 @@ export const note = createCommand({
// Create the note case // Create the note case
const moderationCase = await ModerationService.createCase({ const moderationCase = await ModerationService.createCase({
type: 'note', type: CaseType.NOTE,
userId: targetUser.id, userId: targetUser.id,
username: targetUser.username, username: targetUser.username,
moderatorId: interaction.user.id, moderatorId: interaction.user.id,

View File

@@ -50,65 +50,25 @@ export const warn = createCommand({
return; return;
} }
// Create the warning case // Issue the warning via service
const moderationCase = await ModerationService.createCase({ const { moderationCase, warningCount, autoTimeoutIssued } = await ModerationService.issueWarning({
type: 'warn',
userId: targetUser.id, userId: targetUser.id,
username: targetUser.username, username: targetUser.username,
moderatorId: interaction.user.id, moderatorId: interaction.user.id,
moderatorName: interaction.user.username, moderatorName: interaction.user.username,
reason, reason,
guildName: interaction.guild?.name || undefined,
dmTarget: targetUser,
timeoutTarget: await interaction.guild?.members.fetch(targetUser.id)
}); });
if (!moderationCase) {
await interaction.editReply({
embeds: [getModerationErrorEmbed("Failed to create warning case.")]
});
return;
}
// Get total warning count for the user
const warningCount = await ModerationService.getActiveWarningCount(targetUser.id);
// Send success message to moderator // Send success message to moderator
await interaction.editReply({ await interaction.editReply({
embeds: [getWarnSuccessEmbed(moderationCase.caseId, targetUser.username, reason)] embeds: [getWarnSuccessEmbed(moderationCase.caseId, targetUser.username, reason)]
}); });
// Try to DM the user if configured // Follow up if auto-timeout was issued
if (config.moderation.cases.dmOnWarn) { if (autoTimeoutIssued) {
try {
const serverName = interaction.guild?.name || 'this server';
await targetUser.send({
embeds: [getUserWarningEmbed(serverName, reason, moderationCase.caseId, warningCount)]
});
} catch (error) {
// Silently fail if user has DMs disabled
console.log(`Could not DM warning to ${targetUser.username}: ${error}`);
}
}
// Optional: Check for auto-timeout threshold
if (config.moderation.cases.autoTimeoutThreshold &&
warningCount >= config.moderation.cases.autoTimeoutThreshold) {
try {
const member = await interaction.guild?.members.fetch(targetUser.id);
if (member) {
// Auto-timeout for 24 hours (86400000 ms)
await member.timeout(86400000, `Automatic timeout: ${warningCount} warnings`);
// Create a timeout case
await ModerationService.createCase({
type: 'timeout',
userId: targetUser.id,
username: targetUser.username,
moderatorId: interaction.client.user!.id,
moderatorName: interaction.client.user!.username,
reason: `Automatic timeout: reached ${warningCount} warnings`,
metadata: { duration: '24h', automatic: true }
});
await interaction.followUp({ await interaction.followUp({
embeds: [getModerationErrorEmbed( embeds: [getModerationErrorEmbed(
`⚠️ User has reached ${warningCount} warnings and has been automatically timed out for 24 hours.` `⚠️ User has reached ${warningCount} warnings and has been automatically timed out for 24 hours.`
@@ -116,10 +76,6 @@ export const warn = createCommand({
flags: MessageFlags.Ephemeral flags: MessageFlags.Ephemeral
}); });
} }
} catch (error) {
console.error('Failed to auto-timeout user:', error);
}
}
} catch (error) { } catch (error) {
console.error("Warn command error:", error); console.error("Warn command error:", error);

View File

@@ -7,8 +7,9 @@ import { userTimers, users } from "@/db/schema";
import { eq, and, sql } from "drizzle-orm"; import { eq, and, sql } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient"; import { DrizzleClient } from "@/lib/DrizzleClient";
import { config } from "@lib/config"; import { config } from "@lib/config";
import { TimerType } from "@/lib/constants";
const EXAM_TIMER_TYPE = 'EXAM_SYSTEM'; const EXAM_TIMER_TYPE = TimerType.EXAM_SYSTEM;
const EXAM_TIMER_KEY = 'default'; const EXAM_TIMER_KEY = 'default';
interface ExamMetadata { interface ExamMetadata {

View File

@@ -4,9 +4,6 @@ import { inventoryService } from "@/modules/inventory/inventory.service";
import { userService } from "@/modules/user/user.service"; import { userService } from "@/modules/user/user.service";
import { createErrorEmbed } from "@lib/embeds"; import { createErrorEmbed } from "@lib/embeds";
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view"; import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
import { inventory, items } from "@/db/schema";
import { eq, and, like } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient";
import type { ItemUsageData } from "@/lib/types"; import type { ItemUsageData } from "@/lib/types";
import { UserError } from "@/lib/errors"; import { UserError } from "@/lib/errors";
import { config } from "@/lib/config"; import { config } from "@/lib/config";
@@ -75,28 +72,8 @@ export const use = createCommand({
const focusedValue = interaction.options.getFocused(); const focusedValue = interaction.options.getFocused();
const userId = interaction.user.id; const userId = interaction.user.id;
// Fetch owned items that match the search query const results = await inventoryService.getAutocompleteItems(userId, focusedValue);
// We join with items table to filter by name directly in the database
const entries = await DrizzleClient.select({
quantity: inventory.quantity,
item: items
})
.from(inventory)
.innerJoin(items, eq(inventory.itemId, items.id))
.where(and(
eq(inventory.userId, BigInt(userId)),
like(items.name, `%${focusedValue}%`)
))
.limit(20); // Fetch up to 20 matching items
const filtered = entries.filter(entry => { await interaction.respond(results);
const usageData = entry.item.usageData as ItemUsageData | null;
const isUsable = usageData && usageData.effects && usageData.effects.length > 0;
return isUsable;
});
await interaction.respond(
filtered.map(entry => ({ name: `${entry.item.name} (${entry.quantity})`, value: entry.item.id }))
);
} }
}); });

View File

@@ -7,7 +7,7 @@ describe("Database Indexes", () => {
SELECT indexname FROM pg_indexes SELECT indexname FROM pg_indexes
WHERE tablename = 'users' WHERE tablename = 'users'
`; `;
const indexNames = result.map(r => r.indexname); const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
expect(indexNames).toContain("users_balance_idx"); expect(indexNames).toContain("users_balance_idx");
expect(indexNames).toContain("users_level_xp_idx"); expect(indexNames).toContain("users_level_xp_idx");
}); });
@@ -17,7 +17,7 @@ describe("Database Indexes", () => {
SELECT indexname FROM pg_indexes SELECT indexname FROM pg_indexes
WHERE tablename = 'transactions' WHERE tablename = 'transactions'
`; `;
const indexNames = result.map(r => r.indexname); const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
expect(indexNames).toContain("transactions_created_at_idx"); expect(indexNames).toContain("transactions_created_at_idx");
}); });
@@ -26,7 +26,7 @@ describe("Database Indexes", () => {
SELECT indexname FROM pg_indexes SELECT indexname FROM pg_indexes
WHERE tablename = 'moderation_cases' WHERE tablename = 'moderation_cases'
`; `;
const indexNames = result.map(r => r.indexname); const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
expect(indexNames).toContain("moderation_cases_user_id_idx"); expect(indexNames).toContain("moderation_cases_user_id_idx");
expect(indexNames).toContain("moderation_cases_case_id_idx"); expect(indexNames).toContain("moderation_cases_case_id_idx");
}); });
@@ -36,7 +36,7 @@ describe("Database Indexes", () => {
SELECT indexname FROM pg_indexes SELECT indexname FROM pg_indexes
WHERE tablename = 'user_timers' WHERE tablename = 'user_timers'
`; `;
const indexNames = result.map(r => r.indexname); const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
expect(indexNames).toContain("user_timers_expires_at_idx"); expect(indexNames).toContain("user_timers_expires_at_idx");
expect(indexNames).toContain("user_timers_lookup_idx"); expect(indexNames).toContain("user_timers_lookup_idx");
}); });

View File

@@ -1,11 +1,14 @@
import { AuroraClient } from "@/lib/BotClient"; import { AuroraClient } from "@/lib/BotClient";
import { env } from "@lib/env"; import { env } from "@lib/env";
import { WebServer } from "@/web/server";
// Load commands & events // Load commands & events
await AuroraClient.loadCommands(); await AuroraClient.loadCommands();
await AuroraClient.loadEvents(); await AuroraClient.loadEvents();
await AuroraClient.deployCommands(); await AuroraClient.deployCommands();
WebServer.start();
// login with the token from .env // login with the token from .env
if (!env.DISCORD_BOT_TOKEN) { if (!env.DISCORD_BOT_TOKEN) {
@@ -14,5 +17,10 @@ if (!env.DISCORD_BOT_TOKEN) {
AuroraClient.login(env.DISCORD_BOT_TOKEN); AuroraClient.login(env.DISCORD_BOT_TOKEN);
// Handle graceful shutdown // Handle graceful shutdown
process.on("SIGINT", () => AuroraClient.shutdown()); const shutdownHandler = () => {
process.on("SIGTERM", () => AuroraClient.shutdown()); WebServer.stop();
AuroraClient.shutdown();
};
process.on("SIGINT", shutdownHandler);
process.on("SIGTERM", shutdownHandler);

View File

@@ -69,6 +69,7 @@ export interface GameConfigType {
autoTimeoutThreshold?: number; autoTimeoutThreshold?: number;
}; };
}; };
system: Record<string, any>;
} }
// Initial default config state // Initial default config state
@@ -160,7 +161,8 @@ const configSchema = z.object({
cases: { cases: {
dmOnWarn: true dmOnWarn: true
} }
}) }),
system: z.record(z.string(), z.any()).default({}),
}); });
export function reloadConfig() { export function reloadConfig() {

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

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

View File

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

View File

@@ -40,7 +40,7 @@ export class CommandLoader {
continue; continue;
} }
if (!file.name.endsWith('.ts') && !file.name.endsWith('.js')) continue; if ((!file.name.endsWith('.ts') && !file.name.endsWith('.js')) || file.name.endsWith('.test.ts') || file.name.endsWith('.spec.ts')) continue;
await this.loadCommandFile(filePath, reload, result); await this.loadCommandFile(filePath, reload, result);
} }

38
src/lib/logger.test.ts Normal file
View File

@@ -0,0 +1,38 @@
import { describe, it, expect, beforeEach } from "bun:test";
import { logger, getRecentLogs } from "./logger";
describe("Logger Buffer", () => {
// Note: Since the buffer is a module-level variable, it persists across tests.
// In a real scenario we might want a reset function, but for now we'll just check relative additions.
it("should add logs to the buffer", () => {
const initialLength = getRecentLogs().length;
logger.info("Test Info Log");
const newLogs = getRecentLogs();
expect(newLogs.length).toBe(initialLength + 1);
expect(newLogs[0]?.message).toBe("Test Info Log");
expect(newLogs[0]?.type).toBe("info");
});
it("should cap the buffer size at 50", () => {
// Fill the buffer
for (let i = 0; i < 60; i++) {
logger.debug(`Log overflow test ${i}`);
}
const logs = getRecentLogs();
expect(logs.length).toBeLessThanOrEqual(50);
expect(logs[0]?.message).toBe("Log overflow test 59");
});
it("should handle different log levels", () => {
logger.error("Critical Error");
logger.success("Operation Successful");
const logs = getRecentLogs();
expect(logs[0]?.type).toBe("success");
expect(logs[1]?.type).toBe("error");
});
});

View File

@@ -1,12 +1,32 @@
import { WebServer } from "@/web/server";
/** /**
* Centralized logging utility with consistent formatting * Centralized logging utility with consistent formatting
*/ */
const LOG_BUFFER_SIZE = 50;
const logBuffer: Array<{ time: string; type: string; message: string }> = [];
function addToBuffer(type: string, message: string) {
const time = new Date().toLocaleTimeString();
logBuffer.unshift({ time, type, message });
if (logBuffer.length > LOG_BUFFER_SIZE) {
logBuffer.pop();
}
}
export function getRecentLogs() {
return logBuffer;
}
export const logger = { export const logger = {
/** /**
* General information message * General information message
*/ */
info: (message: string, ...args: any[]) => { info: (message: string, ...args: any[]) => {
console.log(` ${message}`, ...args); console.log(` ${message}`, ...args);
addToBuffer("info", message);
try { WebServer.broadcastLog("info", message); } catch { }
}, },
/** /**
@@ -14,6 +34,8 @@ export const logger = {
*/ */
success: (message: string, ...args: any[]) => { success: (message: string, ...args: any[]) => {
console.log(`${message}`, ...args); console.log(`${message}`, ...args);
addToBuffer("success", message);
try { WebServer.broadcastLog("success", message); } catch { }
}, },
/** /**
@@ -21,6 +43,8 @@ export const logger = {
*/ */
warn: (message: string, ...args: any[]) => { warn: (message: string, ...args: any[]) => {
console.warn(`⚠️ ${message}`, ...args); console.warn(`⚠️ ${message}`, ...args);
addToBuffer("warning", message);
try { WebServer.broadcastLog("warning", message); } catch { }
}, },
/** /**
@@ -28,6 +52,8 @@ export const logger = {
*/ */
error: (message: string, ...args: any[]) => { error: (message: string, ...args: any[]) => {
console.error(`${message}`, ...args); console.error(`${message}`, ...args);
addToBuffer("error", message);
try { WebServer.broadcastLog("error", message); } catch { }
}, },
/** /**
@@ -35,5 +61,7 @@ export const logger = {
*/ */
debug: (message: string, ...args: any[]) => { debug: (message: string, ...args: any[]) => {
console.log(`🔍 ${message}`, ...args); console.log(`🔍 ${message}`, ...args);
addToBuffer("debug", message);
try { WebServer.broadcastLog("debug", message); } catch { }
}, },
}; };

View File

@@ -1,4 +1,6 @@
import type { AutocompleteInteraction, ChatInputCommandInteraction, ClientEvents, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder, SlashCommandSubcommandsOnlyBuilder } from "discord.js"; import type { AutocompleteInteraction, ChatInputCommandInteraction, ClientEvents, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder, SlashCommandSubcommandsOnlyBuilder } from "discord.js";
import { LootType, EffectType } from "./constants";
import { DrizzleClient } from "./DrizzleClient";
export interface Command { export interface Command {
data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder | SlashCommandSubcommandsOnlyBuilder; data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder | SlashCommandSubcommandsOnlyBuilder;
@@ -14,16 +16,16 @@ export interface Event<K extends keyof ClientEvents> {
} }
export type ItemEffect = export type ItemEffect =
| { type: 'ADD_XP'; amount: number } | { type: EffectType.ADD_XP; amount: number }
| { type: 'ADD_BALANCE'; amount: number } | { type: EffectType.ADD_BALANCE; amount: number }
| { type: 'XP_BOOST'; multiplier: number; durationSeconds?: number; durationMinutes?: number; durationHours?: number } | { type: EffectType.XP_BOOST; multiplier: number; durationSeconds?: number; durationMinutes?: number; durationHours?: number }
| { type: 'TEMP_ROLE'; roleId: string; durationSeconds?: number; durationMinutes?: number; durationHours?: number } | { type: EffectType.TEMP_ROLE; roleId: string; durationSeconds?: number; durationMinutes?: number; durationHours?: number }
| { type: 'REPLY_MESSAGE'; message: string } | { type: EffectType.REPLY_MESSAGE; message: string }
| { type: 'COLOR_ROLE'; roleId: string } | { type: EffectType.COLOR_ROLE; roleId: string }
| { type: 'LOOTBOX'; pool: LootTableItem[] }; | { type: EffectType.LOOTBOX; pool: LootTableItem[] };
export interface LootTableItem { export interface LootTableItem {
type: 'CURRENCY' | 'ITEM' | 'XP' | 'NOTHING'; type: LootType;
weight: number; weight: number;
amount?: number; // For CURRENCY, XP amount?: number; // For CURRENCY, XP
itemId?: number; // For ITEM itemId?: number; // For ITEM
@@ -37,7 +39,5 @@ export interface ItemUsageData {
effects: ItemEffect[]; effects: ItemEffect[];
} }
import { DrizzleClient } from "./DrizzleClient";
export type DbClient = typeof DrizzleClient; export type DbClient = typeof DrizzleClient;
export type Transaction = Parameters<Parameters<DbClient['transaction']>[0]>[0]; export type Transaction = Parameters<Parameters<DbClient['transaction']>[0]>[0];

View File

@@ -4,6 +4,7 @@ import { DrizzleClient } from "@/lib/DrizzleClient";
import type { ItemUsageData, ItemEffect } from "@/lib/types"; import type { ItemUsageData, ItemEffect } from "@/lib/types";
import { getItemWizardEmbed, getItemTypeSelection, getEffectTypeSelection, getDetailsModal, getEconomyModal, getVisualsModal, getEffectConfigModal } from "./item_wizard.view"; import { getItemWizardEmbed, getItemTypeSelection, getEffectTypeSelection, getDetailsModal, getEconomyModal, getVisualsModal, getEffectConfigModal } from "./item_wizard.view";
import type { DraftItem } from "./item_wizard.types"; import type { DraftItem } from "./item_wizard.types";
import { ItemType, EffectType } from "@/lib/constants";
// --- Types --- // --- Types ---
@@ -23,7 +24,7 @@ export const renderWizard = (userId: string, isDraft = true) => {
name: "New Item", name: "New Item",
description: "No description", description: "No description",
rarity: "Common", rarity: "Common",
type: "MATERIAL", type: ItemType.MATERIAL,
price: null, price: null,
iconUrl: "", iconUrl: "",
imageUrl: "", imageUrl: "",
@@ -176,26 +177,26 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
if (type) { if (type) {
let effect: ItemEffect | null = null; let effect: ItemEffect | null = null;
if (type === "ADD_XP" || type === "ADD_BALANCE") { if (type === EffectType.ADD_XP || type === EffectType.ADD_BALANCE) {
const amount = parseInt(interaction.fields.getTextInputValue("amount")); const amount = parseInt(interaction.fields.getTextInputValue("amount"));
if (!isNaN(amount)) effect = { type: type as any, amount }; if (!isNaN(amount)) effect = { type: type as any, amount };
} }
else if (type === "REPLY_MESSAGE") { else if (type === EffectType.REPLY_MESSAGE) {
effect = { type: "REPLY_MESSAGE", message: interaction.fields.getTextInputValue("message") }; effect = { type: EffectType.REPLY_MESSAGE, message: interaction.fields.getTextInputValue("message") };
} }
else if (type === "XP_BOOST") { else if (type === EffectType.XP_BOOST) {
const multiplier = parseFloat(interaction.fields.getTextInputValue("multiplier")); const multiplier = parseFloat(interaction.fields.getTextInputValue("multiplier"));
const duration = parseInt(interaction.fields.getTextInputValue("duration")); const duration = parseInt(interaction.fields.getTextInputValue("duration"));
if (!isNaN(multiplier) && !isNaN(duration)) effect = { type: "XP_BOOST", multiplier, durationSeconds: duration }; if (!isNaN(multiplier) && !isNaN(duration)) effect = { type: EffectType.XP_BOOST, multiplier, durationSeconds: duration };
} }
else if (type === "TEMP_ROLE") { else if (type === EffectType.TEMP_ROLE) {
const roleId = interaction.fields.getTextInputValue("role_id"); const roleId = interaction.fields.getTextInputValue("role_id");
const duration = parseInt(interaction.fields.getTextInputValue("duration")); const duration = parseInt(interaction.fields.getTextInputValue("duration"));
if (roleId && !isNaN(duration)) effect = { type: "TEMP_ROLE", roleId: roleId, durationSeconds: duration }; if (roleId && !isNaN(duration)) effect = { type: EffectType.TEMP_ROLE, roleId: roleId, durationSeconds: duration };
} }
else if (type === "COLOR_ROLE") { else if (type === EffectType.COLOR_ROLE) {
const roleId = interaction.fields.getTextInputValue("role_id"); const roleId = interaction.fields.getTextInputValue("role_id");
if (roleId) effect = { type: "COLOR_ROLE", roleId: roleId }; if (roleId) effect = { type: EffectType.COLOR_ROLE, roleId: roleId };
} }
if (effect) { if (effect) {

View File

@@ -10,12 +10,13 @@ import {
} from "discord.js"; } from "discord.js";
import { createBaseEmbed } from "@lib/embeds"; import { createBaseEmbed } from "@lib/embeds";
import type { DraftItem } from "./item_wizard.types"; import type { DraftItem } from "./item_wizard.types";
import { ItemType } from "@/lib/constants";
const getItemTypeOptions = () => [ const getItemTypeOptions = () => [
{ label: "Material", value: "MATERIAL", description: "Used for crafting or trading" }, { label: "Material", value: ItemType.MATERIAL, description: "Used for crafting or trading" },
{ label: "Consumable", value: "CONSUMABLE", description: "Can be used to gain effects" }, { label: "Consumable", value: ItemType.CONSUMABLE, description: "Can be used to gain effects" },
{ label: "Equipment", value: "EQUIPMENT", description: "Can be equipped (Not yet implemented)" }, { label: "Equipment", value: ItemType.EQUIPMENT, description: "Can be equipped (Not yet implemented)" },
{ label: "Quest Item", value: "QUEST", description: "Required for quests" }, { label: "Quest Item", value: ItemType.QUEST, description: "Required for quests" },
]; ];
const getEffectTypeOptions = () => [ const getEffectTypeOptions = () => [

View File

@@ -209,6 +209,15 @@ describe("economyService", () => {
expect(result.streak).toBe(1); expect(result.streak).toBe(1);
}); });
it("should preserve streak if cooldown is missing but user has a streak", async () => {
mockFindFirst
.mockResolvedValueOnce(undefined) // No cooldown
.mockResolvedValueOnce({ id: 1n, dailyStreak: 10 });
const result = await economyService.claimDaily("1");
expect(result.streak).toBe(11);
});
it("should prevent weekly bonus exploit by resetting streak", async () => { it("should prevent weekly bonus exploit by resetting streak", async () => {
// Mock user at streak 7. // Mock user at streak 7.
// Mock time as 24h + 1m after expiry. // Mock time as 24h + 1m after expiry.

View File

@@ -4,6 +4,7 @@ import { config } from "@/lib/config";
import { withTransaction } from "@/lib/db"; import { withTransaction } from "@/lib/db";
import type { Transaction } from "@/lib/types"; import type { Transaction } from "@/lib/types";
import { UserError } from "@/lib/errors"; import { UserError } from "@/lib/errors";
import { TimerType, TransactionType } from "@/lib/constants";
export const economyService = { export const economyService = {
transfer: async (fromUserId: string, toUserId: string, amount: bigint, tx?: Transaction) => { transfer: async (fromUserId: string, toUserId: string, amount: bigint, tx?: Transaction) => {
@@ -48,7 +49,7 @@ export const economyService = {
await txFn.insert(transactions).values({ await txFn.insert(transactions).values({
userId: BigInt(fromUserId), userId: BigInt(fromUserId),
amount: -amount, amount: -amount,
type: 'TRANSFER_OUT', type: TransactionType.TRANSFER_OUT,
description: `Transfer to ${toUserId}`, description: `Transfer to ${toUserId}`,
}); });
@@ -56,7 +57,7 @@ export const economyService = {
await txFn.insert(transactions).values({ await txFn.insert(transactions).values({
userId: BigInt(toUserId), userId: BigInt(toUserId),
amount: amount, amount: amount,
type: 'TRANSFER_IN', type: TransactionType.TRANSFER_IN,
description: `Transfer from ${fromUserId}`, description: `Transfer from ${fromUserId}`,
}); });
@@ -67,14 +68,12 @@ export const economyService = {
claimDaily: async (userId: string, tx?: Transaction) => { claimDaily: async (userId: string, tx?: Transaction) => {
return await withTransaction(async (txFn) => { return await withTransaction(async (txFn) => {
const now = new Date(); const now = new Date();
const startOfDay = new Date(now);
startOfDay.setHours(0, 0, 0, 0);
// Check cooldown // Check cooldown
const cooldown = await txFn.query.userTimers.findFirst({ const cooldown = await txFn.query.userTimers.findFirst({
where: and( where: and(
eq(userTimers.userId, BigInt(userId)), eq(userTimers.userId, BigInt(userId)),
eq(userTimers.type, 'COOLDOWN'), eq(userTimers.type, TimerType.COOLDOWN),
eq(userTimers.key, 'daily') eq(userTimers.key, 'daily')
), ),
}); });
@@ -89,17 +88,23 @@ export const economyService = {
}); });
if (!user) { if (!user) {
throw new Error("User not found"); // This might be system error because user should exist if authenticated, but keeping simple for now throw new Error("User not found");
} }
let streak = (user.dailyStreak || 0) + 1; let streak = (user.dailyStreak || 0) + 1;
// If previous cooldown exists and expired more than 24h ago (meaning >48h since last claim), reduce streak by one for each day passed minimum 1 // Check if streak should be reset due to missing a day
if (cooldown) { if (cooldown) {
const timeSinceReady = now.getTime() - cooldown.expiresAt.getTime(); const timeSinceReady = now.getTime() - cooldown.expiresAt.getTime();
// If more than 24h passed since it became ready, they missed a full calendar day
if (timeSinceReady > 24 * 60 * 60 * 1000) { if (timeSinceReady > 24 * 60 * 60 * 1000) {
streak = 1; streak = 1;
} }
} else if ((user.dailyStreak || 0) > 0) {
// If no cooldown record exists but user has a streak,
// we'll allow one "free" increment to restore the timer state.
// This prevents unfair resets if timers were cleared/lost.
streak = (user.dailyStreak || 0) + 1;
} else { } else {
streak = 1; streak = 1;
} }
@@ -127,7 +132,7 @@ export const economyService = {
await txFn.insert(userTimers) await txFn.insert(userTimers)
.values({ .values({
userId: BigInt(userId), userId: BigInt(userId),
type: 'COOLDOWN', type: TimerType.COOLDOWN,
key: 'daily', key: 'daily',
expiresAt: nextReadyAt, expiresAt: nextReadyAt,
}) })
@@ -140,7 +145,7 @@ export const economyService = {
await txFn.insert(transactions).values({ await txFn.insert(transactions).values({
userId: BigInt(userId), userId: BigInt(userId),
amount: totalReward, amount: totalReward,
type: 'DAILY_REWARD', type: TransactionType.DAILY_REWARD,
description: `Daily reward (Streak: ${streak})`, description: `Daily reward (Streak: ${streak})`,
}); });

View File

@@ -27,7 +27,7 @@ class LootdropService {
// Cleanup interval for activity tracking and expired lootdrops // Cleanup interval for activity tracking and expired lootdrops
setInterval(() => { setInterval(() => {
this.cleanupActivity(); this.cleanupActivity();
this.cleanupExpiredLootdrops(); this.cleanupExpiredLootdrops(true);
}, 60000); }, 60000);
} }
@@ -45,16 +45,24 @@ class LootdropService {
} }
} }
private async cleanupExpiredLootdrops() { public async cleanupExpiredLootdrops(includeClaimed: boolean = false): Promise<number> {
try { try {
const now = new Date(); const now = new Date();
await DrizzleClient.delete(lootdrops) const whereClause = includeClaimed
.where(and( ? lt(lootdrops.expiresAt, now)
isNull(lootdrops.claimedBy), : and(isNull(lootdrops.claimedBy), lt(lootdrops.expiresAt, now));
lt(lootdrops.expiresAt, now)
)); const result = await DrizzleClient.delete(lootdrops)
.where(whereClause)
.returning();
if (result.length > 0) {
console.log(`[LootdropService] Cleaned up ${result.length} expired lootdrops.`);
}
return result.length;
} catch (error) { } catch (error) {
console.error("Failed to cleanup lootdrops:", error); console.error("Failed to cleanup lootdrops:", error);
return 0;
} }
} }

View File

@@ -5,6 +5,7 @@ import type { EffectHandler } from "./types";
import type { LootTableItem } from "@/lib/types"; import type { LootTableItem } from "@/lib/types";
import { inventoryService } from "@/modules/inventory/inventory.service"; import { inventoryService } from "@/modules/inventory/inventory.service";
import { inventory, items } from "@/db/schema"; import { inventory, items } from "@/db/schema";
import { TimerType, TransactionType, LootType } from "@/lib/constants";
// Helper to extract duration in seconds // Helper to extract duration in seconds
@@ -20,7 +21,7 @@ export const handleAddXp: EffectHandler = async (userId, effect, txFn) => {
}; };
export const handleAddBalance: EffectHandler = async (userId, effect, txFn) => { export const handleAddBalance: EffectHandler = async (userId, effect, txFn) => {
await economyService.modifyUserBalance(userId, BigInt(effect.amount), 'ITEM_USE', `Used Item`, null, txFn); await economyService.modifyUserBalance(userId, BigInt(effect.amount), TransactionType.ITEM_USE, `Used Item`, null, txFn);
return `Gained ${effect.amount} 🪙`; return `Gained ${effect.amount} 🪙`;
}; };
@@ -33,7 +34,7 @@ export const handleXpBoost: EffectHandler = async (userId, effect, txFn) => {
const expiresAt = new Date(Date.now() + boostDuration * 1000); const expiresAt = new Date(Date.now() + boostDuration * 1000);
await txFn.insert(userTimers).values({ await txFn.insert(userTimers).values({
userId: BigInt(userId), userId: BigInt(userId),
type: 'EFFECT', type: TimerType.EFFECT,
key: 'xp_boost', key: 'xp_boost',
expiresAt: expiresAt, expiresAt: expiresAt,
metadata: { multiplier: effect.multiplier } metadata: { multiplier: effect.multiplier }
@@ -49,7 +50,7 @@ export const handleTempRole: EffectHandler = async (userId, effect, txFn) => {
const roleExpiresAt = new Date(Date.now() + roleDuration * 1000); const roleExpiresAt = new Date(Date.now() + roleDuration * 1000);
await txFn.insert(userTimers).values({ await txFn.insert(userTimers).values({
userId: BigInt(userId), userId: BigInt(userId),
type: 'ACCESS', type: TimerType.ACCESS,
key: `role_${effect.roleId}`, key: `role_${effect.roleId}`,
expiresAt: roleExpiresAt, expiresAt: roleExpiresAt,
metadata: { roleId: effect.roleId } metadata: { roleId: effect.roleId }
@@ -84,22 +85,22 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
if (!winner) return "The box is empty..."; // Should not happen if (!winner) return "The box is empty..."; // Should not happen
// Process Winner // Process Winner
if (winner.type === 'NOTHING') { if (winner.type === LootType.NOTHING) {
return winner.message || "You found nothing inside."; return winner.message || "You found nothing inside.";
} }
if (winner.type === 'CURRENCY') { if (winner.type === LootType.CURRENCY) {
let amount = winner.amount || 0; let amount = winner.amount || 0;
if (winner.minAmount && winner.maxAmount) { if (winner.minAmount && winner.maxAmount) {
amount = Math.floor(Math.random() * (winner.maxAmount - winner.minAmount + 1)) + winner.minAmount; amount = Math.floor(Math.random() * (winner.maxAmount - winner.minAmount + 1)) + winner.minAmount;
} }
if (amount > 0) { if (amount > 0) {
await economyService.modifyUserBalance(userId, BigInt(amount), 'LOOTBOX', 'Lootbox Reward', null, txFn); await economyService.modifyUserBalance(userId, BigInt(amount), TransactionType.LOOTBOX, 'Lootbox Reward', null, txFn);
return winner.message || `You found ${amount} 🪙!`; return winner.message || `You found ${amount} 🪙!`;
} }
} }
if (winner.type === 'XP') { if (winner.type === LootType.XP) {
let amount = winner.amount || 0; let amount = winner.amount || 0;
if (winner.minAmount && winner.maxAmount) { if (winner.minAmount && winner.maxAmount) {
amount = Math.floor(Math.random() * (winner.maxAmount - winner.minAmount + 1)) + winner.minAmount; amount = Math.floor(Math.random() * (winner.maxAmount - winner.minAmount + 1)) + winner.minAmount;
@@ -110,7 +111,7 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
} }
} }
if (winner.type === 'ITEM') { if (winner.type === LootType.ITEM) {
if (winner.itemId) { if (winner.itemId) {
const quantity = BigInt(winner.amount || 1); const quantity = BigInt(winner.amount || 1);

View File

@@ -18,6 +18,8 @@ const mockWhere = mock();
const mockSelect = mock(); const mockSelect = mock();
const mockFrom = mock(); const mockFrom = mock();
const mockOnConflictDoUpdate = mock(); const mockOnConflictDoUpdate = mock();
const mockInnerJoin = mock();
const mockLimit = mock();
// Chain setup // Chain setup
mockInsert.mockReturnValue({ values: mockValues }); mockInsert.mockReturnValue({ values: mockValues });
@@ -34,7 +36,10 @@ mockWhere.mockReturnValue({ returning: mockReturning });
mockDelete.mockReturnValue({ where: mockWhere }); mockDelete.mockReturnValue({ where: mockWhere });
mockSelect.mockReturnValue({ from: mockFrom }); mockSelect.mockReturnValue({ from: mockFrom });
mockFrom.mockReturnValue({ where: mockWhere }); mockFrom.mockReturnValue({ where: mockWhere, innerJoin: mockInnerJoin });
mockInnerJoin.mockReturnValue({ where: mockWhere });
mockWhere.mockReturnValue({ returning: mockReturning, limit: mockLimit });
mockLimit.mockResolvedValue([]);
// Mock DrizzleClient // Mock DrizzleClient
mock.module("@/lib/DrizzleClient", () => { mock.module("@/lib/DrizzleClient", () => {
@@ -239,4 +244,39 @@ describe("inventoryService", () => {
expect(mockDelete).toHaveBeenCalledWith(inventory); // Consume expect(mockDelete).toHaveBeenCalledWith(inventory); // Consume
}); });
}); });
describe("getAutocompleteItems", () => {
it("should return formatted autocomplete results with rarity", async () => {
const mockItems = [
{
item: { id: 1, name: "Common Sword", rarity: "Common", usageData: { effects: [{}] } },
quantity: 5n
},
{
item: { id: 2, name: "Epic Shield", rarity: "Epic", usageData: { effects: [{}] } },
quantity: 1n
}
];
mockLimit.mockResolvedValue(mockItems);
// Restore mocks that might have been polluted by other tests
mockFrom.mockReturnValue({ where: mockWhere, innerJoin: mockInnerJoin });
mockWhere.mockReturnValue({ returning: mockReturning, limit: mockLimit });
const result = await inventoryService.getAutocompleteItems("1", "Sw");
expect(mockSelect).toHaveBeenCalled();
expect(mockFrom).toHaveBeenCalledWith(inventory);
expect(mockInnerJoin).toHaveBeenCalled(); // checks join
expect(mockWhere).toHaveBeenCalled(); // checks filters
expect(mockLimit).toHaveBeenCalledWith(20);
expect(result).toHaveLength(2);
expect(result[0]?.name).toBe("Common Sword (5) [Common]");
expect(result[0]?.value).toBe(1);
expect(result[1]?.name).toBe("Epic Shield (1) [Epic]");
expect(result[1]?.value).toBe(2);
});
});
}); });

View File

@@ -1,5 +1,5 @@
import { inventory, items, users, userTimers } from "@/db/schema"; import { inventory, items, users, userTimers } from "@/db/schema";
import { eq, and, sql, count } from "drizzle-orm"; import { eq, and, sql, count, ilike } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient"; import { DrizzleClient } from "@/lib/DrizzleClient";
import { economyService } from "@/modules/economy/economy.service"; import { economyService } from "@/modules/economy/economy.service";
import { levelingService } from "@/modules/leveling/leveling.service"; import { levelingService } from "@/modules/leveling/leveling.service";
@@ -7,6 +7,7 @@ import { config } from "@/lib/config";
import { UserError } from "@/lib/errors"; import { UserError } from "@/lib/errors";
import { withTransaction } from "@/lib/db"; import { withTransaction } from "@/lib/db";
import type { Transaction, ItemUsageData } from "@/lib/types"; import type { Transaction, ItemUsageData } from "@/lib/types";
import { TransactionType } from "@/lib/constants";
@@ -121,7 +122,7 @@ export const inventoryService = {
const totalPrice = item.price * quantity; const totalPrice = item.price * quantity;
// Deduct Balance using economy service (passing tx ensures atomicity) // Deduct Balance using economy service (passing tx ensures atomicity)
await economyService.modifyUserBalance(userId, -totalPrice, 'PURCHASE', `Bought ${quantity}x ${item.name}`, null, txFn); await economyService.modifyUserBalance(userId, -totalPrice, TransactionType.PURCHASE, `Bought ${quantity}x ${item.name}`, null, txFn);
await inventoryService.addItem(userId, itemId, quantity, txFn); await inventoryService.addItem(userId, itemId, quantity, txFn);
@@ -180,5 +181,29 @@ export const inventoryService = {
return { success: true, results, usageData, item }; return { success: true, results, usageData, item };
}, tx); }, tx);
},
getAutocompleteItems: async (userId: string, query: string) => {
const entries = await DrizzleClient.select({
quantity: inventory.quantity,
item: items
})
.from(inventory)
.innerJoin(items, eq(inventory.itemId, items.id))
.where(and(
eq(inventory.userId, BigInt(userId)),
ilike(items.name, `%${query}%`)
))
.limit(20);
const filtered = entries.filter(entry => {
const usageData = entry.item.usageData as ItemUsageData | null;
return usageData && usageData.effects && usageData.effects.length > 0;
});
return filtered.map(entry => ({
name: `${entry.item.name} (${entry.quantity}) [${entry.item.rarity || 'Common'}]`,
value: entry.item.id
}));
} }
}; };

View File

@@ -1,5 +1,6 @@
import { EmbedBuilder } from "discord.js"; import { EmbedBuilder } from "discord.js";
import type { ItemUsageData } from "@/lib/types"; import type { ItemUsageData } from "@/lib/types";
import { EffectType } from "@/lib/constants";
/** /**
* Inventory entry with item details * Inventory entry with item details
@@ -34,7 +35,7 @@ export function getItemUseResultEmbed(results: string[], item?: { name: string,
const description = results.map(r => `${r}`).join("\n"); const description = results.map(r => `${r}`).join("\n");
// Check if it was a lootbox // Check if it was a lootbox
const isLootbox = item?.usageData?.effects?.some((e: any) => e.type === 'LOOTBOX'); const isLootbox = item?.usageData?.effects?.some((e: any) => e.type === EffectType.LOOTBOX);
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setDescription(description) .setDescription(description)

View File

@@ -3,6 +3,7 @@ import { eq, sql, and } from "drizzle-orm";
import { withTransaction } from "@/lib/db"; import { withTransaction } from "@/lib/db";
import { config } from "@/lib/config"; import { config } from "@/lib/config";
import type { Transaction } from "@/lib/types"; import type { Transaction } from "@/lib/types";
import { TimerType } from "@/lib/constants";
export const levelingService = { export const levelingService = {
// Calculate total XP required to REACH a specific level (Cumulative) // Calculate total XP required to REACH a specific level (Cumulative)
@@ -78,7 +79,7 @@ export const levelingService = {
const cooldown = await txFn.query.userTimers.findFirst({ const cooldown = await txFn.query.userTimers.findFirst({
where: and( where: and(
eq(userTimers.userId, BigInt(id)), eq(userTimers.userId, BigInt(id)),
eq(userTimers.type, 'COOLDOWN'), eq(userTimers.type, TimerType.COOLDOWN),
eq(userTimers.key, 'chat_xp') eq(userTimers.key, 'chat_xp')
), ),
}); });
@@ -95,7 +96,7 @@ export const levelingService = {
const xpBoost = await txFn.query.userTimers.findFirst({ const xpBoost = await txFn.query.userTimers.findFirst({
where: and( where: and(
eq(userTimers.userId, BigInt(id)), eq(userTimers.userId, BigInt(id)),
eq(userTimers.type, 'EFFECT'), eq(userTimers.type, TimerType.EFFECT),
eq(userTimers.key, 'xp_boost') eq(userTimers.key, 'xp_boost')
) )
}); });
@@ -114,7 +115,7 @@ export const levelingService = {
await txFn.insert(userTimers) await txFn.insert(userTimers)
.values({ .values({
userId: BigInt(id), userId: BigInt(id),
type: 'COOLDOWN', type: TimerType.COOLDOWN,
key: 'chat_xp', key: 'chat_xp',
expiresAt: nextReadyAt, expiresAt: nextReadyAt,
}) })

View File

@@ -0,0 +1,291 @@
import { describe, it, expect, mock, beforeEach } from "bun:test";
import { ModerationService } from "./moderation.service";
import { moderationCases } from "@/db/schema";
import { CaseType } from "@/lib/constants";
// Mock Drizzle Functions
const mockFindFirst = mock();
const mockFindMany = mock();
const mockInsert = mock();
const mockUpdate = mock();
const mockValues = mock();
const mockReturning = mock();
const mockSet = mock();
const mockWhere = mock();
// Mock Config
const mockConfig = {
moderation: {
cases: {
dmOnWarn: true,
autoTimeoutThreshold: 3
}
}
};
mock.module("@/lib/config", () => ({
config: mockConfig
}));
// Mock View
const mockGetUserWarningEmbed = mock(() => ({}));
mock.module("./moderation.view", () => ({
getUserWarningEmbed: mockGetUserWarningEmbed
}));
// Mock DrizzleClient
mock.module("@/lib/DrizzleClient", () => ({
DrizzleClient: {
query: {
moderationCases: {
findFirst: mockFindFirst,
findMany: mockFindMany,
},
},
insert: mockInsert,
update: mockUpdate,
}
}));
// Setup chains
mockInsert.mockReturnValue({ values: mockValues });
mockValues.mockReturnValue({ returning: mockReturning });
mockUpdate.mockReturnValue({ set: mockSet });
mockSet.mockReturnValue({ where: mockWhere });
mockWhere.mockReturnValue({ returning: mockReturning });
describe("ModerationService", () => {
beforeEach(() => {
mockFindFirst.mockReset();
mockFindMany.mockReset();
mockInsert.mockClear();
mockUpdate.mockClear();
mockValues.mockClear();
mockReturning.mockClear();
mockSet.mockClear();
mockWhere.mockClear();
mockGetUserWarningEmbed.mockClear();
// Reset config to defaults
mockConfig.moderation.cases.dmOnWarn = true;
mockConfig.moderation.cases.autoTimeoutThreshold = 3;
});
describe("issueWarning", () => {
const defaultOptions = {
userId: "123456789",
username: "testuser",
moderatorId: "987654321",
moderatorName: "mod",
reason: "test reason",
guildName: "Test Guild"
};
it("should issue a warning and attempt to DM the user", async () => {
mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" });
mockReturning.mockResolvedValue([{ caseId: "CASE-0002" }]);
mockFindMany.mockResolvedValue([{ type: CaseType.WARN, active: true }]); // 1 warning total
const mockDmTarget = { send: mock() };
const result = await ModerationService.issueWarning({
...defaultOptions,
dmTarget: mockDmTarget
});
expect(result.moderationCase).toBeDefined();
expect(result.warningCount).toBe(1);
expect(mockDmTarget.send).toHaveBeenCalled();
expect(mockGetUserWarningEmbed).toHaveBeenCalled();
});
it("should not DM if dmOnWarn is false", async () => {
mockConfig.moderation.cases.dmOnWarn = false;
mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" });
mockReturning.mockResolvedValue([{ caseId: "CASE-0002" }]);
mockFindMany.mockResolvedValue([]);
const mockDmTarget = { send: mock() };
await ModerationService.issueWarning({
...defaultOptions,
dmTarget: mockDmTarget
});
expect(mockDmTarget.send).not.toHaveBeenCalled();
});
it("should trigger auto-timeout when threshold is reached", async () => {
mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" });
mockReturning.mockResolvedValue([{ caseId: "CASE-0002" }]);
// Simulate 3 warnings (threshold is 3)
mockFindMany.mockResolvedValue([{}, {}, {}]);
const mockTimeoutTarget = { timeout: mock() };
const result = await ModerationService.issueWarning({
...defaultOptions,
timeoutTarget: mockTimeoutTarget
});
expect(result.autoTimeoutIssued).toBe(true);
expect(mockTimeoutTarget.timeout).toHaveBeenCalledWith(86400000, expect.stringContaining("3 warnings"));
// Should create two cases: one for warn, one for timeout
expect(mockInsert).toHaveBeenCalledTimes(2);
});
it("should not timeout if threshold is not reached", async () => {
mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" });
mockReturning.mockResolvedValue([{ caseId: "CASE-0002" }]);
// Simulate 2 warnings (threshold is 3)
mockFindMany.mockResolvedValue([{}, {}]);
const mockTimeoutTarget = { timeout: mock() };
const result = await ModerationService.issueWarning({
...defaultOptions,
timeoutTarget: mockTimeoutTarget
});
expect(result.autoTimeoutIssued).toBe(false);
expect(mockTimeoutTarget.timeout).not.toHaveBeenCalled();
expect(mockInsert).toHaveBeenCalledTimes(1);
});
});
describe("getNextCaseId", () => {
it("should return CASE-0001 if no cases exist", async () => {
mockFindFirst.mockResolvedValue(undefined);
// Accessing private method via bracket notation for testing
const nextId = await (ModerationService as any).getNextCaseId();
expect(nextId).toBe("CASE-0001");
});
it("should increment the latest case ID", async () => {
mockFindFirst.mockResolvedValue({ caseId: "CASE-0042" });
const nextId = await (ModerationService as any).getNextCaseId();
expect(nextId).toBe("CASE-0043");
});
it("should handle padding correctly (e.g., 9 -> 0010)", async () => {
mockFindFirst.mockResolvedValue({ caseId: "CASE-0009" });
const nextId = await (ModerationService as any).getNextCaseId();
expect(nextId).toBe("CASE-0010");
});
});
describe("createCase", () => {
it("should create a new moderation case with correct values", async () => {
mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" });
const mockNewCase = {
caseId: "CASE-0002",
type: CaseType.WARN,
userId: 123456789n,
username: "testuser",
moderatorId: 987654321n,
moderatorName: "mod",
reason: "test reason",
metadata: {},
active: true
};
mockReturning.mockResolvedValue([mockNewCase]);
const result = await ModerationService.createCase({
type: CaseType.WARN,
userId: "123456789",
username: "testuser",
moderatorId: "987654321",
moderatorName: "mod",
reason: "test reason"
});
expect(result?.caseId).toBe("CASE-0002");
expect(mockInsert).toHaveBeenCalled();
expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({
caseId: "CASE-0002",
type: CaseType.WARN,
userId: 123456789n,
reason: "test reason"
}));
});
it("should set active to false for non-warn types", async () => {
mockFindFirst.mockResolvedValue(undefined);
mockReturning.mockImplementation((values) => [values]); // Simplified mock
const result = await ModerationService.createCase({
type: CaseType.BAN,
userId: "123456789",
username: "testuser",
moderatorId: "987654321",
moderatorName: "mod",
reason: "test reason"
});
expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({
active: false
}));
});
});
describe("getCaseById", () => {
it("should return a case by its ID", async () => {
const mockCase = { caseId: "CASE-0001", reason: "test" };
mockFindFirst.mockResolvedValue(mockCase);
const result = await ModerationService.getCaseById("CASE-0001");
expect(result).toEqual(mockCase as any);
});
});
describe("getUserCases", () => {
it("should return all cases for a user", async () => {
const mockCases = [{ caseId: "CASE-0001" }, { caseId: "CASE-0002" }];
mockFindMany.mockResolvedValue(mockCases);
const result = await ModerationService.getUserCases("123456789");
expect(result).toHaveLength(2);
expect(mockFindMany).toHaveBeenCalled();
});
});
describe("clearCase", () => {
it("should update a case to be inactive and resolved", async () => {
const mockUpdatedCase = { caseId: "CASE-0001", active: false };
mockReturning.mockResolvedValue([mockUpdatedCase]);
const result = await ModerationService.clearCase({
caseId: "CASE-0001",
clearedBy: "987654321",
clearedByName: "mod",
reason: "resolved"
});
expect(result?.active).toBe(false);
expect(mockUpdate).toHaveBeenCalled();
expect(mockSet).toHaveBeenCalledWith(expect.objectContaining({
active: false,
resolvedBy: 987654321n,
resolvedReason: "resolved"
}));
});
});
describe("getActiveWarningCount", () => {
it("should return the number of active warnings", async () => {
mockFindMany.mockResolvedValue([
{ id: 1n, type: CaseType.WARN, active: true },
{ id: 2n, type: CaseType.WARN, active: true }
]);
const count = await ModerationService.getActiveWarningCount("123456789");
expect(count).toBe(2);
});
it("should return 0 if no active warnings", async () => {
mockFindMany.mockResolvedValue([]);
const count = await ModerationService.getActiveWarningCount("123456789");
expect(count).toBe(0);
});
});
});

View File

@@ -1,7 +1,10 @@
import { moderationCases } from "@/db/schema"; import { moderationCases } from "@/db/schema";
import { eq, and, desc } from "drizzle-orm"; import { eq, and, desc } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient"; import { DrizzleClient } from "@/lib/DrizzleClient";
import type { CreateCaseOptions, ClearCaseOptions, SearchCasesFilter, CaseType } from "./moderation.types"; import type { CreateCaseOptions, ClearCaseOptions, SearchCasesFilter } from "./moderation.types";
import { config } from "@/lib/config";
import { getUserWarningEmbed } from "./moderation.view";
import { CaseType } from "@/lib/constants";
export class ModerationService { export class ModerationService {
/** /**
@@ -41,12 +44,85 @@ export class ModerationService {
moderatorName: options.moderatorName, moderatorName: options.moderatorName,
reason: options.reason, reason: options.reason,
metadata: options.metadata || {}, metadata: options.metadata || {},
active: options.type === 'warn' ? true : false, // Only warnings are "active" by default active: options.type === CaseType.WARN ? true : false, // Only warnings are "active" by default
}).returning(); }).returning();
return newCase; return newCase;
} }
/**
* Issue a warning with DM and threshold logic
*/
static async issueWarning(options: {
userId: string;
username: string;
moderatorId: string;
moderatorName: string;
reason: string;
guildName?: string;
dmTarget?: { send: (options: any) => Promise<any> };
timeoutTarget?: { timeout: (duration: number, reason: string) => Promise<any> };
}) {
const moderationCase = await this.createCase({
type: CaseType.WARN,
userId: options.userId,
username: options.username,
moderatorId: options.moderatorId,
moderatorName: options.moderatorName,
reason: options.reason,
});
if (!moderationCase) {
throw new Error("Failed to create moderation case");
}
const warningCount = await this.getActiveWarningCount(options.userId);
// Try to DM the user if configured
if (config.moderation.cases.dmOnWarn && options.dmTarget) {
try {
await options.dmTarget.send({
embeds: [getUserWarningEmbed(
options.guildName || 'this server',
options.reason,
moderationCase.caseId,
warningCount
)]
});
} catch (error) {
console.log(`Could not DM warning to ${options.username}: ${error}`);
}
}
// Check for auto-timeout threshold
let autoTimeoutIssued = false;
if (config.moderation.cases.autoTimeoutThreshold &&
warningCount >= config.moderation.cases.autoTimeoutThreshold &&
options.timeoutTarget) {
try {
// Auto-timeout for 24 hours (86400000 ms)
await options.timeoutTarget.timeout(86400000, `Automatic timeout: ${warningCount} warnings`);
// Create a timeout case
await this.createCase({
type: CaseType.TIMEOUT,
userId: options.userId,
username: options.username,
moderatorId: "0", // System/Bot
moderatorName: "System",
reason: `Automatic timeout: reached ${warningCount} warnings`,
metadata: { duration: '24h', automatic: true }
});
autoTimeoutIssued = true;
} catch (error) {
console.error('Failed to auto-timeout user:', error);
}
}
return { moderationCase, warningCount, autoTimeoutIssued };
}
/** /**
* Get a case by its case ID * Get a case by its case ID
*/ */
@@ -79,7 +155,7 @@ export class ModerationService {
return await DrizzleClient.query.moderationCases.findMany({ return await DrizzleClient.query.moderationCases.findMany({
where: and( where: and(
eq(moderationCases.userId, BigInt(userId)), eq(moderationCases.userId, BigInt(userId)),
eq(moderationCases.type, 'warn'), eq(moderationCases.type, CaseType.WARN),
eq(moderationCases.active, true) eq(moderationCases.active, true)
), ),
orderBy: [desc(moderationCases.createdAt)], orderBy: [desc(moderationCases.createdAt)],
@@ -93,7 +169,7 @@ export class ModerationService {
return await DrizzleClient.query.moderationCases.findMany({ return await DrizzleClient.query.moderationCases.findMany({
where: and( where: and(
eq(moderationCases.userId, BigInt(userId)), eq(moderationCases.userId, BigInt(userId)),
eq(moderationCases.type, 'note') eq(moderationCases.type, CaseType.NOTE)
), ),
orderBy: [desc(moderationCases.createdAt)], orderBy: [desc(moderationCases.createdAt)],
}); });

View File

@@ -1,4 +1,6 @@
export type CaseType = 'warn' | 'timeout' | 'kick' | 'ban' | 'note' | 'prune'; import { CaseType } from "@/lib/constants";
export { CaseType };
export interface CreateCaseOptions { export interface CreateCaseOptions {
type: CaseType; type: CaseType;

View File

@@ -6,6 +6,7 @@ import { economyService } from "@/modules/economy/economy.service";
import { levelingService } from "@/modules/leveling/leveling.service"; import { levelingService } from "@/modules/leveling/leveling.service";
import { withTransaction } from "@/lib/db"; import { withTransaction } from "@/lib/db";
import type { Transaction } from "@/lib/types"; import type { Transaction } from "@/lib/types";
import { TransactionType } from "@/lib/constants";
export const questService = { export const questService = {
assignQuest: async (userId: string, questId: number, tx?: Transaction) => { assignQuest: async (userId: string, questId: number, tx?: Transaction) => {
@@ -62,7 +63,7 @@ export const questService = {
if (rewards?.balance) { if (rewards?.balance) {
const bal = BigInt(rewards.balance); const bal = BigInt(rewards.balance);
await economyService.modifyUserBalance(userId, bal, 'QUEST_REWARD', `Reward for quest ${questId}`, null, txFn); await economyService.modifyUserBalance(userId, bal, TransactionType.QUEST_REWARD, `Reward for quest ${questId}`, null, txFn);
results.balance = bal; results.balance = bal;
} }

View File

@@ -1,86 +1,21 @@
import { userTimers } from "@/db/schema"; import { temporaryRoleService } from "./temp-role.service";
import { eq, and, lt } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient";
import { AuroraClient } from "@/lib/BotClient";
import { env } from "@/lib/env";
/**
* The Janitor responsible for cleaning up expired ACCESS timers
* and revoking privileges.
*/
export const schedulerService = { export const schedulerService = {
start: () => { start: () => {
console.log("🕒 Scheduler started: Janitor loop running every 60s"); console.log("🕒 Scheduler started: Maintenance loops initialized.");
// Run immediately on start
schedulerService.runJanitor();
// Loop every 60 seconds // 1. Temporary Role Revocation (every 60s)
setInterval(() => { setInterval(() => {
schedulerService.runJanitor(); temporaryRoleService.processExpiredRoles();
}, 60 * 1000); }, 60 * 1000);
// Terminal Update Loop (every 60s) // 2. Terminal Update Loop (every 60s)
const { terminalService } = require("@/modules/terminal/terminal.service"); const { terminalService } = require("@/modules/terminal/terminal.service");
setInterval(() => { setInterval(() => {
terminalService.update(); terminalService.update();
}, 60 * 1000); }, 60 * 1000);
},
runJanitor: async () => { // Run an initial check on start
try { temporaryRoleService.processExpiredRoles();
// Find all expired ACCESS timers
// We do this in a transaction to ensure we read and delete atomically if possible,
// though for this specific case, fetching then deleting is fine as long as we handle race conditions gracefully.
const now = new Date();
const expiredAccess = await DrizzleClient.query.userTimers.findMany({
where: and(
eq(userTimers.type, 'ACCESS'),
lt(userTimers.expiresAt, now)
)
});
if (expiredAccess.length === 0) return;
console.log(`🧹 Janitor: Found ${expiredAccess.length} expired access timers.`);
for (const timer of expiredAccess) {
const meta = timer.metadata as any;
const userIdStr = timer.userId.toString();
// Specific Handling for Roles
if (timer.key.startsWith('role_')) {
try {
const roleId = meta?.roleId || timer.key.replace('role_', '');
const guildId = env.DISCORD_GUILD_ID;
if (guildId) {
// We try to fetch, if bot is not in guild or lacks perms, it will catch
const guild = await AuroraClient.guilds.fetch(guildId);
const member = await guild.members.fetch(userIdStr);
await member.roles.remove(roleId);
console.log(`👋 Removed temporary role ${roleId} from ${member.user.tag}`);
}
} catch (err) {
console.error(`Failed to remove role for user ${userIdStr}:`, err);
// We still delete the timer so we don't loop forever on a left user
}
} else {
console.log(`🚫 Revoking access for User ${timer.userId}: Key=${timer.key} (Channel: ${meta?.channelId || 'N/A'})`);
// TODO: Generic channel permission removal if needed
}
// Delete the timer row
await DrizzleClient.delete(userTimers)
.where(and(
eq(userTimers.userId, timer.userId),
eq(userTimers.type, timer.type),
eq(userTimers.key, timer.key)
));
}
} catch (error) {
console.error("Janitor Error:", error);
}
} }
}; };

View File

@@ -0,0 +1,114 @@
import { describe, it, expect, mock, beforeEach } from "bun:test";
import { temporaryRoleService } from "./temp-role.service";
import { userTimers } from "@/db/schema";
import { TimerType } from "@/lib/constants";
const mockDelete = mock();
const mockWhere = mock();
const mockFindMany = mock();
mockDelete.mockReturnValue({ where: mockWhere });
// Mock DrizzleClient
mock.module("@/lib/DrizzleClient", () => ({
DrizzleClient: {
delete: mockDelete,
query: {
userTimers: {
findMany: mockFindMany
}
}
}
}));
// Mock AuroraClient
const mockRemoveRole = mock();
const mockFetchMember = mock();
const mockFetchGuild = mock();
mock.module("@/lib/BotClient", () => ({
AuroraClient: {
guilds: {
fetch: mockFetchGuild
}
}
}));
mock.module("@/lib/env", () => ({
env: {
DISCORD_GUILD_ID: "guild123"
}
}));
describe("temporaryRoleService", () => {
beforeEach(() => {
mockDelete.mockClear();
mockWhere.mockClear();
mockFindMany.mockClear();
mockRemoveRole.mockClear();
mockFetchMember.mockClear();
mockFetchGuild.mockClear();
mockFetchGuild.mockResolvedValue({
members: {
fetch: mockFetchMember
}
});
mockFetchMember.mockResolvedValue({
user: { tag: "TestUser#1234" },
roles: { remove: mockRemoveRole }
});
});
it("should revoke expired roles and delete timers", async () => {
// Mock findMany to return an expired role timer
mockFindMany.mockResolvedValue([
{
userId: 123n,
type: TimerType.ACCESS,
key: 'role_456',
expiresAt: new Date(Date.now() - 1000),
metadata: { roleId: '456' }
}
]);
const count = await temporaryRoleService.processExpiredRoles();
expect(count).toBe(1);
expect(mockFetchGuild).toHaveBeenCalledWith("guild123");
expect(mockFetchMember).toHaveBeenCalledWith("123");
expect(mockRemoveRole).toHaveBeenCalledWith("456");
expect(mockDelete).toHaveBeenCalledWith(userTimers);
});
it("should still delete the timer even if member is not found", async () => {
mockFindMany.mockResolvedValue([
{
userId: 999n,
type: TimerType.ACCESS,
key: 'role_789',
expiresAt: new Date(Date.now() - 1000),
metadata: {}
}
]);
// Mock member fetch failure
mockFetchMember.mockRejectedValue(new Error("Member not found"));
const count = await temporaryRoleService.processExpiredRoles();
expect(count).toBe(1);
expect(mockRemoveRole).not.toHaveBeenCalled();
expect(mockDelete).toHaveBeenCalledWith(userTimers);
});
it("should return 0 if no expired timers exist", async () => {
mockFindMany.mockResolvedValue([]);
const count = await temporaryRoleService.processExpiredRoles();
expect(count).toBe(0);
expect(mockDelete).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,67 @@
import { userTimers } from "@/db/schema";
import { eq, and, lt } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient";
import { AuroraClient } from "@/lib/BotClient";
import { env } from "@/lib/env";
import { TimerType } from "@/lib/constants";
export const temporaryRoleService = {
/**
* Checks for and revokes expired temporary roles.
* This is intended to run as a high-frequency maintenance task.
*/
processExpiredRoles: async (): Promise<number> => {
const now = new Date();
let revokedCount = 0;
// Find all expired ACCESS (temporary role) timers
const expiredTimers = await DrizzleClient.query.userTimers.findMany({
where: and(
eq(userTimers.type, TimerType.ACCESS),
lt(userTimers.expiresAt, now)
)
});
if (expiredTimers.length === 0) return 0;
for (const timer of expiredTimers) {
const userIdStr = timer.userId.toString();
const meta = timer.metadata as any;
// We only handle keys that indicate role management
if (timer.key.startsWith('role_')) {
try {
const roleId = meta?.roleId || timer.key.replace('role_', '');
const guildId = env.DISCORD_GUILD_ID;
if (guildId) {
const guild = await AuroraClient.guilds.fetch(guildId);
const member = await guild.members.fetch(userIdStr).catch(() => null);
if (member) {
await member.roles.remove(roleId);
console.log(`👋 Temporary role ${roleId} revoked from ${member.user.tag} (Expired)`);
} else {
console.log(`⚠️ Could not find member ${userIdStr} to revoke role ${roleId}.`);
}
}
} catch (err) {
console.error(`❌ Failed to revoke role for user ${userIdStr}:`, err);
}
}
// Always delete the timer record after trying to revoke (or if it's not a role key)
// to prevent repeated failed attempts.
await DrizzleClient.delete(userTimers)
.where(and(
eq(userTimers.userId, timer.userId),
eq(userTimers.type, timer.type),
eq(userTimers.key, timer.key)
));
revokedCount++;
}
return revokedCount;
}
};

View File

@@ -1,13 +1,30 @@
import { TextChannel, Message, ContainerBuilder, TextDisplayBuilder, SectionBuilder, MessageFlags } from "discord.js"; import {
TextChannel,
ContainerBuilder,
TextDisplayBuilder,
SectionBuilder,
SeparatorBuilder,
ThumbnailBuilder,
MessageFlags,
SeparatorSpacingSize
} from "discord.js";
import { AuroraClient } from "@/lib/BotClient"; import { AuroraClient } from "@/lib/BotClient";
import { DrizzleClient } from "@/lib/DrizzleClient"; import { DrizzleClient } from "@/lib/DrizzleClient";
import { users, transactions, lootdrops } from "@/db/schema"; import { users, transactions, lootdrops, inventory } from "@/db/schema";
import { desc } from "drizzle-orm"; import { desc, sql } from "drizzle-orm";
import { config, saveConfig } from "@/lib/config"; import { config, saveConfig } from "@/lib/config";
// Color palette for containers (hex as decimal)
const COLORS = {
HEADER: 0x9B59B6, // Purple - mystical
LEADERS: 0xF1C40F, // Gold - achievement
ACTIVITY: 0x3498DB, // Blue - activity
ALERT: 0xE74C3C // Red - active events
};
export const terminalService = { export const terminalService = {
init: async (channel: TextChannel) => { init: async (channel: TextChannel) => {
// limit to one terminal for now // Limit to one terminal for now
if (config.terminal) { if (config.terminal) {
try { try {
const oldChannel = await AuroraClient.channels.fetch(config.terminal.channelId) as TextChannel; const oldChannel = await AuroraClient.channels.fetch(config.terminal.channelId) as TextChannel;
@@ -16,11 +33,11 @@ export const terminalService = {
if (oldMsg) await oldMsg.delete(); if (oldMsg) await oldMsg.delete();
} }
} catch (e) { } catch (e) {
// ignore if old message doesn't exist // Ignore if old message doesn't exist
} }
} }
const msg = await channel.send({ content: "🔄 Initializing Aurora Observatory..." }); const msg = await channel.send({ content: "🔄 Initializing Aurora Station..." });
config.terminal = { config.terminal = {
channelId: channel.id, channelId: channel.id,
@@ -48,8 +65,6 @@ export const terminalService = {
const containers = await terminalService.buildMessage(); const containers = await terminalService.buildMessage();
// Components V2 requires the IsComponentsV2 flag and no content/embeds
// Disable allowedMentions to prevent pings from the dashboard
await message.edit({ await message.edit({
content: null, content: null,
embeds: null as any, embeds: null as any,
@@ -64,24 +79,51 @@ export const terminalService = {
}, },
buildMessage: async () => { buildMessage: async () => {
// 1. Data Fetching // ═══════════════════════════════════════════════════════════
// DATA FETCHING
// ═══════════════════════════════════════════════════════════
const allUsers = await DrizzleClient.select().from(users); const allUsers = await DrizzleClient.select().from(users);
const totalUsers = allUsers.length; const totalUsers = allUsers.length;
const totalWealth = allUsers.reduce((acc, u) => acc + (u.balance || 0n), 0n); const totalWealth = allUsers.reduce((acc, u) => acc + (u.balance || 0n), 0n);
// 2. Leaderboards Calculation // System stats
const topLevels = [...allUsers].sort((a, b) => (b.level || 0) - (a.level || 0)).slice(0, 3); const uptime = process.uptime();
const topWealth = [...allUsers].sort((a, b) => Number(b.balance || 0n) - Number(a.balance || 0n)).slice(0, 3); const uptimeHours = Math.floor(uptime / 3600);
const uptimeMinutes = Math.floor((uptime % 3600) / 60);
const ping = AuroraClient.ws.ping;
const now = Math.floor(Date.now() / 1000);
const formatUser = (u: typeof users.$inferSelect, i: number) => { // Guild member count (if available)
const star = i === 0 ? "🌟" : i === 1 ? "⭐" : "✨"; const guild = AuroraClient.guilds.cache.first();
return `${star} <@${u.id}>`; const memberCount = guild?.memberCount ?? totalUsers;
};
const levelText = topLevels.map((u, i) => `> ${formatUser(u, i)} • Lvl ${u.level}`).join("\n") || "> *The sky is empty...*"; // Additional metrics
const wealthText = topWealth.map((u, i) => `> ${formatUser(u, i)}${u.balance} AU`).join("\n") || "> *The sky is empty...*"; const avgLevel = totalUsers > 0
? Math.round(allUsers.reduce((acc, u) => acc + (u.level || 1), 0) / totalUsers)
: 1;
const topStreak = allUsers.reduce((max, u) => Math.max(max, u.dailyStreak || 0), 0);
// 3. Lootdrops Data // Items in circulation
const itemsResult = await DrizzleClient
.select({ total: sql<string>`COALESCE(SUM(${inventory.quantity}), 0)` })
.from(inventory);
const totalItems = Number(itemsResult[0]?.total || 0);
// Last command timestamp
const lastCmd = AuroraClient.lastCommandTimestamp
? `<t:${Math.floor(AuroraClient.lastCommandTimestamp / 1000)}:R>`
: "*Never*";
// Leaderboards
const topLevels = [...allUsers]
.sort((a, b) => (b.level || 0) - (a.level || 0))
.slice(0, 3);
const topWealth = [...allUsers]
.sort((a, b) => Number(b.balance || 0n) - Number(a.balance || 0n))
.slice(0, 3);
// Lootdrops
const activeDrops = await DrizzleClient.query.lootdrops.findMany({ const activeDrops = await DrizzleClient.query.lootdrops.findMany({
where: (lootdrops, { isNull }) => isNull(lootdrops.claimedBy), where: (lootdrops, { isNull }) => isNull(lootdrops.claimedBy),
limit: 1, limit: 1,
@@ -94,65 +136,166 @@ export const terminalService = {
orderBy: desc(lootdrops.createdAt) orderBy: desc(lootdrops.createdAt)
}); });
// --- CONTAINER 1: Header --- // Recent transactions
const headerContainer = new ContainerBuilder()
.addTextDisplayComponents(
new TextDisplayBuilder().setContent("# 🌌 AURORA OBSERVATORY"),
new TextDisplayBuilder().setContent("*Current Moon Phase: Waxing Crescent 🌒*")
);
// --- CONTAINER 2: Observation Log ---
let phenomenaContent = "";
if (activeDrops.length > 0 && activeDrops[0]) {
const drop = activeDrops[0];
phenomenaContent = `\n**SHOOTING STAR DETECTED**\nRadiance: \`${drop.rewardAmount} ${drop.currency}\`\nCoordinates: <#${drop.channelId}>\nImpact: <t:${Math.floor(drop.expiresAt!.getTime() / 1000)}:R>`;
} else if (recentDrops.length > 0 && recentDrops[0]) {
const drop = recentDrops[0];
const claimer = allUsers.find(u => u.id === drop.claimedBy);
phenomenaContent = `\n**RECENT EVENT**\nStar yielded \`${drop.rewardAmount} ${drop.currency}\` to ${claimer ? `<@${claimer.id}>` : '**Unknown**'}`;
}
const logContainer = new ContainerBuilder()
.addTextDisplayComponents(
new TextDisplayBuilder().setContent("## 🔭 OBSERVATION LOG"),
new TextDisplayBuilder().setContent(`> **Stargazers**: \`${totalUsers}\`\n> **Astral Wealth**: \`${totalWealth.toLocaleString()} AU\`${phenomenaContent}`)
);
// --- CONTAINER 3: Leaders ---
const leaderContainer = new ContainerBuilder()
.addTextDisplayComponents(
new TextDisplayBuilder().setContent("## ✨ CONSTELLATION LEADERS"),
new TextDisplayBuilder().setContent(`**Brightest Stars**\n${levelText}`),
new TextDisplayBuilder().setContent(`**Gilded Nebulas**\n${wealthText}`)
);
// --- CONTAINER 4: Echoes ---
const recentTx = await DrizzleClient.query.transactions.findMany({ const recentTx = await DrizzleClient.query.transactions.findMany({
limit: 5, limit: 3,
orderBy: [desc(transactions.createdAt)] orderBy: [desc(transactions.createdAt)]
}); });
const activityLines = recentTx.map(tx => { // ═══════════════════════════════════════════════════════════
const time = Math.floor(tx.createdAt!.getTime() / 1000); // HELPER FORMATTERS
let icon = "💫"; // ═══════════════════════════════════════════════════════════
if (tx.type.includes("LOOT")) icon = "🌠";
if (tx.type.includes("GIFT")) icon = "🌕";
const user = allUsers.find(u => u.id === tx.userId);
// the description might contain a channel id all the way at the end const getMedal = (i: number) => i === 0 ? "🥇" : i === 1 ? "🥈" : "🥉";
const channelId = tx.description?.split(" ").pop() || "";
const text = tx.description?.replace(channelId, "<#" + channelId + ">") || "";
return `<t:${time}:F> ${icon} ${user ? `<@${user.id}>` : '**Unknown**'}: ${text}`;
});
const echoesContainer = new ContainerBuilder() const formatLeaderEntry = (u: typeof users.$inferSelect, i: number, type: 'level' | 'wealth') => {
const medal = getMedal(i);
const value = type === 'level'
? `Lvl ${u.level ?? 1}`
: `${Number(u.balance ?? 0).toLocaleString()} AU`;
return `${medal} **${u.username}** — ${value}`;
};
const getActivityIcon = (type: string) => {
if (type.includes("LOOT")) return "🌠";
if (type.includes("GIFT")) return "🎁";
if (type.includes("SHOP")) return "🛒";
if (type.includes("DAILY")) return "☀️";
if (type.includes("QUEST")) return "📜";
return "💫";
};
// ═══════════════════════════════════════════════════════════
// CONTAINER 1: HEADER - Station Overview
// ═══════════════════════════════════════════════════════════
const botAvatar = AuroraClient.user?.displayAvatarURL({ size: 64 }) ?? "";
const headerSection = new SectionBuilder()
.addTextDisplayComponents( .addTextDisplayComponents(
new TextDisplayBuilder().setContent("## 📡 COSMIC ECHOES"), new TextDisplayBuilder().setContent("# 🔮 AURORA STATION"),
new TextDisplayBuilder().setContent(activityLines.join("\n") || "Silence...") new TextDisplayBuilder().setContent("-# Real-time server observatory")
)
.setThumbnailAccessory(
new ThumbnailBuilder().setURL(botAvatar)
); );
const statsText = [
`📡 **Uptime** ${uptimeHours}h ${uptimeMinutes}m`,
`🏓 **Ping** ${ping}ms`,
`👥 **Students** ${totalUsers}`,
`🪙 **Economy** ${totalWealth.toLocaleString()} AU`
].join(" • ");
return [headerContainer, logContainer, leaderContainer, echoesContainer]; const secondaryStats = [
`📦 **Items** ${totalItems.toLocaleString()}`,
`📈 **Avg Lvl** ${avgLevel}`,
`🔥 **Top Streak** ${topStreak}d`,
`⚡ **Last Cmd** ${lastCmd}`
].join(" • ");
const headerContainer = new ContainerBuilder()
.setAccentColor(COLORS.HEADER)
.addSectionComponents(headerSection)
.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small))
.addTextDisplayComponents(
new TextDisplayBuilder().setContent(statsText),
new TextDisplayBuilder().setContent(secondaryStats),
new TextDisplayBuilder().setContent(`-# Updated <t:${now}:R>`)
);
// ═══════════════════════════════════════════════════════════
// CONTAINER 2: LEADERBOARDS
// ═══════════════════════════════════════════════════════════
const levelLeaderText = topLevels.length > 0
? topLevels.map((u, i) => formatLeaderEntry(u, i, 'level')).join("\n")
: "*No data yet*";
const wealthLeaderText = topWealth.length > 0
? topWealth.map((u, i) => formatLeaderEntry(u, i, 'wealth')).join("\n")
: "*No data yet*";
const leadersContainer = new ContainerBuilder()
.setAccentColor(COLORS.LEADERS)
.addTextDisplayComponents(
new TextDisplayBuilder().setContent("## ✨ CONSTELLATION LEADERS")
)
.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small))
.addTextDisplayComponents(
new TextDisplayBuilder().setContent(`**Brightest Stars**\n${levelLeaderText}`),
new TextDisplayBuilder().setContent(`**Gilded Nebulas**\n${wealthLeaderText}`)
);
// ═══════════════════════════════════════════════════════════
// CONTAINER 3: LIVE ACTIVITY
// ═══════════════════════════════════════════════════════════
// Determine if there's an active lootdrop
const hasActiveDrop = activeDrops.length > 0 && activeDrops[0];
const activityColor = hasActiveDrop ? COLORS.ALERT : COLORS.ACTIVITY;
const activityContainer = new ContainerBuilder()
.setAccentColor(activityColor)
.addTextDisplayComponents(
new TextDisplayBuilder().setContent("## 🌠 LIVE ACTIVITY")
)
.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
// Active lootdrop or recent event
if (hasActiveDrop) {
const drop = activeDrops[0]!;
const expiresTimestamp = Math.floor(drop.expiresAt!.getTime() / 1000);
activityContainer.addTextDisplayComponents(
new TextDisplayBuilder().setContent(
`🚨 **SHOOTING STAR ACTIVE**\n` +
`> **Reward:** \`${drop.rewardAmount} ${drop.currency}\`\n` +
`> **Location:** <#${drop.channelId}>\n` +
`> **Expires:** <t:${expiresTimestamp}:R>`
)
);
} else if (recentDrops.length > 0 && recentDrops[0]) {
const drop = recentDrops[0];
const claimer = allUsers.find(u => u.id === drop.claimedBy);
const claimedTimestamp = drop.createdAt ? Math.floor(drop.createdAt.getTime() / 1000) : now;
activityContainer.addTextDisplayComponents(
new TextDisplayBuilder().setContent(
`✅ **Last Star Claimed**\n` +
`> **${claimer?.username ?? 'Unknown'}** collected \`${drop.rewardAmount} ${drop.currency}\` <t:${claimedTimestamp}:R>`
)
);
} else {
activityContainer.addTextDisplayComponents(
new TextDisplayBuilder().setContent(`-# The sky is quiet... waiting for the next star.`)
);
}
// Recent transactions
if (recentTx.length > 0) {
activityContainer.addSeparatorComponents(
new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)
);
const txLines = recentTx.map(tx => {
const time = Math.floor(tx.createdAt!.getTime() / 1000);
const icon = getActivityIcon(tx.type);
const user = allUsers.find(u => u.id === tx.userId);
// Clean description (remove trailing channel IDs)
let desc = tx.description || "Unknown";
desc = desc.replace(/\s*\d{17,19}\s*$/, "").trim();
return `${icon} **${user?.username ?? 'Unknown'}**: ${desc} · <t:${time}:R>`;
});
activityContainer.addTextDisplayComponents(
new TextDisplayBuilder().setContent("**Recent Echoes**"),
new TextDisplayBuilder().setContent(txLines.join("\n"))
);
}
return [headerContainer, leadersContainer, activityContainer];
} }
}; };

View File

@@ -4,6 +4,7 @@ import { inventoryService } from "@/modules/inventory/inventory.service";
import { itemTransactions } from "@/db/schema"; import { itemTransactions } from "@/db/schema";
import { withTransaction } from "@/lib/db"; import { withTransaction } from "@/lib/db";
import type { Transaction } from "@/lib/types"; import type { Transaction } from "@/lib/types";
import { TransactionType, ItemTransactionType } from "@/lib/constants";
// Module-level session storage // Module-level session storage
const sessions = new Map<string, TradeSession>(); const sessions = new Map<string, TradeSession>();
@@ -25,7 +26,7 @@ const processTransfer = async (tx: Transaction, from: TradeParticipant, to: Trad
await economyService.modifyUserBalance( await economyService.modifyUserBalance(
from.id, from.id,
-from.offer.money, -from.offer.money,
'TRADE_OUT', TransactionType.TRADE_OUT,
`Trade with ${to.username} (Thread: ${threadId})`, `Trade with ${to.username} (Thread: ${threadId})`,
to.id, to.id,
tx tx
@@ -33,7 +34,7 @@ const processTransfer = async (tx: Transaction, from: TradeParticipant, to: Trad
await economyService.modifyUserBalance( await economyService.modifyUserBalance(
to.id, to.id,
from.offer.money, from.offer.money,
'TRADE_IN', TransactionType.TRADE_IN,
`Trade with ${from.username} (Thread: ${threadId})`, `Trade with ${from.username} (Thread: ${threadId})`,
from.id, from.id,
tx tx
@@ -54,7 +55,7 @@ const processTransfer = async (tx: Transaction, from: TradeParticipant, to: Trad
relatedUserId: BigInt(to.id), relatedUserId: BigInt(to.id),
itemId: item.id, itemId: item.id,
quantity: -item.quantity, quantity: -item.quantity,
type: 'TRADE_OUT', type: ItemTransactionType.TRADE_OUT,
description: `Traded to ${to.username}`, description: `Traded to ${to.username}`,
}); });
@@ -64,7 +65,7 @@ const processTransfer = async (tx: Transaction, from: TradeParticipant, to: Trad
relatedUserId: BigInt(from.id), relatedUserId: BigInt(from.id),
itemId: item.id, itemId: item.id,
quantity: item.quantity, quantity: item.quantity,
type: 'TRADE_IN', type: ItemTransactionType.TRADE_IN,
description: `Received from ${from.username}`, description: `Received from ${from.username}`,
}); });
} }

View File

@@ -1,8 +1,9 @@
import { userTimers } from "@/db/schema"; import { userTimers } from "@/db/schema";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient"; import { DrizzleClient } from "@/lib/DrizzleClient";
import { TimerType } from "@/lib/constants";
export type TimerType = 'COOLDOWN' | 'EFFECT' | 'ACCESS'; export { TimerType };
export const userTimerService = { export const userTimerService = {
/** /**

108
src/web/public/script.js Normal file
View File

@@ -0,0 +1,108 @@
function formatUptime(seconds) {
if (seconds < 0) return "0s";
const days = Math.floor(seconds / (3600 * 24));
const hours = Math.floor((seconds % (3600 * 24)) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
const parts = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0) parts.push(`${minutes}m`);
parts.push(`${secs}s`);
return parts.join(" ");
}
function updateUptime() {
const el = document.getElementById("uptime-display");
if (!el) return;
const startTimestamp = parseInt(el.getAttribute("data-start-timestamp"), 10);
if (isNaN(startTimestamp)) return;
const now = Date.now();
const elapsedSeconds = (now - startTimestamp) / 1000;
el.textContent = formatUptime(elapsedSeconds);
}
document.addEventListener("DOMContentLoaded", () => {
// Update immediately to prevent stale content flash if possible
updateUptime();
// Update every second
setInterval(updateUptime, 1000);
// WebSocket Connection
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/ws`;
function connectWs() {
const ws = new WebSocket(wsUrl);
const statusIndicator = document.querySelector(".status-indicator");
ws.onopen = () => {
console.log("WS Connected");
if (statusIndicator) statusIndicator.classList.add("online");
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === "HEARTBEAT") {
console.log("Heartbeat:", msg.data);
// Sync uptime?
// We can optionally verify if client clock is drifting, but let's keep it simple.
} else if (msg.type === "WELCOME") {
console.log(msg.message);
} else if (msg.type === "LOG") {
appendToActivityFeed(msg.data);
}
} catch (e) {
console.error("WS Parse Error", e);
}
};
function appendToActivityFeed(log) {
const list = document.querySelector(".activity-feed");
if (!list) return;
const item = document.createElement("li");
item.className = `activity-item ${log.type}`;
const timeSpan = document.createElement("span");
timeSpan.className = "time";
timeSpan.textContent = log.timestamp;
const messageSpan = document.createElement("span");
messageSpan.className = "message";
messageSpan.textContent = log.message;
item.appendChild(timeSpan);
item.appendChild(messageSpan);
// Prepend to top
list.insertBefore(item, list.firstChild);
// Limit history
if (list.children.length > 50) {
list.removeChild(list.lastChild);
}
}
ws.onclose = () => {
console.log("WS Disconnected");
if (statusIndicator) statusIndicator.classList.remove("online");
// Retry in 5s
setTimeout(connectWs, 5000);
};
ws.onerror = (err) => {
console.error("WS Error", err);
ws.close();
};
}
connectWs();
});

607
src/web/public/style.css Normal file
View File

@@ -0,0 +1,607 @@
:root {
/* Color Palette - HSL (Hue, Saturation, Lightness) */
/* Primary (Aurora Cyan) */
--primary-h: 180;
--primary-s: 100%;
--primary-l: 50%;
--primary: hsl(var(--primary-h), var(--primary-s), var(--primary-l));
/* Secondary (Aurora Purple) */
--secondary-h: 270;
--secondary-s: 100%;
--secondary-l: 65%;
--secondary: hsl(var(--secondary-h), var(--secondary-s), var(--secondary-l));
/* Backgrounds (Dark Slate) */
--bg-h: 222;
--bg-s: 47%;
--bg-l: 7%;
/* Very Dark */
--bg-color: hsl(var(--bg-h), var(--bg-s), var(--bg-l));
--card-bg-h: 217;
--card-bg-s: 33%;
--card-bg-l: 15%;
--card-bg: hsl(var(--card-bg-h), var(--card-bg-s), var(--card-bg-l));
/* Text */
--text-main: hsl(210, 40%, 98%);
--text-muted: hsl(215, 20%, 65%);
--text-accent: var(--primary);
/* Borders */
--border-color: hsl(215, 25%, 25%);
/* Typography */
--font-heading: 'Outfit', system-ui, sans-serif;
--font-body: 'Inter', system-ui, sans-serif;
/* Spacing & Radii */
--radius-md: 0.75rem;
--radius-lg: 1rem;
--header-height: 4rem;
/* Effects */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.2), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-glow: 0 0 15px hsla(var(--primary-h), var(--primary-s), 50%, 0.15);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
background-color: var(--bg-color);
color: var(--text-main);
font-family: var(--font-body);
margin: 0;
line-height: 1.6;
display: flex;
flex-direction: column;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-heading);
margin-top: 0;
line-height: 1.2;
color: var(--text-main);
}
h1 {
font-weight: 700;
}
/* Header */
header {
background: rgba(15, 23, 42, 0.8);
/* Semi-transparent */
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border-color);
height: var(--header-height);
padding: 0 2rem;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 50;
}
header h1 {
font-size: 1.5rem;
margin: 0;
background: linear-gradient(135deg, var(--primary), var(--secondary));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.02em;
}
header nav a {
color: var(--text-muted);
text-decoration: none;
font-weight: 500;
margin-left: 1.5rem;
transition: color 0.15s ease;
font-size: 0.95rem;
}
header nav a:hover {
color: var(--primary);
}
/* Main Layout */
main {
flex: 1;
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
/* Card Component */
.card {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 2rem;
margin-bottom: 1.5rem;
box-shadow: var(--shadow-md);
position: relative;
overflow: hidden;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-glow), var(--shadow-md);
border-color: hsla(var(--primary-h), var(--primary-s), 50%, 0.3);
}
.card h2 {
font-size: 1.25rem;
margin-bottom: 1rem;
color: var(--text-main);
display: flex;
align-items: center;
gap: 0.5rem;
}
.card p {
color: var(--text-muted);
margin-bottom: 0;
font-size: 0.95rem;
}
/* Links */
a {
color: var(--primary);
text-decoration: none;
transition: opacity 0.2s;
}
a:hover {
opacity: 0.8;
}
/* Buttons (Future Proofing) */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
font-weight: 600;
font-family: var(--font-heading);
cursor: pointer;
transition: all 0.2s ease;
border: none;
font-size: 0.9rem;
text-decoration: none;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary), hsl(var(--primary-h), 90%, 45%));
color: #000;
/* Contrast text on Cyan */
box-shadow: 0 4px 6px -1px hsla(var(--primary-h), var(--primary-s), 50%, 0.2);
}
.btn-primary:hover {
filter: brightness(1.1);
box-shadow: 0 6px 8px -1px hsla(var(--primary-h), var(--primary-s), 50%, 0.3);
}
/* Forms & Inputs */
input[type="text"],
input[type="email"],
input[type="password"],
textarea,
select {
width: 100%;
padding: 0.75rem 1rem;
background-color: rgba(15, 23, 42, 0.5);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
color: var(--text-main);
font-family: var(--font-body);
font-size: 0.95rem;
transition: all 0.2s;
}
input:focus,
textarea:focus,
select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px hsla(var(--primary-h), var(--primary-s), 50%, 0.2);
background-color: rgba(15, 23, 42, 0.8);
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
}
th {
text-align: left;
padding: 1rem;
background-color: rgba(15, 23, 42, 0.5);
color: var(--text-muted);
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--border-color);
}
td {
padding: 1rem;
border-bottom: 1px solid #1e293b;
/* Fallback or specific border */
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
color: var(--text-main);
}
tr:last-child td {
border-bottom: none;
}
tr:hover td {
background-color: rgba(255, 255, 255, 0.02);
}
/* Footer */
footer {
padding: 2rem;
text-align: center;
color: var(--text-muted);
font-size: 0.875rem;
border-top: 1px solid var(--border-color);
background: var(--bg-color);
}
/* Utilities */
.text-gradient {
background: linear-gradient(135deg, var(--primary), var(--secondary));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* Animations & Micro-Interactions */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Entry Animations */
.fade-in {
animation: fadeIn 0.4s ease-out forwards;
}
/* Stagger animations for children using nth-child */
main>* {
opacity: 0;
/* Initially hidden */
animation: slideUp 0.5s ease-out forwards;
}
main>*:nth-child(1) {
animation-delay: 0.1s;
}
main>*:nth-child(2) {
animation-delay: 0.2s;
}
main>*:nth-child(3) {
animation-delay: 0.3s;
}
main>*:nth-child(4) {
animation-delay: 0.4s;
}
/* Dynamic Background */
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background:
radial-gradient(circle at 15% 50%, hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.08), transparent 25%),
radial-gradient(circle at 85% 30%, hsla(var(--secondary-h), var(--secondary-s), var(--secondary-l), 0.08), transparent 25%);
z-index: -1;
pointer-events: none;
}
/* Link Interactions */
a {
position: relative;
transition: color 0.2s ease, opacity 0.2s ease;
}
header nav a::after {
content: '';
position: absolute;
bottom: -4px;
left: 0;
width: 0%;
height: 2px;
background: var(--primary);
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
header nav a:hover::after {
width: 100%;
}
/* Accessibility: Reduced Motion */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Mobile Responsiveness */
@media (max-width: 768px) {
:root {
--header-height: 3.5rem;
/* Compact header on mobile */
}
body {
font-size: 14px;
/* Slightly smaller base font */
}
/* Layout Adjustments */
header {
padding: 0 1rem;
}
header nav a {
margin-left: 1rem;
font-size: 0.9rem;
}
main {
padding: 1rem;
width: 100%;
max-width: 100%;
}
/* Typography Scaling */
h1 {
font-size: 1.75rem;
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1.25rem;
}
/* Card Adjustments */
.card {
padding: 1.25rem;
border-radius: var(--radius-md);
/* Slightly smaller radius */
}
/* Stack flex containers if needed (general util) */
.flex-col-mobile {
flex-direction: column !important;
}
/* Touch Targets */
.btn,
a,
input,
select {
min-height: 44px;
/* Compliance with touch target guidelines */
}
/* Horizontal scroll for wide tables */
.table-container {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
margin-left: -1rem;
margin-right: -1rem;
padding-left: 1rem;
padding-right: 1rem;
}
}
/* Dashboard Layout */
.dashboard-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
padding: 1.5rem;
border-radius: var(--radius-lg);
text-align: center;
box-shadow: var(--shadow-sm);
}
.stat-card h3 {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.stat-card .stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--text-main);
font-family: var(--font-heading);
}
.dashboard-main {
grid-column: 1 / -1;
display: grid;
grid-template-columns: 2fr 1fr;
gap: 1.5rem;
}
.panel {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 1.5rem;
display: flex;
flex-direction: column;
}
.panel.control-panel {
grid-column: 1 / -1;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.75rem;
}
.panel-header h2 {
font-size: 1.1rem;
margin: 0;
}
/* Activity Feed */
.activity-feed {
list-style: none;
padding: 0;
margin: 0;
max-height: 300px;
overflow-y: auto;
}
.activity-item {
display: flex;
gap: 1rem;
padding: 0.75rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
font-size: 0.9rem;
}
.activity-item .time {
color: var(--text-muted);
font-family: monospace;
}
.activity-item.info .message { color: var(--text-main); }
.activity-item.success .message { color: hsl(150, 60%, 45%); }
.activity-item.warning .message { color: hsl(35, 90%, 60%); }
.activity-item.error .message { color: hsl(0, 80%, 60%); }
.badge.live {
background: hsla(0, 100%, 50%, 0.2);
color: hsl(0, 100%, 60%);
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.7rem;
font-weight: bold;
text-transform: uppercase;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
/* Mock Chart */
.mock-chart-container {
height: 200px;
display: flex;
align-items: flex-end;
gap: 4px;
padding-top: 1rem;
border-bottom: 1px solid var(--border-color);
margin-bottom: 0.5rem;
}
.mock-chart-bar {
flex: 1;
background: var(--primary);
opacity: 0.5;
border-radius: 2px 2px 0 0;
transition: height 0.5s ease;
}
.mock-chart-bar:hover {
opacity: 0.8;
}
.metrics-legend {
font-size: 0.8rem;
color: var(--text-muted);
text-align: center;
}
/* Responsive Dashboard */
@media (max-width: 768px) {
.dashboard-grid {
grid-template-columns: 1fr 1fr; /* 2 columns on tablet/mobile */
}
.dashboard-main {
grid-template-columns: 1fr; /* Stack panels */
}
}

62
src/web/router.test.ts Normal file
View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from "bun:test";
import { router } from "./router";
describe("Web Router", () => {
it("should return home page on /", async () => {
const req = new Request("http://localhost/");
const res = await router(req);
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("text/html");
const text = await res.text();
expect(text).toContain("Aurora Web");
expect(text).toContain("Uptime:");
expect(text).toContain('id="uptime-display"');
});
it("should return dashboard page on /dashboard", async () => {
const req = new Request("http://localhost/dashboard");
const res = await router(req);
expect(res.status).toBe(200);
expect(await res.text()).toContain("Live Activity");
});
it("should return health check on /health", async () => {
const req = new Request("http://localhost/health");
const res = await router(req);
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("application/json");
const data = await res.json();
expect(data).toHaveProperty("status", "ok");
});
it("should block path traversal", async () => {
// Attempts to go up two directories to reach the project root or src
const req = new Request("http://localhost/public/../../package.json");
const res = await router(req);
// Should be 403 Forbidden or 404 Not Found (our logical change makes it 403)
expect([403, 404]).toContain(res.status);
});
it("should serve existing static file", async () => {
// We know style.css exists in src/web/public
const req = new Request("http://localhost/public/style.css");
const res = await router(req);
expect(res.status).toBe(200);
if (res.status === 200) {
const text = await res.text();
expect(text).toContain("body");
}
});
it("should not serve static files on non-GET methods", async () => {
const req = new Request("http://localhost/public/style.css", { method: "POST" });
const res = await router(req);
expect(res.status).toBe(404);
});
it("should return 404 for unknown routes", async () => {
const req = new Request("http://localhost/unknown");
const res = await router(req);
expect(res.status).toBe(404);
});
});

49
src/web/router.ts Normal file
View File

@@ -0,0 +1,49 @@
import { homeRoute } from "./routes/home";
import { healthRoute } from "./routes/health";
import { dashboardRoute } from "./routes/dashboard";
import { file } from "bun";
import { join, resolve } from "path";
export async function router(request: Request): Promise<Response> {
const url = new URL(request.url);
const method = request.method;
// Resolve the absolute path to the public directory
const publicDir = resolve(import.meta.dir, "public");
if (method === "GET") {
// Handle Static Files
// We handle requests starting with /public/ OR containing an extension (like /style.css)
if (url.pathname.startsWith("/public/") || url.pathname.includes(".")) {
// Normalize path: remove /public prefix if present so that
// /public/style.css and /style.css both map to .../public/style.css
const relativePath = url.pathname.replace(/^\/public/, "");
// Resolve full path
const normalizedRelative = relativePath.startsWith("/") ? "." + relativePath : relativePath;
const requestedPath = resolve(publicDir, normalizedRelative);
// Security Check: Block Path Traversal
if (requestedPath.startsWith(publicDir)) {
const staticFile = file(requestedPath);
if (await staticFile.exists()) {
return new Response(staticFile);
}
} else {
return new Response("Forbidden", { status: 403 });
}
}
if (url.pathname === "/" || url.pathname === "/index.html") {
return homeRoute();
}
if (url.pathname === "/health") {
return healthRoute();
}
if (url.pathname === "/dashboard") {
return dashboardRoute();
}
}
return new Response("Not Found", { status: 404 });
}

105
src/web/routes/dashboard.ts Normal file
View File

@@ -0,0 +1,105 @@
import { BaseLayout } from "../views/layout";
import { AuroraClient } from "@/lib/BotClient";
import { getRecentLogs } from "@/lib/logger";
export function dashboardRoute(): Response {
// Gather real data
const guildCount = AuroraClient.guilds.cache.size;
const userCount = AuroraClient.guilds.cache.reduce((acc, guild) => acc + guild.memberCount, 0);
const commandCount = AuroraClient.commands.size;
const ping = AuroraClient.ws.ping;
// Real system metrics
const memoryUsage = (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(2);
const uptimeSeconds = process.uptime();
const uptime = new Date(uptimeSeconds * 1000).toISOString().substr(11, 8); // HH:MM:SS
// Real activity logs
const activityLogs = getRecentLogs();
const content = `
<div class="dashboard-grid">
<!-- Top Stats Row -->
<div class="stat-card">
<h3>Servers</h3>
<div class="stat-value">${guildCount}</div>
</div>
<div class="stat-card">
<h3>Users</h3>
<div class="stat-value">${userCount}</div>
</div>
<div class="stat-card">
<h3>Commands</h3>
<div class="stat-value">${commandCount}</div>
</div>
<div class="stat-card">
<h3>Ping</h3>
<div class="stat-value">${ping < 0 ? "?" : ping}ms</div>
</div>
<!-- Main Content Area -->
<div class="dashboard-main">
<div class="panel activity-panel">
<div class="panel-header">
<h2>Live Activity</h2>
<span class="badge live">LIVE</span>
</div>
<ul class="activity-feed">
${activityLogs.length > 0 ? activityLogs.map(log => `
<li class="activity-item ${log.type}">
<span class="time">${log.time}</span>
<span class="message">${log.message}</span>
</li>
`).join('') : `
<li class="activity-item info"><span class="time">--:--:--</span> <span class="message">No recent activity.</span></li>
`}
</ul>
</div>
<div class="panel metrics-panel">
<div class="panel-header">
<h2>System Health</h2>
</div>
<div class="metrics-grid">
<div class="metric-item">
<span class="metric-label">Uptime</span>
<span class="metric-value">${uptime}</span>
</div>
<div class="metric-item">
<span class="metric-label">Memory (Heap)</span>
<span class="metric-value">${memoryUsage} MB</span>
</div>
<div class="metric-item">
<span class="metric-label">Node Version</span>
<span class="metric-value">${process.version}</span>
</div>
<div class="metric-item">
<span class="metric-label">Platform</span>
<span class="metric-value">${process.platform}</span>
</div>
</div>
</div>
</div>
<!-- Control Panel -->
<div class="panel control-panel">
<div class="panel-header">
<h2>Quick Actions</h2>
</div>
<div class="action-buttons">
<button class="btn btn-secondary" disabled>Clear Cache</button>
<button class="btn btn-secondary" disabled>Reload Commands</button>
<button class="btn btn-danger" disabled>Restart Bot</button>
</div>
</div>
</div>
`;
const html = BaseLayout({ title: "Dashboard", content });
return new Response(html, {
headers: { "Content-Type": "text/html" },
});
}

9
src/web/routes/health.ts Normal file
View File

@@ -0,0 +1,9 @@
export function healthRoute(): Response {
return new Response(JSON.stringify({
status: "ok",
uptime: process.uptime(),
timestamp: new Date().toISOString()
}), {
headers: { "Content-Type": "application/json" },
});
}

16
src/web/routes/home.ts Normal file
View File

@@ -0,0 +1,16 @@
import { BaseLayout } from "../views/layout";
export function homeRoute(): Response {
const content = `
<div class="card">
<h2>Welcome</h2>
<p>The Aurora web server is up and running!</p>
</div>
`;
const html = BaseLayout({ title: "Home", content });
return new Response(html, {
headers: { "Content-Type": "text/html" },
});
}

85
src/web/server.ts Normal file
View File

@@ -0,0 +1,85 @@
import { env } from "@/lib/env";
import { router } from "./router";
import type { Server } from "bun";
export class WebServer {
private static server: Server<unknown> | null = null;
private static heartbeatInterval: ReturnType<typeof setInterval> | null = null;
public static start(port?: number) {
this.server = Bun.serve({
port: port ?? (typeof env.PORT === "string" ? parseInt(env.PORT) : 3000),
fetch: (req, server) => {
const url = new URL(req.url);
if (url.pathname === "/ws") {
// Upgrade the request to a WebSocket
// We pass dummy data for now
if (server.upgrade(req, { data: undefined })) {
return undefined;
}
return new Response("WebSocket upgrade failed", { status: 500 });
}
return router(req);
},
websocket: {
open(ws) {
// console.log("ws: client connected");
ws.subscribe("status-updates");
ws.send(JSON.stringify({ type: "WELCOME", message: "Connected to Aurora WebSocket" }));
},
message(ws, message) {
// Handle incoming messages if needed
},
close(ws) {
// console.log("ws: client disconnected");
ws.unsubscribe("status-updates");
},
},
});
console.log(`🌐 Web server listening on http://localhost:${this.server.port}`);
// Start a heartbeat loop
this.heartbeatInterval = setInterval(() => {
if (this.server) {
const uptime = process.uptime();
this.server.publish("status-updates", JSON.stringify({
type: "HEARTBEAT",
data: {
uptime,
timestamp: Date.now()
}
}));
}
}, 5000);
}
public static stop() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
if (this.server) {
this.server.stop();
console.log("🛑 Web server stopped");
this.server = null;
}
}
public static get port(): number | undefined {
return this.server?.port;
}
public static broadcastLog(type: string, message: string) {
if (this.server) {
this.server.publish("status-updates", JSON.stringify({
type: "LOG",
data: {
timestamp: new Date().toLocaleTimeString(),
type,
message
}
}));
}
}
}

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "bun:test";
import { formatUptime } from "./format";
describe("formatUptime", () => {
it("formats seconds correctly", () => {
expect(formatUptime(45)).toBe("45s");
});
it("formats minutes and seconds", () => {
expect(formatUptime(65)).toBe("1m 5s");
});
it("formats hours, minutes, and seconds", () => {
expect(formatUptime(3665)).toBe("1h 1m 5s");
});
it("formats days correctly", () => {
expect(formatUptime(90061)).toBe("1d 1h 1m 1s");
});
it("handles zero", () => {
expect(formatUptime(0)).toBe("0s");
});
});

20
src/web/utils/format.ts Normal file
View File

@@ -0,0 +1,20 @@
/**
* Formats a duration in seconds into a human-readable string.
* Example: 3665 -> "1h 1m 5s"
*/
export function formatUptime(seconds: number): string {
if (seconds < 0) return "0s";
const days = Math.floor(seconds / (3600 * 24));
const hours = Math.floor((seconds % (3600 * 24)) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
const parts = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0) parts.push(`${minutes}m`);
parts.push(`${secs}s`);
return parts.join(" ");
}

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from "bun:test";
import { escapeHtml } from "./html";
describe("HTML Utils", () => {
it("should escape special characters", () => {
const unsafe = '<script>alert("xss")</script>';
const safe = escapeHtml(unsafe);
expect(safe).toBe("&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;");
});
it("should handle mixed content", () => {
const unsafe = 'Hello & "World"';
const safe = escapeHtml(unsafe);
expect(safe).toBe("Hello &amp; &quot;World&quot;");
});
});

14
src/web/utils/html.ts Normal file
View File

@@ -0,0 +1,14 @@
/**
* Escapes unsafe characters in a string to prevent XSS.
* @param unsafe - The raw string to escape.
* @returns The escaped string safe for HTML insertion.
*/
export function escapeHtml(unsafe: string): string {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}

54
src/web/views/layout.ts Normal file
View File

@@ -0,0 +1,54 @@
import { escapeHtml } from "../utils/html";
import { formatUptime } from "../utils/format";
interface LayoutProps {
title: string;
content: string;
}
export function BaseLayout({ title, content }: LayoutProps): string {
const safeTitle = escapeHtml(title);
// Calculate uptime for the footer
const uptimeSeconds = process.uptime();
const startTimestamp = Date.now() - (uptimeSeconds * 1000);
const initialUptimeString = formatUptime(uptimeSeconds);
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${safeTitle} | Aurora</title>
<link rel="stylesheet" href="/style.css">
<meta name="description" content="Aurora Bot Web Interface">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Outfit:wght@500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<header>
<h1>Aurora Web</h1>
<nav>
<a href="/">Home</a>
<a href="/dashboard">Dashboard</a>
</nav>
</header>
<main>
${content}
</main>
<footer>
<div class="footer-content">
<p>&copy; ${new Date().getFullYear()} Aurora Bot</p>
<div class="footer-status">
<span class="status-indicator online">●</span>
<span>System Operational</span>
<span class="separator">|</span>
<span>Uptime: <span id="uptime-display" data-start-timestamp="${Math.floor(startTimestamp)}">${initialUptimeString}</span></span>
</div>
</div>
</footer>
<script src="/script.js" defer></script>
</body>
</html>`;
}

46
src/web/websocket.test.ts Normal file
View File

@@ -0,0 +1,46 @@
import { describe, expect, it, afterAll, beforeAll } from "bun:test";
import { WebServer } from "./server";
describe("WebSocket Server", () => {
// Start server on a random port
const port = 0;
beforeAll(() => {
WebServer.start(port);
});
afterAll(() => {
WebServer.stop();
});
it("should accept websocket connection and send welcome message", async () => {
const port = WebServer.port;
expect(port).toBeDefined();
const ws = new WebSocket(`ws://localhost:${port}/ws`);
const messagePromise = new Promise<any>((resolve) => {
ws.onmessage = (event) => {
resolve(JSON.parse(event.data as string));
};
});
const msg = await messagePromise;
expect(msg.type).toBe("WELCOME");
expect(msg.message).toContain("Connected");
ws.close();
});
it("should reject non-ws upgrade requests on /ws endpoint via http", async () => {
const port = WebServer.port;
// Just a normal fetch to /ws should fail with 426 Upgrade Required usually,
// but our implementation returns "WebSocket upgrade failed" 500 or undefined -> 101 Switching Protocols if valid.
// If we send a normal GET request to /ws without Upgrade headers, server.upgrade(req) returns false.
// So it returns status 500 "WebSocket upgrade failed" based on our code.
const res = await fetch(`http://localhost:${port}/ws`);
expect(res.status).toBe(500);
expect(await res.text()).toBe("WebSocket upgrade failed");
});
});

View File

@@ -0,0 +1,52 @@
# 2026-01-07-replace-mock-dashboard-data.md: Replace Mock Dashboard Data with Live Telemetry
**Status:** Done
**Created:** 2026-01-07
**Tags:** dashboard, telemetry, logging, database
## 1. Context & User Story
* **As a:** Bot Administrator
* **I want to:** see actual system logs, real-time resource usage, and accurate database statistics on the web dashboard
* **So that:** I can monitor the true health and activity of the Aurora application without checking the terminal or database manually.
## 2. Technical Requirements
### Data Model Changes
- [ ] No strict database schema changes required, but may need a cohesive `LogService` or in-memory buffer to store recent "Activity" events for the dashboard history.
### API / Interface
- **Dashboard Route (`src/web/routes/dashboard.ts`):**
- [x] Replace `mockedActivity` array with a fetch from a real log buffer/source.
- [x] Replace `userCount` approximation with a precise count from `UserService` or `AuroraClient`.
- [x] Replace "System Metrics" mock bars with real values (RAM usage, Uptime, CPU load if possible).
- **Log Source:**
- [x] Implement a mechanism (e.g., specific `Logger` transport or `WebServer` static buffer) to capture the last ~50 distinct application events (commands, errors, warnings) for display.
- [ ] (Optional) If "Docker Compose Logs" are strictly required, implement a file reader for the standard output log file if accessible, otherwise rely on internal application logging.
### Real Data Integration
- **Activity Feed:** Must show actual commands executed, system errors, and startup events.
- **Top Stats:** Ensure `Servers`, `Users`, `Commands`, and `Ping` come from the live `AuroraClient` instance.
- **Metrics:** Display `process.memoryUsage().heapUsed` converted to MB. Display `process.uptime()`.
## 3. Constraints & Validations (CRITICAL)
- **Performance:** Fetching logs or stats must not block the event loop. Avoid heavy DB queries on every dashboard refresh; cache stats if necessary (e.g., via `setInterval` in background).
- **Security:** Do not expose sensitive data (tokens, raw SQL) in the activity feed.
- **Fallbacks:** If data is unavailable (e.g., client not ready), show "Loading..." or a neutral placeholder, not fake data.
## 4. Acceptance Criteria
1. [x] The "Activity Feed" on the dashboard displays real, recent events that occurred in the application (e.g., "Bot started", "Command /ping executed").
2. [x] The "System Metrics" section displays a visual representation (or text) of **actual** memory usage and uptime.
3. [x] The hardcoded `mockedActivity` array is removed from `dashboard.ts`.
4. [x] Refreshing the dashboard page updates the metrics and feed with the latest data.
## 5. Implementation Plan
- [x] Step 1: Create a simple in-memory `LogBuffer` in `src/lib/logger.ts` (or similar) to keep the last 50 logs.
- [x] Step 2: Hook this buffer into the existing logging system (or add manual pushes in `command.handler.ts` etc).
- [x] Step 3: Implement `getSystemMetrics()` helper to return formatted RAM/CPU data.
- [x] Step 4: Update `src/web/routes/dashboard.ts` to import the log buffer and metrics helper.
- [x] Step 5: Replace the HTML template variables with these real data sources.
## Implementation Notes
- **Log Buffer**: Added a 50-item rolling buffer in `src/lib/logger.ts` exposing `getRecentLogs()`.
- **Dashboard Update**: `src/web/routes/dashboard.ts` now uses `AuroraClient` stats and `process` metrics (Uptime, Memory) directly.
- **Tests**: Added `src/lib/logger.test.ts` to verify buffer logic.