diff --git a/.dockerignore b/.dockerignore index cf4aefb..84be385 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,6 +17,10 @@ logs/ # Environment files .env.example +config.example.toml +master_doc_follow_1.md +README.md +ARCHITECTURE.md # Misc GEM/ diff --git a/.env.example b/.env.example index 23dda02..5302fae 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,24 @@ -# Elly Discord Bot Environment Variables -# ======================================== +# Elly Discord Bot — Environment Variables +# ========================================= +# +# Copy this file to `.env` and fill in real values. Both `@elly/core` and +# `@elly/bot` validate their environment with Zod at boot — missing or +# malformed values will abort the process before any side effects occur. -# Discord Bot Token (required) -# Get this from https://discord.com/developers/applications +# ---- Shared (both crates) ---- +# Controls log formatting and other dev-vs-prod behaviour. +NODE_ENV=development + +# Optional: override the level from [logging] in config.toml. +# One of: debug | info | warn | error | fatal +# LOG_LEVEL=debug + +# ---- @elly/bot ---- +# Discord bot token. Get it from https://discord.com/developers/applications. DISCORD_TOKEN=your_discord_bot_token_here + +# ---- IPC (shared between @elly/core and @elly/bot) ---- +# Shared secret used to authenticate Bot -> Core HTTP requests. +# Must be the same value in both processes. Use a long random string. +# Generate one with: `openssl rand -hex 32` +IPC_TOKEN=change-me-to-a-long-random-secret-of-at-least-16-chars diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..5f11163 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,363 @@ +# Elly Discord Bot — Architecture + +## Runtime & Tooling + +- **Runtime:** Deno (compilerOptions in `deno.json`: strict, esnext, deno.window). +- **Dependencies via Deno imports map (`deno.json`):** + - `discord.js` → `npm:discord.js@^14.14.1` + - `@discordjs/rest` → `npm:@discordjs/rest@^2.2.0` + - `@toml-tools/parser` → `npm:@toml-tools/parser@^1.0.0` (declared but unused; project ships its own TOML parser) + - SQLite via `jsr:@db/sqlite@0.12` (imported directly in `src/database/sqlite.ts`). +- **Tasks:** `start`, `dev` (with `--watch`), `check`, `lint`, `fmt`. Required permissions: `--allow-net`, `--allow-read`, `--allow-write`, `--allow-env`, `--allow-ffi --unstable-ffi` (FFI is needed for `@db/sqlite`). +- **Container:** `Dockerfile` builds on `denoland/deno:latest`, runs `deno install --frozen`, copies `src/`, `config.toml`, `.env`, creates `/app/data` and `/app/logs`, then runs `src/index.ts`. `docker-compose.yml` mounts `./data`, `./logs`, `./config.toml` (read-only) into the container. +- **Secrets:** `DISCORD_TOKEN` is read from env (loaded by Deno's `--env` flag from `.env`). No other secrets. + +--- + +## Top-level Layout (`src/`) + +```bash +src/ +├── index.ts # entry point, command registry, interactionCreate handler +├── client/EllyClient.ts # extended discord.js Client +├── config/ # config types + custom TOML loader/validator +├── api/pika/ # PikaNetwork API client + cache + types +├── database/ # SQLite layer + legacy JSON layer + repositories +├── events/ # ready, interactionCreate, messageCreate +├── services/PermissionService.ts +├── commands/ # slash commands grouped by category +├── utils/ # logger, errors, embeds, time, pagination +└── types/index.ts # core type definitions (Command, PermissionLevel, etc.) +``` + +--- + +## Entry Point — `src/index.ts` + +`main()`: + +1. Calls `loadConfig('./config.toml')` then `validateConfig(config)`. Errors abort (`Deno.exit(1)`); warnings only logged. +2. Constructs `new EllyClient(config)`. +3. Builds an in-source array of `{ cmd, category }` pairs (28 commands across Statistics, Utility, Suggestions, QOTD, Applications, Family, Moderation, Developer) and calls `client.registerCommand(cmd)` for each. +4. Registers `messageCreateEvent` (filter / auto-mod) on the client. +5. Calls `client.initialize()` (loads DBs, sets event handlers, starts refresh intervals). +6. Registers a single `interactionCreate` listener inline in `index.ts` that: + - Returns early unless `interaction.isChatInputCommand()`. + - Looks up `client.commands.get(name)`. + - Cooldown check via `client.isOnCooldown(userId, name)`. + - Permission check via `client.permissions.hasPermission(member, command.permission)`. + - Calls `command.execute(interaction)` and sets cooldown on success. + - Routes errors to `client.errorHandler.handleCommandError(error, interaction)`. +7. Adds `SIGINT`/`SIGTERM` handlers calling `client.shutdown()`. +8. Calls `client.login(token)` (token from `Deno.env.get('DISCORD_TOKEN')`). +9. After login, builds a `REST` client and PUTs `Routes.applicationGuildCommands(clientId, guildId)` with `cmd.data.toJSON()` for every registered command (guild-scoped sync for instant updates). + +Note: `src/events/interactionCreate.ts` and `src/events/ready.ts` exist with richer logic (autocomplete, button/modal/select-menu stubs, ready-event command sync) but are **not wired up** — the actual handlers in use are the inline listener in `index.ts` plus the `ready` listener inside `EllyClient.setupEventHandlers()`. + +--- + +## Configuration — `src/config/` + +### `types.ts` + +Defines `Config` as a composition of: +`BotConfig`, `DatabaseConfig`, `APIConfig`, `GuildConfig`, `ChannelsConfig`, `RolesConfig`, `FeaturesConfig`, `LimitsConfig`, `ColorsConfig`, `LoggingConfig`. All channels/roles are referenced **by name** (string), not ID, except `bot.owners.ids`, `guild.id`, and `roles.manageable.ids`. + +### `config.ts` + +- **Custom TOML parser** (`parseTOML`): line-based, supports `[section]`, `[section.subsection]`, double/single quoted strings, hex (`0x…`) and decimal numbers, booleans, single-line and multi-line arrays, inline `#` comments (string-aware). No table arrays, no datetime types. Returns `Record` and is type-cast to `Config`. +- **`loadConfig(path)`:** reads file via `Deno.readTextFile`, parses, returns `Config`. Throws on `Deno.errors.NotFound`. +- **`validateConfig(config)` → `ConfigValidationResult`:** returns `{ valid, errors[], warnings[] }`. Required fields: `bot.name`, `bot.prefix`, `database.path`, `guild.id`. Validates Discord ID regex `^\d{17,19}$` for owners and guild ID, activity type enum, color range `0..0xFFFFFF`, log level enum, `purge_max_messages ∈ [1,100]`, etc. Missing channels/roles/features only emit warnings. +- **`validateConfigOrThrow`** and **`getConfigValue(config, 'a.b.c')`** are also exported. + +`config.example.toml` shows defaults: bot/owners, database path `./data/elly.db`, channel/role names, feature toggles, `limits` (champion 366d, away 355d, purge 100, reminder 365d), colors, logging. + +--- + +## Client — `src/client/EllyClient.ts` + +`EllyClient extends discord.js Client`. Constructed with a `Config`. Hard-coded gateway intents: `Guilds`, `GuildMembers`, `GuildMessages`, `GuildMessageReactions`, `MessageContent`, `DirectMessages`. Partials: `Message`, `Channel`, `Reaction`, `User`, `GuildMember`. + +Public fields: + +- `config: Config` +- `pikaAPI: PikaNetworkAPI` — constructed with `timeout = config.api.pika_request_timeout` and `cache.profileTTL = config.api.pika_cache_ttl`, `leaderboardTTL = pika_cache_ttl/2`. +- `database: JsonDatabase` — legacy JSON DB at `config.database.path.replace('.db', '.json')`. +- `dbManager: DatabaseManager | null` — SQLite manager at `config.database.path.replace('.json', '.sqlite')`. Created in `initialize()`; falls back to JSON if SQLite init throws. +- `permissions: PermissionService` +- `logger: Logger` (level + file from `config.logging`) +- `errorHandler: ErrorHandler` (singleton from `getErrorHandler()`). +- `commands: Collection` — registry populated by `registerCommand`. +- `cooldowns: Collection>` — `commandName → userId → expiresAtMs`. +- `mainGuild: Guild | null` +- `roles` cache: `admin, leader, officer, developer, guildMember, champion, away, applicationsBlacklisted, suggestionsBlacklisted` (all `Role | null`, resolved by name). +- `channels_cache`: `applications, applicationLogs, suggestions, suggestionLogs, guildUpdates, discordChangelog, inactivity, developmentLogs, donations, reminders` (all `TextChannel | null`, resolved by name). + +Lifecycle: + +- `initialize()`: `database.load()` → tries `createDatabaseManager(sqlitePath)` → `setupEventHandlers()` → `startRefreshInterval()`. +- `setupEventHandlers()`: `once('ready', onReady)`, error/warn loggers. +- `onReady()`: sets presence (activity from config), calls `refreshCache()`. +- `refreshCache()`: fetches `mainGuild`, then resolves all roles/channels by lowercased name lookup against `mainGuild.roles.cache` / `mainGuild.channels.cache` (text-channel filter via `channel.isTextBased()`). +- `startRefreshInterval()`: + - `refreshCache()` every 10 minutes. + - `pikaAPI.clearCache()` every 1 hour. +- Cooldowns: `isOnCooldown(userId, commandName)` returns remaining seconds (rounded up) or 0. `setCooldown(userId, commandName, seconds)` lazily creates the inner `Collection`. +- `shutdown()`: clears intervals, `dbManager.close()`, `database.close()` (saves dirty JSON), `pikaAPI.destroy()`, `super.destroy()`. + +--- + +## Permission System — `src/services/PermissionService.ts` + `PermissionLevel` enum + +Numeric ladder (`src/types/index.ts`): + +```bash +User=0, GuildMember=1, Officer=2, Leader=3, Admin=4, Developer=5, Owner=6 +``` + +`PermissionService.getPermissionLevel(member)`: + +1. Returns `Owner` if `member.id ∈ config.bot.owners.ids`. +2. Otherwise iterates a fixed priority list (Developer → Admin → Leader → Officer → GuildMember) and returns the first matching role (by lowercased name). +3. Falls back to `User`. + +Helpers: `hasPermission(member, level)` (`>=` comparison), `isOwner/isStaff/isLeader/isAdmin/isDeveloper`, `isApplicationsBlacklisted`, `isSuggestionsBlacklisted`, `isChampion`, `isAway`, `isManageableRole(roleId)` (checks `config.roles.manageable.ids`). Static `getLevelName(level)` and `formatDeniedMessage(required)` produce the user-visible permission error string used by `index.ts`. + +A `requirePermission(level)` decorator factory is also exported but not used by any command (commands declare `permission: PermissionLevel.X` on the `Command` object instead). + +--- + +## Database Layer — `src/database/` + +The codebase contains **two parallel database implementations** that coexist: + +### 1. SQLite (current/canonical) + +- **`sqlite.ts`** — `SQLiteDatabase` wrapper around `Database` from `jsr:@db/sqlite@0.12`. On `connect()` it `mkdir -p` the parent directory, opens the DB, and applies `PRAGMA journal_mode=WAL`, `PRAGMA foreign_keys=ON`, `PRAGMA busy_timeout=5000`. Exposes `query`, `queryOne`, `execute`, `exec` returning `QueryResult` (`{ success, data?, error?, rowsAffected?, lastInsertRowId? }`). Manual transaction API (`beginTransaction/commit/rollback`) plus a `transaction(fn)` wrapper that rolls back on throw. Custom error hierarchy: `DatabaseError` → `ConnectionError`, `QueryError`, `TransactionError`. Module-level singleton via `createSQLiteDatabase(path)` / `getDatabase()`. +- **`schema.ts`** — declarative `TABLES` map (15 tables) created with `CREATE TABLE IF NOT EXISTS` plus 13 `CREATE INDEX IF NOT EXISTS` statements. Tables: + - `schema_info` (key/value, used to track `version=1`). + - `reminders`, `families`, `family_children` (M:N), `away_status`. + - `suggestions`, `suggestion_votes` (FK→suggestions ON DELETE CASCADE). + - `applications` (with embedded form fields: `minecraft_username`, `discord_age`, `timezone`, `activity`, `why_join`, `experience`, `extra`). + - `champions`, `qotd_questions`, `qotd_config` (single-row enforced via `CHECK (id = 1)`). + - `channel_filters`, `filter_allowed_roles` (FK CASCADE), `filter_actions`. + - `staff_progress`, `staff_actions`, `blacklists`, `counters`. + - `initializeSchema(db)` runs all CREATEs and inserts `version` into `schema_info`. + - `getSchemaVersion(db)` and `runMigrations(db)` — migration framework with no migrations yet (currently `SCHEMA_VERSION = 1`). +- **`BaseRepository.ts`** — generic abstract class. Subclasses provide `tableName` and the abstract `rowToEntity` / `entityToRow` mappers. Provides: + - Result type: `Result = {ok:true, value:T} | {ok:false, error:E}` with `ok()`/`err()` helpers. + - Error subclasses: `RepositoryError`, `NotFoundError`, `DuplicateError`, `ValidationError`. + - Helpers: `generateId(prefix)` (timestamp-base36 + random suffix), `now()`, `handleQueryResult`, `handleExecuteResult`. + - Generic CRUD: `findById`, `findAll(limit?, offset?)`, `count`, `deleteById`, `exists`, plus protected `findWhere`, `findOneWhere`, `updateWhere`, `deleteWhere`. +- **`DatabaseManager.ts`** — orchestrates `SQLiteDatabase` + schema + repositories. On `initialize()`: creates the `SQLiteDatabase`, runs `initializeSchema`, runs `runMigrations`, instantiates `FamilyRepositorySQLite` and `ReminderRepositorySQLite` (the only two SQLite-backed repositories). Exposes `families`, `reminders`, raw `connection`, `transaction(fn)`, `getStats()` (path, file size, table list, connected), `vacuum()`, `backup(path)` (`Deno.copyFile`), `restore(path)` (close → copy → re-init), `close()`. Module-level singleton via `createDatabaseManager(path)`. +- **SQLite repositories implemented:** + - `FamilyRepositorySQLite` — `getOrCreate(userId)`, `findByUserId` (eagerly loads children via secondary query against `family_children`), `getChildren`, `setPartner` (validates self-marriage and existing partner; updates both rows in a single transaction), `removePartner`, `setParent`/`removeParent` (uses `family_children` join table inside a transaction), `getFamilyTree(userId)` (returns `{ user, partner?, parent?, children: Family[] }`), `areRelated`, `deleteFamily` (clears partner refs, parent refs, child rows, then deletes the row — all in one transaction). + - `ReminderRepositorySQLite` — `create(Omit)` generates `rem_…` IDs, `findByUserId` (sorted by `remind_at ASC`), `findDue()` (`remind_at <= now()`), `findUpcoming(userId, limit=10)`, `updateRemindAt`, `delete`, `deleteByUserId`, `countByUserId`, `processRecurring(id)` (deletes if non-recurring, else advances `remindAt += recurrenceInterval`). + +### 2. Legacy JSON layer + +- **`connection.ts`** — exports two classes: + - `Database` — stub class (`isConnected` flag, no real storage) — unused. + - `JsonDatabase` — in-memory `Map>` persisted to a JSON file (path is `config.database.path.replace('.db', '.json')`). Methods: `load()`, `save()`, `insert(table, key, value)`, `get(table, key)`, `update(table, key, partial)`, `delete(table, key)`, `find(table, predicate)`, `getAll(table)`, `count(table)`, `clearTable(table)`, `close()` (flushes pending save). `scheduleSave()` debounces writes by 5 seconds. +- **JSON-backed repositories** (`src/database/repositories/`): `ApplicationRepository`, `SuggestionRepository`, `ChampionRepository`, `QOTDRepository`, `FilterRepository`, `StaffRepository`, `AwayRepository`, `ReminderRepository`, `FamilyRepository`. These are constructed ad-hoc inside command handlers (e.g. `new ApplicationRepository(client.database)`). +- **API mismatch:** Most legacy repos (`Application`, `Suggestion`, `Champion`, `QOTD`, `Filter`, `Staff`) call `this.db.set(collection, array)` and `this.db.get(collection)` — i.e. they treat tables as a single keyed value. `JsonDatabase` does **not** implement `set`; its `get` requires `(table, key)`. As a result these repositories are not actually functional against `JsonDatabase` as written; only `AwayRepository`, `ReminderRepository` (legacy) and `FamilyRepository` use the correct k-v API (`insert/get/update/delete/find/getAll`). + +### `index.ts` (database barrel) + +Re-exports everything: SQLite types, `DatabaseManager`, schema helpers, `BaseRepository` + `Result/ok/err`, both SQLite repositories, the legacy `JsonDatabase`, and all legacy JSON repositories. + +--- + +## PikaNetwork API — `src/api/pika/` + +External integration with `https://stats.pika-network.net/api` plus HTML scraping of `https://pika-network.net/` and uptime checks via `https://api.mcstatus.io/v2/status/java/`. + +### `client.ts` — `PikaNetworkAPI` + +Configurable via `PikaAPIOptions`: `timeout` (10s default), `userAgent` (`Elly Discord Bot/1.0`), `rateLimitDelay` (200ms), `batchSize` (5), `cache`, `debug`. Maintains in-instance request stats (`totalRequests`, `successful`, `failed`, `totalLatency`). + +Internals: + +- `request(endpoint, baseUrl?)` — `fetch` with `AbortController` timeout; expects JSON, returns `null` on error/empty/invalid JSON; updates stats. +- `fetchHtml(url)` — same pattern but returns text. +- `delay(ms)` — used between batches. + +Public API (each method first checks the cache): + +- **Profile:** `getProfile`, `playerExists`, `getFriendList`, `getLevellingInfo`, `getGuildInfo`, `getRankInfo`, `getMiscInfo`, `getJoinInfo` (computes "estimated first join" from earliest punishment date). +- **Clan:** `getClan`, `getClanMembers`. +- **Leaderboards / stats:** `getLeaderboard(username, gamemode, interval='lifetime', mode='all_modes')`, `getRatioData` (KDR/WLR/WPR/AHSR + FKDR for bedwars), `getBedWarsStats`, `getSkyWarsStats`. Stat parsing helpers (`getStatValue`, `getStatPosition`, `parseBedWarsStats`, `parseSkyWarsStats`) extract the `entries[0].value` / `entries[0].place` from the keyed `LeaderboardResponse`. +- **Batch:** `getMinimalBatchLeaderboard(usernames, interval)` (returns `[{username, bedwars_wins, skywars_wins, total_wins}]`), `getBatchLeaderboard(usernames, gamemode, interval)` returning a `Map`. Both chunk by 5 and `delay(200)` between batches. +- **Total leaderboard:** `getTotalLeaderboard(options)` → `/leaderboards?type=…&interval=…&stat=…&mode=…&offset=…&limit=…`. +- **Forum scraping (regex-based, no DOM parser):** `getStaffList()` (matches `UsernameRole` pairs; valid roles are an internal `Set`: owner, manager, lead developer, developer, admin, sr mod, moderator, helper, trial), `isStaff(username)`, `getVoteLeaderboard()` (parses winning voters and runners-up sections), `getPunishments(username, filter?, includeConsole=true)` (regex over `class="row"` blocks; cleans Minecraft `&`/`§` color codes via `cleanReason`). +- **Server status:** `getServerStatus(serverIP='play.pika-network.net')` queries `mcstatus.io`, augments with a fixed icon URL (`eu.mc-api.net/v3/server/favicon`), banner URL (`api.loohpjames.com/serverbanner.png`), and (for the Pika IP) website + Discord links. +- **Cache management:** `clearCache`, `clearCacheType(type)`, `getCacheStats`, `getSimpleCacheStats`, `getRequestStats` (computes `successRate` and `averageLatency`), `resetStats`, `destroy()` (calls `cache.destroy()` to clear cleanup interval), `healthCheck()` (HEAD `/profile/Technoblade`). + +### `cache.ts` — `PikaCache` + +Composes 8 instances of an internal `AdvancedCache`: + +- `AdvancedCache`: TTL+LRU `Map>` with `defaultTTL` (1h fallback), `maxSize` (1000), `enableLRU`. Tracks `hits`, `misses`, `evictions`. Keys are normalized to lowercase. Methods: `get`, `set` (evicts LRU when full), `has`, `delete`, `clear`, `cleanup` (purges expired), `keys`, `getMetadata`, `touch`, plus a `getStats()` returning `hitRate`. +- **TTL defaults (overridable):** profile 10m, clan 15m, leaderboard 5m, staff 1h, vote 30m, server 1m, punishments 10m. Generic 5m. `cleanupIntervalMs` 5m runs `cleanup()` over all sub-caches. +- **Per-domain key formats** (`getProfile`/`setProfile`, `getClan`/`setClan`, `getLeaderboard(username, gamemode, mode, interval)` keyed as `${u}:${gm}:${m}:${i}`, `getStaff/setStaff` keyed `'list'`, `getVotes` keyed `'leaderboard'`, etc.). +- `clear()` and `clearType(type)` clear all/one sub-cache. `destroy()` clears the cleanup interval. + +### `types.ts` & `index.ts` + +Pure type definitions plus `isProfileResponse`/`isClanResponse`/`isLeaderboardResponse` runtime guards. `index.ts` is a barrel re-exporting everything. + +--- + +## Events — `src/events/` + +### `messageCreate.ts` (wired up via `client.on(messageCreateEvent.name, …)` in `index.ts`) + +Fires only for non-bot guild messages. Skips if `config.features.channelFiltering` is false (note: source reads `config.features.channelFiltering`, but the config key is `channel_filtering` — naming mismatch). + +Constructs a fresh `FilterRepository(client.database)` per message and: + +1. `repo.checkMessage(channelId, content, userRoleIds)` runs through enabled filters for that channel (`getChannelFilters`); skips filters whose `allowedRoles` overlap the user's roles; tests content via `matchesFilter`: + - `links`: `/https?:\/\/[^\s]+/i` + - `images`: image-extension URL ending pattern + - `invites`: `(discord\.gg|discord\.com\/invite)\/[a-zA-Z0-9]+` + - `attachments`: returns false here (handled separately) + - `custom`: user-supplied `pattern` compiled to RegExp +2. If no content match and the message has `attachments`, scans for an `attachments`-type filter and applies the same allowed-roles logic. +3. On match: `message.delete()`, `repo.logAction(…)` (truncates content to 500 chars; ring-buffer keeps last 1000 actions), DMs the user a warning embed (silently swallowed if DMs disabled), and posts a log embed to the channel named `config.channels.development_logs`. + +### `interactionCreate.ts` & `ready.ts` + +Defined but **not registered** by `index.ts`. The `interactionCreate.ts` file has handlers for `isChatInputCommand`, `isAutocomplete`, `isButton`, `isModalSubmit`, `isStringSelectMenu` with `customId` prefix routing (`paginator:`, `application:`, `suggestion:`, `family:`, `feedback:`, `stats:`) — but the button/modal/select branches are TODO placeholders. The `ready.ts` file replicates the post-login command sync logic that already lives in `index.ts`. + +Actual ready behavior comes from `EllyClient.onReady()` (cache refresh + presence). + +--- + +## Commands — `src/commands/` + +Every command file exports a `Command` object (`src/types/index.ts`): + +```ts +interface Command { + data: SlashCommandBuilder | …; + permission: PermissionLevel; + cooldown?: number; // seconds + guildOnly?: boolean; + ownerOnly?: boolean; + execute(interaction): Promise; + autocomplete?(interaction): Promise; +} +``` + +Categories and notable behaviours: + +### Statistics (`PermissionLevel.User`, cooldown 5s typical) + +- **`/bedwars [mode] [interval]`** — `bedwars.ts`. `Promise.all([getProfile, getBedWarsStats])`, picks an embed colour from a hardcoded `rankColors` map keyed by lowercased rank `displayName`, builds a 12-field embed with K/D, FKDR, W/L, beds, games, winstreak. Uses `mc-heads.net/head//right` for thumbnail. +- **`/skywars`** — analogous to bedwars. +- **`/guild `** — looks up clan via `pikaAPI.getClan`. +- **`/server`** — calls `pikaAPI.getServerStatus(...)`. + +### Utility + +- **`/remind` (set/list/cancel)** — `utility/remind.ts`. Uses **legacy** `ReminderRepository` (NOT the SQLite one). `parseTime` handles `15m`, `2h30m`, `1d 2h 30m`. Enforces `config.limits.reminder_max_duration_days` and a hard cap of 25 active reminders/user. `discordTimestamp(date,'R')` for relative time. Uses ISO-string `remindAt` (legacy schema). +- **`/away`**, **`/champion`**, **`/role`** — manage role/state with `away_max_days`/`champion_max_days` limits and the manageable-role allowlist for `/role`. +- **`/staff`** — staff simulator game. Hardcoded `SCENARIOS` table for `appeal`/`report`/`assist` categories with multiple-choice answers, scoring via `StaffRepository`. Uses level thresholds `[0,50,150,300,500,750,1000,1500,2000,3000]` and per-action point values (`appeal:10, report:8, punishment:5, assist:3`). + +### Suggestions — `/suggestions ` (`commands/suggestions/index.ts`, 989 lines) + +Subcommands: `create`, `view`, `edit`, `delete`, `my`, `approve`, `deny`, `consider`, `implement`, `list` (filter by status, sort by newest/oldest/votes/controversial), `stats`, `top`. User submits via modal; backed by legacy `SuggestionRepository` with auto-incrementing `orderNum` via the JSON `counters` table; supports up/down voting via reactions/buttons. + +### Applications — `/applications ` (`commands/applications/`) + +Multi-file module. `index.ts` defines all subcommands and dispatches to handlers in `handlers/`: + +- `apply.ts` (708 lines): blacklist check (reads `client.database.get('blacklists')`), pending-application guard, 7-day cooldown after denial. Shows a 5-field modal (`minecraft_username`, `timezone`, `activity`, `why_join`, `experience`). On submit, creates the application via `ApplicationRepository`, posts an embed with two action rows (Accept/Deny/Interview/Note + ViewStats/UserHistory) to `client.channels_cache.applications`, sets up a 14-day `createMessageComponentCollector`. Officer-permission gate inside the collector. Deny opens a reason modal; Accept assigns `client.roles.guildMember` and DMs the applicant. Notes are stored in the legacy DB collection `application_notes`. +- `view.ts`, `review.ts` (accept/deny), `list.ts` (list/search/history), `stats.ts` (stats/leaderboard), `settings.ts`, `admin.ts` (export to CSV, purge old applications). + +### QOTD — `/qotd ` (`commands/qotd/index.ts`, 611 lines) + +User: `answer`, `suggest`, `streak`. Staff: `add` (with category), `queue`, `remove`, `send`, `stats`, `leaderboard`. Admin: `config` (channel, role, time HH:MM, enabled). Persists to `qotd_questions` and `qotd_config` (legacy). + +### Family + +- **`/marry`**, **`/divorce`**, **`/adopt`**, **`/relationship`** — use legacy `FamilyRepository` (note: SQLite `FamilyRepositorySQLite` exists and is initialized on `dbManager`, but commands use the legacy class). `/marry` validates self/bot/parent-child constraints, posts a proposal with Accept/Decline buttons (60s timeout, restricted to target user via filter), commits via `setPartner` on accept. + +### Moderation + +- **`/purge [user] [contains]`** — Officer perm. Filters out messages older than 14 days, supports user and content filters, requires `ManageMessages` for the bot. Posts a result embed and logs to `developmentLogs`. +- **`/filter`** — channel-filter management (create/list/edit/delete/toggle filters of types `links/images/attachments/invites/custom`). + +### Developer + +All require `Owner`/`Developer`/`Admin` permission level. + +- **`/eval [silent] [async]`** (Owner) — uses `new Function(...)` with an injected context (`client, interaction, guild, channel, user, member`). Pre-checks code against `SENSITIVE_PATTERNS` (`token, secret, password, api_key, auth, credential`); also redacts those patterns from the output. Output formatted with `Deno.inspect`, truncated to 1900 chars. +- **`/shell [timeout]`** (Owner) — strict whitelist of base commands (`ls,pwd,whoami,date,uptime,df,free,cat,head,tail,wc,echo,deno`) plus blocklist regexes (`rm`, `sudo`, `chmod/chown`, `mv`, `cp`, `wget`, `curl`, package managers, redirects to `/`, pipes to `sh/bash`, `eval/exec`, `$(...)`, backticks). Spawns `sh -c ` via `Deno.Command` with a default 10s `AbortController` timeout (max 30s). +- **`/database` (Developer)** — `stats`, `backup` (writes to `./data/backups/elly_.sqlite`), `query` (only `SELECT`/`PRAGMA`, blocks DML/DDL by regex, runs against `client.dbManager.connection`), `tables` (lists tables + row counts), `vacuum`. +- **`/sync` (Admin)** — Manually re-PUTs commands or clears them via `Routes.applicationGuildCommands` / `Routes.applicationCommands`. +- **`/reload` (Owner)** — Same idea, simpler scoping. +- **`/blacklist` (Admin)** — add/remove/check/list users on `bot|applications|suggestions|commands` blacklists in the legacy `blacklists` collection. +- **`/debug`**, **`/emit`** — diagnostic helpers (`debug.ts`, `emit.ts`). + +--- + +## Utilities — `src/utils/` + +### `logger.ts` + +`Logger` with levels `debug|info|warn|error` (numeric thresholds). Console output is ANSI-coloured per level; file output (when `logFile` set) is newline-delimited JSON appended via `Deno.writeTextFile(path, line, {append:true})`. `child(context)` derives a sub-logger with concatenated context (`a:b`). `createLogger(context, options?)` factory. + +### `errors.ts` + +- **`BotError`** base class: `code`, `userMessage`, `isOperational`, `timestamp`, `context`, optional `cause`. `toJSON()` produces a serialisable record (used for logging). +- **Subclasses:** `CommandError`, `PermissionError`, `ValidationError`, `APIError`, `RateLimitError`, `ConfigError`. +- **`ErrorHandler`** singleton (`getErrorHandler()`): + - `handle(error, context?)` normalises to `BotError`, logs (warn for operational, error otherwise), stores in a ring buffer (max 100), increments `errorCount`. + - `handleCommandError(error, interaction)` — used by the inline `interactionCreate` handler. Builds a red embed with `userMessage`, `code` field, and timestamp, then `followUp` if replied/deferred or `reply` ephemerally. + - `getStats()`, `getRecentErrors(limit=10)`, `clearErrors()`. +- **`withErrorHandling(fn, context?)`**, **`assert(condition, …)`** (throws `ValidationError`), **`assertDefined(value, …)`**, **`tryAsync(fn)`** (returns Result-style), **`retry(fn, {maxAttempts, initialDelay, maxDelay, backoffFactor})`** (exponential backoff). + +### `embeds.ts` + +Factories for consistent embeds: `successEmbed`, `errorEmbed`, `warningEmbed`, `infoEmbed`, `primaryEmbed`, `loadingEmbed`, plus higher-level helpers: `statsEmbed` (with `mc-heads.net` thumbnail), `guildInfoEmbed`, `applicationEmbed`, `suggestionEmbed`, `relationshipEmbed`, `reminderEmbed`, `modLogEmbed` (per-action colour map). `withUserFooter(embed, user, text?)` and `withTimestamp(embed, date?)` are mixins. `DEFAULT_COLORS` is exported as fallback. + +### `time.ts` + +- `parseTime(input)` — accepts unitless integer (interpreted as minutes) or compound strings like `1d 2h 30m`. Unit aliases cover `s/sec/second(s)`, `m/min/minute(s)`, `h/hr/hour(s)`, `d/day(s)`, `w/week(s)`, `mo/month(s)` (=30d), `y/year(s)` (=365d). Returns `number | null`. +- `formatDuration(ms)` — verbose (`1 day, 2 hours, and 30 minutes`). +- `formatDurationShort(ms)` — compact (`1d 2h 30m`). +- `relativeTime(date)` — string like `2 hours ago`, `in 3 days`. +- `formatDate`, `formatDateTime`, `discordTimestamp(date, style='f')` (``). +- `isPast`, `isFuture`, `addTime(date, ms)`, `startOfDay`, `endOfDay`. `parseDuration` re-exported as alias of `parseTime`. + +### `pagination.ts` + +`ButtonPaginator` with prev/page-counter/next/stop button row (custom IDs prefixed `paginator:`). Auto-appends `Page X/Y` to embed footers when `showPageNumbers`. Restricts interactions to `authorId` (if set), uses a `MessageComponentCollector` with `timeout` (default 60s). Disables buttons on the boundary pages and on stop/end. + +--- + +## Type Definitions — `src/types/index.ts` + +Centralises: + +- `PermissionLevel` enum. +- `Command`, `CommandGroup`, `BotEvent`. +- `ButtonHandler`, `ModalHandler`, `SelectMenuHandler` (interfaces; `customId` may be `string | RegExp`). Currently unused — present for the not-wired-up `interactionCreate.ts` event. +- Domain entities (legacy/JSON shape): `Application`, `ApplicationFeedback`, `Suggestion`, `FamilyRelationship`, `Reminder`, `Champion`, `AwayStatus`, `QOTDQuestion`, `QOTDChannel`, `StaffProgress`, `FilteredChannel`, `Blacklist`, `ModLog`. Note the SQLite repositories define their own narrower entity types (e.g. `Family`, `Reminder`) inside their respective files with numeric epoch timestamps; the legacy types use ISO strings. +- Utility types: `EmbedColors`, `CooldownEntry`, `PaginatorOptions`, `APIResponse`, `AuditAction`, `AuditActionType` union. + +--- + +## Cross-cutting Notes / Inconsistencies + +- **Two parallel persistence layers.** `EllyClient` initializes both, but the SQLite `DatabaseManager` only wires up Family and Reminder repositories. All other domain code (suggestions, applications, QOTD, filters, staff, champions, away, blacklists) still reads/writes through the legacy `JsonDatabase`. +- **Broken legacy API contract.** Repositories listed under "API mismatch" above call `db.set` (which doesn't exist on `JsonDatabase`) and `db.get(table)` with one argument. Either there was a prior version of `JsonDatabase` exposing array semantics, or these features are non-functional at runtime. +- **Family commands use legacy repo** even though `FamilyRepositorySQLite` is initialized — `/marry` and friends construct `new FamilyRepository(client.database)`. +- **Reminder commands use legacy repo** with ISO-string `remindAt`, while `ReminderRepositorySQLite` uses numeric epoch — there is no scheduled job in this codebase that actually fires due reminders (`findDue` exists but no caller invokes it). +- **Two separate ready/sync paths.** `index.ts` syncs commands directly after `client.login`; `events/ready.ts` would do the same but is never registered. `EllyClient.setupEventHandlers()` registers its own `once('ready', onReady)` for cache + presence. +- **`messageCreate` config-key bug.** Reads `config.features.channelFiltering` but the TOML key is `channel_filtering` (snake_case), so the feature toggle never enables filtering through config — it's always falsy unless the TOML loader stores it under both keys (it does not). +- **No autocomplete handler is wired** — `Command.autocomplete` is defined in the type but the live `interactionCreate` listener in `index.ts` only handles `isChatInputCommand`. +- **Button/modal/select-menu interactions** for applications, suggestions, family use **per-message component collectors** (`createMessageComponentCollector` / `awaitMessageComponent` / `awaitModalSubmit`) rather than a global router; each command attaches its own collector on the messages it sends. +- **Cooldown storage** is in-process only (`Collection`s on `EllyClient`); restarts reset all cooldowns. +- **PikaNetwork API key rotation, rate-limit headers, and 429 backoff** are not implemented; the `RateLimitError` class exists in `utils/errors.ts` but is unreferenced. diff --git a/README.md b/README.md index e133c43..c8f8c23 100644 --- a/README.md +++ b/README.md @@ -30,20 +30,20 @@ git clone cd EllyProject ``` -2. Copy and configure the config file: +1. Copy and configure the config file: ```bash cp config.toml.example config.toml # Edit config.toml with your settings ``` -3. Set up environment variables: +1. Set up environment variables: ```bash export DISCORD_TOKEN="your-bot-token" ``` -4. Run the bot: +1. Run the bot: ```bash deno task start @@ -121,7 +121,7 @@ All configuration is done through `config.toml`. See the file for available opti ## Project Structure -``` +```bash src/ ├── index.ts # Entry point ├── client/ # Discord client diff --git a/config.example.toml b/config.example.toml index 9ec724b..0f9908a 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,5 +1,13 @@ -# Elly Discord Bot Configuration +# Elly Discord Bot — Configuration # ================================ +# +# Copy this file to `config.toml` and fill in real values. The file is +# validated against the shared Zod schema (`@elly/shared/src/config/schema.ts`) +# at boot — missing or malformed values will abort the process before any +# side effects occur. +# +# Discord IDs must be 17–19 digit snowflakes. Channel and role names match +# guild entities by display name (case-insensitive). [bot] name = "" @@ -16,7 +24,21 @@ ids = [ ] [database] -path = "./data/elly.db" +# Path to the unified SQLite database owned by @elly/core. +path = "./data/elly.sqlite" + +[kv] +# Path to the on-disk Deno.Kv store. Used for cooldowns, interaction state, +# and PikaNetwork response caching. Replaces all in-memory ad-hoc caches. +path = "./data/kv" + +[ipc] +# Local HTTP IPC between @elly/bot and @elly/core. The bot opens connections +# to this host:port; the core binds and serves on it. The shared secret +# lives in .env (IPC_TOKEN) and never in this file. +host = "127.0.0.1" +port = 8787 +request_timeout_ms = 10000 [api] pika_cache_ttl = 3600000 # 1 hour in ms @@ -85,5 +107,12 @@ error = 0xED4245 info = 0x5865F2 [logging] -level = "info" # debug, info, warn, error +# Minimum level: debug | info | warn | error | fatal +level = "info" +# Console output format: "console" (ANSI-colored, dev) or "json" (structured). +# In production (NODE_ENV=production) JSON is forced regardless of this value. +format = "console" +# Optional: also write structured JSON lines to a rotating file. file = "./logs/elly.log" +file_max_bytes = 10485760 # 10 MiB — rotates when current file exceeds this. +file_max_backups = 5 # Number of `.1` … `.N` archives to keep. diff --git a/crates/bot/deno.json b/crates/bot/deno.json new file mode 100644 index 0000000..bc5c8b6 --- /dev/null +++ b/crates/bot/deno.json @@ -0,0 +1,5 @@ +{ + "name": "@elly/bot", + "version": "0.1.0", + "exports": "./src/main.ts" +} diff --git a/crates/bot/src/main.ts b/crates/bot/src/main.ts new file mode 100644 index 0000000..a032579 --- /dev/null +++ b/crates/bot/src/main.ts @@ -0,0 +1,144 @@ +/** + * @elly/bot entrypoint — Phase 1 boot. + * + * Phase 1 responsibilities: + * 1. Load and validate `config.toml` against the shared Zod schema. + * 2. Load and validate bot environment variables (DISCORD_TOKEN, IPC_TOKEN). + * 3. Construct the root structured logger and emit a boot summary. + * 4. Wire SIGINT/SIGTERM handlers for graceful shutdown. + * + * Phase 3 will add: Discord.js client, command/interaction router, and + * the typed IPC client that talks to `@elly/core`. + */ + +import { + BotEnvSchema, + ConfigError, + ConfigValidationError, + createLogger, + EnvValidationError, + loadConfig, + loadEnv, + type BotEnv, + type Config, + type Logger, +} from "@elly/shared"; + +const CONFIG_PATH = Deno.env.get("CONFIG_PATH") ?? "./config.toml"; +const LOGGER_NAME = "@elly/bot"; + +async function main(): Promise { + const config = await loadConfigOrExit(); + const env = loadEnvOrExit(); + const logger = buildLogger(config, env); + + logger.info("phase 1 boot starting", { + crate: "bot", + nodeEnv: env.NODE_ENV, + configPath: CONFIG_PATH, + }); + + logBootSummary(logger, config, env); + installSignalHandlers(logger); + + logger.info("phase 1 boot complete", { + pid: Deno.pid, + deno: Deno.version.deno, + typescript: Deno.version.typescript, + }); + + // Phase 1 has no long-running work; the Discord client arrives in Phase 3. + await logger.flush(); +} + +// ===================================================================== +// Boot helpers +// ===================================================================== + +async function loadConfigOrExit(): Promise { + try { + return await loadConfig(CONFIG_PATH); + } catch (err) { + bootFail(err, "config"); + } +} + +function loadEnvOrExit(): BotEnv { + try { + return loadEnv(BotEnvSchema); + } catch (err) { + bootFail(err, "env"); + } +} + +function buildLogger(config: Config, env: BotEnv): Logger { + const level = env.LOG_LEVEL ?? config.logging.level; + const isProd = env.NODE_ENV === "production"; + + return createLogger({ + name: LOGGER_NAME, + level, + format: isProd ? "json" : config.logging.format, + file: config.logging.file + ? { + path: config.logging.file, + maxBytes: config.logging.file_max_bytes, + maxBackups: config.logging.file_max_backups, + } + : undefined, + }); +} + +function logBootSummary(logger: Logger, config: Config, env: BotEnv): void { + logger.info("config validated", { + bot: config.bot.name, + guild: config.guild.name, + coreIpc: `${config.ipc.host}:${config.ipc.port}`, + features: countEnabled(config.features), + }); + logger.debug("environment validated", { + nodeEnv: env.NODE_ENV, + logLevel: env.LOG_LEVEL ?? "", + discordTokenPresent: Boolean(env.DISCORD_TOKEN), + ipcTokenPresent: Boolean(env.IPC_TOKEN), + }); +} + +function countEnabled(features: Record): string { + const enabled = Object.entries(features) + .filter(([, v]) => v) + .map(([k]) => k); + return `${enabled.length}/${Object.keys(features).length} enabled`; +} + +function installSignalHandlers(logger: Logger): void { + const shutdown = (signal: string) => { + logger.info("shutdown signal received", { signal }); + logger.flush().finally(() => Deno.exit(0)); + }; + Deno.addSignalListener("SIGINT", () => shutdown("SIGINT")); + Deno.addSignalListener("SIGTERM", () => shutdown("SIGTERM")); +} + +function bootFail(err: unknown, stage: "config" | "env"): never { + if (err instanceof ConfigValidationError || err instanceof EnvValidationError) { + console.error(`[@elly/bot] ${stage} validation failed:`); + for (const issue of err.issues) { + console.error(` - ${issue.path}: ${issue.message}`); + } + Deno.exit(1); + } + if (err instanceof ConfigError) { + console.error(`[@elly/bot] ${stage} error: ${err.message}`); + Deno.exit(1); + } + console.error(`[@elly/bot] unexpected ${stage} error:`, err); + Deno.exit(1); +} + +if (import.meta.main) { + main().catch((err) => { + console.error("[@elly/bot] fatal boot error:", err); + Deno.exit(1); + }); +} diff --git a/crates/core/deno.json b/crates/core/deno.json new file mode 100644 index 0000000..00e418d --- /dev/null +++ b/crates/core/deno.json @@ -0,0 +1,5 @@ +{ + "name": "@elly/core", + "version": "0.1.0", + "exports": "./src/main.ts" +} diff --git a/crates/core/src/container.ts b/crates/core/src/container.ts new file mode 100644 index 0000000..f5542f1 --- /dev/null +++ b/crates/core/src/container.ts @@ -0,0 +1,148 @@ +/** + * DI composition root for `@elly/core`. + * + * Owns the lifecycle of every infrastructure dependency in the right order: + * + * build(): open DB → run migrations → open Kv → build bus → build HTTP server + * shutdown(): http server → kv → db + * + * Every dependency is created exactly once and passed by reference to the + * consumers that need it. Nothing in this crate may reach for a module-level + * singleton; if you need a dependency, ask for it through the container. + */ + +import type { Config, CoreEnv, Logger } from "@elly/shared"; + +import { openDatabase, type DbConnection } from "./infrastructure/db/connection.ts"; +import { Migrator } from "./infrastructure/db/migrator.ts"; +import { openKv, type KvHandle } from "./infrastructure/kv/store.ts"; +import { CooldownStore } from "./infrastructure/kv/cooldown.ts"; +import { InteractionStateStore } from "./infrastructure/kv/interactionState.ts"; +import { CacheStore } from "./infrastructure/kv/cache.ts"; +import { DomainEventBus } from "./infrastructure/pubsub/bus.ts"; +import { createHttpServer, type HttpServer } from "./infrastructure/http/server.ts"; +import { buildSystemRoutes } from "./infrastructure/http/routes/system.ts"; + +export interface CoreContainer { + readonly config: Config; + readonly env: CoreEnv; + readonly logger: Logger; + readonly version: string; + readonly startedAt: number; + + readonly db: DbConnection; + readonly kv: KvHandle; + readonly cooldowns: CooldownStore; + readonly interactionState: InteractionStateStore; + readonly cache: CacheStore; + readonly bus: DomainEventBus; + readonly http: HttpServer; + + /** Tear down every dependency in reverse-creation order. */ + shutdown(): Promise; +} + +export interface BuildContainerOptions { + readonly config: Config; + readonly env: CoreEnv; + readonly logger: Logger; + readonly version: string; +} + +export async function buildContainer(options: BuildContainerOptions): Promise { + const log = options.logger.child({ component: "container" }); + const startedAt = Date.now(); + + log.debug("building core container"); + + // ---- Database ----------------------------------------------------- + const db = await openDatabase({ + path: options.config.database.path, + logger: options.logger, + }); + + const migrator = new Migrator({ db: db.db, logger: options.logger }); + await migrator.migrateToLatest(); + + // ---- Kv + helpers ------------------------------------------------- + const kv = await openKv({ + path: options.config.kv.path, + logger: options.logger, + }); + + const cooldowns = new CooldownStore(kv); + const interactionState = new InteractionStateStore(kv); + const cache = new CacheStore(kv); + + // ---- Event bus ---------------------------------------------------- + const bus = new DomainEventBus(options.logger); + + // ---- HTTP server -------------------------------------------------- + const routes = buildSystemRoutes({ + version: options.version, + startedAt, + db, + kv, + bus, + }); + + const http = createHttpServer({ + host: options.config.ipc.host, + port: options.config.ipc.port, + ipcToken: options.env.IPC_TOKEN, + logger: options.logger, + bus, + routes, + }); + + log.info("core container built", { + db: options.config.database.path, + kv: options.config.kv.path, + ipc: `${options.config.ipc.host}:${options.config.ipc.port}`, + }); + + return { + config: options.config, + env: options.env, + logger: options.logger, + version: options.version, + startedAt, + + db, + kv, + cooldowns, + interactionState, + cache, + bus, + http, + + async shutdown(): Promise { + const errors: unknown[] = []; + + const tryClose = async (label: string, fn: () => Promise) => { + try { + await fn(); + } catch (err) { + log.error("shutdown step failed", { + step: label, + err: err instanceof Error ? err : new Error(String(err)), + }); + errors.push(err); + } + }; + + // Reverse build order: stop accepting traffic first, then close DBs. + await tryClose("http", () => http.shutdown()); + await tryClose("kv", () => kv.close()); + await tryClose("db", () => db.close()); + + if (errors.length > 0) { + log.warn("core container shutdown completed with errors", { + errorCount: errors.length, + }); + } else { + log.info("core container shutdown clean"); + } + }, + }; +} diff --git a/crates/core/src/infrastructure/db/connection.ts b/crates/core/src/infrastructure/db/connection.ts new file mode 100644 index 0000000..cce851b --- /dev/null +++ b/crates/core/src/infrastructure/db/connection.ts @@ -0,0 +1,128 @@ +/** + * SQLite connection bootstrap. + * + * Opens (or creates) the on-disk SQLite database, applies our production + * PRAGMAs, and wraps the connection in a fully-typed `Kysely`. + * + * Pragmas: + * - `journal_mode=WAL` — readers don't block writers; required for the + * IPC server + cron jobs running concurrently. + * - `foreign_keys=ON` — SQLite defaults to OFF; we want referential + * integrity for child tables added in Phase 4. + * - `busy_timeout=5000` — block writers up to 5s if WAL is being checkpointed. + * - `synchronous=NORMAL` — safe with WAL, much faster than FULL. + */ + +import { Database as SqliteDatabase } from "@db/sqlite"; +import { ensureDir } from "@std/fs"; +import { dirname } from "@std/path"; +import { Kysely, sql } from "kysely"; +import type { Logger } from "@elly/shared"; + +import { DenoSqliteDialect } from "./kysely-dialect.ts"; +import type { Database } from "./schema.ts"; + +export interface DbConnection { + /** Type-safe Kysely query builder bound to the schema. */ + readonly db: Kysely; + /** Underlying FFI handle — exposed for raw maintenance ops (vacuum, backup). */ + readonly raw: SqliteDatabase; + /** Absolute path the DB was opened from. */ + readonly path: string; + /** Close the underlying connection and tear down Kysely. */ + close(): Promise; + /** Run `VACUUM` to compact the on-disk file. */ + vacuum(): Promise; + /** Copy the live database file to `targetPath` (safe under WAL). */ + backup(targetPath: string): Promise; + /** Return useful runtime stats (path, byte size, table count). */ + getStats(): Promise; +} + +export interface DbStats { + readonly path: string; + readonly sizeBytes: number; + readonly tables: ReadonlyArray; +} + +export interface OpenDbOptions { + readonly path: string; + readonly logger: Logger; +} + +/** + * Open the SQLite file, apply pragmas, return the connection wrapper. + * + * Idempotent across crash/restart: WAL files left behind are recovered + * automatically by SQLite on the next open. + */ +export async function openDatabase(options: OpenDbOptions): Promise { + const log = options.logger.child({ component: "db" }); + await ensureDir(dirname(options.path)); + + log.debug("opening sqlite database", { path: options.path }); + const raw = new SqliteDatabase(options.path); + + // Apply pragmas. `db.exec()` is sync; failures throw synchronously. + raw.exec("PRAGMA journal_mode = WAL"); + raw.exec("PRAGMA foreign_keys = ON"); + raw.exec("PRAGMA synchronous = NORMAL"); + raw.exec("PRAGMA busy_timeout = 5000"); + + const db = new Kysely({ dialect: new DenoSqliteDialect(raw) }); + + log.info("sqlite database opened", { path: options.path }); + + return { + db, + raw, + path: options.path, + + async close(): Promise { + log.debug("closing sqlite database"); + await db.destroy(); + // Kysely's destroy() calls driver.destroy(), which closes `raw`. + }, + + async vacuum(): Promise { + log.debug("vacuuming sqlite database"); + await sql`VACUUM`.execute(db); + }, + + async backup(targetPath: string): Promise { + log.info("backing up sqlite database", { target: targetPath }); + await ensureDir(dirname(targetPath)); + // `VACUUM INTO` is the SQLite-recommended online-safe backup method. + await sql`VACUUM INTO ${sql.lit(targetPath)}`.execute(db); + }, + + getStats(): Promise { + return collectStats(db, options.path); + }, + }; +} + +async function collectStats( + db: Kysely, + path: string, +): Promise { + let sizeBytes = 0; + try { + const stat = await Deno.stat(path); + sizeBytes = stat.size; + } catch { + sizeBytes = 0; + } + + const result = await sql<{ name: string }>` + SELECT name FROM sqlite_master + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' + ORDER BY name + `.execute(db); + + return { + path, + sizeBytes, + tables: result.rows.map((r) => r.name), + }; +} diff --git a/crates/core/src/infrastructure/db/kysely-dialect.ts b/crates/core/src/infrastructure/db/kysely-dialect.ts new file mode 100644 index 0000000..e1e8d36 --- /dev/null +++ b/crates/core/src/infrastructure/db/kysely-dialect.ts @@ -0,0 +1,144 @@ +/** + * Custom Kysely dialect that drives `jsr:@db/sqlite` (native FFI SQLite). + * + * Kysely's bundled `SqliteDialect` depends on `better-sqlite3`, which is + * Node-only. This thin adapter wraps Deno's FFI binding so we keep query + * builder, types, and migrations entirely in the Kysely ecosystem without + * pulling in a Node compatibility shim. + * + * Notes: + * - All operations are sync at the FFI layer; we wrap in `Promise.resolve` + * to satisfy Kysely's async `DatabaseConnection` contract. + * - Read-vs-write dispatch uses the compiled query's AST kind, falling + * back to a SQL prefix check for raw queries. + * - Streaming is not supported (FFI driver returns full result sets). + */ + +import { + CompiledQuery, + type DatabaseConnection, + type DatabaseIntrospector, + type Dialect, + type DialectAdapter, + type Driver, + type Kysely, + type QueryCompiler, + type QueryResult, + SqliteAdapter, + SqliteIntrospector, + SqliteQueryCompiler, +} from "kysely"; +import type { BindValue, Database as SqliteDatabase } from "@db/sqlite"; + +export class DenoSqliteDialect implements Dialect { + constructor(private readonly db: SqliteDatabase) {} + + createAdapter(): DialectAdapter { + return new SqliteAdapter(); + } + + createDriver(): Driver { + return new DenoSqliteDriver(this.db); + } + + createIntrospector(db: Kysely): DatabaseIntrospector { + return new SqliteIntrospector(db); + } + + createQueryCompiler(): QueryCompiler { + return new SqliteQueryCompiler(); + } +} + +class DenoSqliteDriver implements Driver { + constructor(private readonly db: SqliteDatabase) {} + + init(): Promise { + return Promise.resolve(); + } + + acquireConnection(): Promise { + return Promise.resolve(new DenoSqliteConnection(this.db)); + } + + async beginTransaction(conn: DatabaseConnection): Promise { + await conn.executeQuery(CompiledQuery.raw("BEGIN")); + } + + async commitTransaction(conn: DatabaseConnection): Promise { + await conn.executeQuery(CompiledQuery.raw("COMMIT")); + } + + async rollbackTransaction(conn: DatabaseConnection): Promise { + await conn.executeQuery(CompiledQuery.raw("ROLLBACK")); + } + + releaseConnection(): Promise { + // FFI driver shares one connection — nothing to release per-statement. + return Promise.resolve(); + } + + destroy(): Promise { + try { + this.db.close(); + } catch { + // Closing a never-opened or already-closed DB is a no-op for us. + } + return Promise.resolve(); + } +} + +class DenoSqliteConnection implements DatabaseConnection { + constructor(private readonly db: SqliteDatabase) {} + + executeQuery(query: CompiledQuery): Promise> { + // Kysely produces parameter values that are already SQLite-bindable + // (numbers, strings, BigInts, booleans, null, Uint8Array). The cast + // crosses the `unknown` -> `BindValue` boundary without inspecting each + // element; misuse would surface as a runtime SQLite error. + const params = query.parameters as readonly BindValue[]; + const stmt = this.db.prepare(query.sql); + + try { + if (isReadQuery(query)) { + const rows = stmt.all(...params) as R[]; + return Promise.resolve({ rows }); + } + + const changes = stmt.run(...params); + // `@db/sqlite` exposes the most recent insert rowid on the Database + // instance after a successful `.run()`. Only surface it when SQLite + // actually assigned a row (rowid > 0). + const lastId = this.db.lastInsertRowId; + return Promise.resolve({ + rows: [], + numAffectedRows: BigInt(changes), + insertId: lastId !== undefined && Number(lastId) > 0 ? BigInt(lastId) : undefined, + }); + } finally { + stmt.finalize(); + } + } + + // deno-lint-ignore require-yield + async *streamQuery(_query: CompiledQuery): AsyncIterableIterator> { + throw new Error("Streaming is not supported by DenoSqliteDialect"); + } +} + +function isReadQuery(query: CompiledQuery): boolean { + const kind = (query.query as { kind?: string }).kind; + switch (kind) { + case "SelectQueryNode": + case "WithNode": + return true; + } + // RETURNING clauses on INSERT/UPDATE/DELETE produce rows. Kysely's + // `RootOperationNode` is a tagged union with no string index signature, + // so we route through `unknown` before the structural property probe. + const node = query.query as unknown as Record; + if ("returning" in node && node.returning != null) return true; + + // Raw/unknown — fall back to a prefix check. + return /^\s*(SELECT|WITH|PRAGMA|EXPLAIN)\b/i.test(query.sql); +} diff --git a/crates/core/src/infrastructure/db/migrations/0001_initial.ts b/crates/core/src/infrastructure/db/migrations/0001_initial.ts new file mode 100644 index 0000000..260b415 --- /dev/null +++ b/crates/core/src/infrastructure/db/migrations/0001_initial.ts @@ -0,0 +1,26 @@ +/** + * Migration 0001 — initial schema bookkeeping. + * + * Creates the `schema_migrations` table used by the migrator to track which + * versions have been applied. Domain tables are introduced in subsequent + * migrations once Phase 4 starts adding feature schemas. + */ + +import type { Kysely } from "kysely"; + +export const version = 1; +export const name = "0001_initial"; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable("schema_migrations") + .ifNotExists() + .addColumn("version", "integer", (col) => col.primaryKey()) + .addColumn("name", "text", (col) => col.notNull()) + .addColumn("applied_at", "text", (col) => col.notNull()) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable("schema_migrations").ifExists().execute(); +} diff --git a/crates/core/src/infrastructure/db/migrations/index.ts b/crates/core/src/infrastructure/db/migrations/index.ts new file mode 100644 index 0000000..059a3c8 --- /dev/null +++ b/crates/core/src/infrastructure/db/migrations/index.ts @@ -0,0 +1,27 @@ +/** + * Ordered registry of all migrations. + * + * Each migration module exports `version`, `name`, `up(db)`, and `down(db)`. + * The `Migrator` applies any version > the highest one recorded in the + * `schema_migrations` table, in ascending order. + * + * New migrations are added by: + * 1. Creating `NNNN_description.ts` in this directory. + * 2. Importing it here and appending to the `MIGRATIONS` array. + * 3. Updating `Database` in `../schema.ts` to reflect the new columns. + */ + +import type { Kysely } from "kysely"; + +export interface Migration { + readonly version: number; + readonly name: string; + up(db: Kysely): Promise; + down(db: Kysely): Promise; +} + +import * as m0001 from "./0001_initial.ts"; + +export const MIGRATIONS: ReadonlyArray = [ + m0001, +] as const; diff --git a/crates/core/src/infrastructure/db/migrator.ts b/crates/core/src/infrastructure/db/migrator.ts new file mode 100644 index 0000000..9d5d848 --- /dev/null +++ b/crates/core/src/infrastructure/db/migrator.ts @@ -0,0 +1,141 @@ +/** + * Forward-only migration runner. + * + * Reads `MIGRATIONS` (ordered by version), determines the current schema + * version from `schema_migrations`, and applies any newer migrations in a + * single transaction each. Failure inside an `up()` rolls that one back — + * earlier migrations remain applied. + * + * Phase 2 ships only one migration (bookkeeping). The migrator is built now + * so Phase 4 can add domain-table migrations without changing infrastructure. + */ + +import type { Kysely } from "kysely"; +import { sql } from "kysely"; +import type { Logger } from "@elly/shared"; + +import type { Database } from "./schema.ts"; +import { type Migration, MIGRATIONS } from "./migrations/index.ts"; + +export interface MigratorOptions { + readonly db: Kysely; + readonly logger: Logger; + /** Override the migration list — useful for ad-hoc tooling. */ + readonly migrations?: ReadonlyArray; +} + +export interface MigratorRunSummary { + readonly applied: ReadonlyArray<{ version: number; name: string }>; + readonly skipped: ReadonlyArray<{ version: number; name: string }>; + readonly finalVersion: number; +} + +export class Migrator { + private readonly db: Kysely; + private readonly log: Logger; + private readonly migrations: ReadonlyArray; + + constructor(options: MigratorOptions) { + this.db = options.db; + this.log = options.logger.child({ component: "migrator" }); + this.migrations = (options.migrations ?? MIGRATIONS) + .slice() + .sort((a, b) => a.version - b.version); + this.assertVersionsUnique(); + } + + /** Apply every migration newer than the recorded version. */ + async migrateToLatest(): Promise { + await this.ensureBootstrapTable(); + const currentVersion = await this.currentVersion(); + this.log.debug("migration baseline", { currentVersion }); + + const applied: Array<{ version: number; name: string }> = []; + const skipped: Array<{ version: number; name: string }> = []; + + for (const migration of this.migrations) { + if (migration.version <= currentVersion) { + skipped.push({ version: migration.version, name: migration.name }); + continue; + } + await this.apply(migration); + applied.push({ version: migration.version, name: migration.name }); + } + + const finalVersion = await this.currentVersion(); + if (applied.length > 0) { + this.log.info("migrations applied", { + count: applied.length, + finalVersion, + names: applied.map((a) => a.name), + }); + } else { + this.log.debug("schema up to date", { finalVersion }); + } + + return { applied, skipped, finalVersion }; + } + + /** Return the highest migration version recorded, or 0 if none. */ + async currentVersion(): Promise { + const result = await sql<{ v: number | null }>` + SELECT MAX(version) AS v FROM schema_migrations + `.execute(this.db); + const row = result.rows[0]; + return row?.v ?? 0; + } + + private async ensureBootstrapTable(): Promise { + // The very first migration creates `schema_migrations`, but we need + // the table to exist BEFORE we read from it. Create it idempotently here. + await this.db.schema + .createTable("schema_migrations") + .ifNotExists() + .addColumn("version", "integer", (col) => col.primaryKey()) + .addColumn("name", "text", (col) => col.notNull()) + .addColumn("applied_at", "text", (col) => col.notNull()) + .execute(); + } + + private async apply(migration: Migration): Promise { + const startedAt = performance.now(); + this.log.info("applying migration", { + version: migration.version, + name: migration.name, + }); + + await this.db.transaction().execute(async (trx) => { + // Migrations operate on the bare DDL surface; `Kysely` is + // the standard variance escape hatch since at apply-time the + // typed `Database` may not yet reflect the schema being created. + await migration.up(trx as unknown as Kysely); + await trx + .insertInto("schema_migrations") + .values({ + version: migration.version, + name: migration.name, + applied_at: new Date().toISOString(), + }) + .execute(); + }); + + const durationMs = Number((performance.now() - startedAt).toFixed(2)); + this.log.info("migration applied", { + version: migration.version, + name: migration.name, + durationMs, + }); + } + + private assertVersionsUnique(): void { + const seen = new Set(); + for (const m of this.migrations) { + if (seen.has(m.version)) { + throw new Error( + `Duplicate migration version ${m.version}; check crates/core/src/infrastructure/db/migrations/index.ts`, + ); + } + seen.add(m.version); + } + } +} diff --git a/crates/core/src/infrastructure/db/schema.ts b/crates/core/src/infrastructure/db/schema.ts new file mode 100644 index 0000000..64c3795 --- /dev/null +++ b/crates/core/src/infrastructure/db/schema.ts @@ -0,0 +1,45 @@ +/** + * Kysely `Database` schema — the canonical TypeScript description of every + * table the core crate persists into SQLite. + * + * Phase 2 only declares the bookkeeping table required by the migrator + * (`schema_migrations`). Each subsequent migration that introduces a new + * domain table also extends this interface here so the type-checker prevents + * us from referencing columns that don't exist. + * + * Convention: + * - Surrogate IDs use ULIDs (string) generated by `@std/ulid`. + * - Timestamps are stored as ISO 8601 strings (TEXT) — easy to read in + * `sqlite3` CLI and trivially sortable. + * - Booleans are stored as 0/1 INTEGERs (SQLite has no native bool). + */ + +import type { Generated } from "kysely"; + +// ===================================================================== +// schema_migrations — managed by `Migrator` +// ===================================================================== + +export interface SchemaMigrationsTable { + /** Monotonically increasing migration version, e.g. 1, 2, 3. */ + version: number; + /** Human-readable name, e.g. `"0001_initial"`. */ + name: string; + /** ISO timestamp of when the migration was applied. */ + applied_at: string; +} + +// ===================================================================== +// Composite Database type — passed to `Kysely` +// ===================================================================== + +export interface Database { + schema_migrations: SchemaMigrationsTable; +} + +// ===================================================================== +// Helper aliases for migrations +// ===================================================================== + +export type GeneratedId = Generated; +export type Timestamp = string; diff --git a/crates/core/src/infrastructure/http/middleware/auth.ts b/crates/core/src/infrastructure/http/middleware/auth.ts new file mode 100644 index 0000000..4a27729 --- /dev/null +++ b/crates/core/src/infrastructure/http/middleware/auth.ts @@ -0,0 +1,76 @@ +/** + * Bearer-token authentication middleware. + * + * Compares the `Authorization: Bearer ` header against the configured + * `IPC_TOKEN`. The comparison is constant-time to avoid leaking the token + * via timing side-channels on the loopback interface. + * + * Routes flagged `anonymous: true` (e.g. `/health`) are exempted. The match + * decision is provided to this middleware via `ctx.locals.routeAnonymous`, + * which the server sets after route resolution. + */ + +import { IPC_AUTH_HEADER, IpcErrorCode } from "@elly/shared"; + +import type { HttpMiddleware } from "../types.ts"; +import { jsonError } from "./error.ts"; + +const BEARER_PREFIX = "Bearer "; + +export interface AuthMiddlewareOptions { + readonly token: string; +} + +export function authMiddleware(options: AuthMiddlewareOptions): HttpMiddleware { + const expected = options.token; + if (expected.length === 0) { + throw new Error("authMiddleware requires a non-empty IPC token"); + } + + return (ctx, next): Promise => { + if (ctx.locals.routeAnonymous === true) { + return next(); + } + + const header = ctx.request.headers.get(IPC_AUTH_HEADER); + if (!header || !header.startsWith(BEARER_PREFIX)) { + ctx.logger.warn("ipc auth missing", { reason: "no_bearer_header" }); + return Promise.resolve( + jsonError({ + status: 401, + code: IpcErrorCode.UNAUTHORIZED, + message: "Missing or malformed Authorization header", + requestId: ctx.requestId, + }), + ); + } + + const presented = header.slice(BEARER_PREFIX.length); + if (!timingSafeEqual(presented, expected)) { + ctx.logger.warn("ipc auth failed", { reason: "token_mismatch" }); + return Promise.resolve( + jsonError({ + status: 401, + code: IpcErrorCode.UNAUTHORIZED, + message: "Invalid IPC token", + requestId: ctx.requestId, + }), + ); + } + + return next(); + }; +} + +function timingSafeEqual(a: string, b: string): boolean { + // Constant-time equality. We compare to the longer of the two so timing + // doesn't leak length. Diff bits accumulate into `mismatch`. + const ab = new TextEncoder().encode(a); + const bb = new TextEncoder().encode(b); + const len = Math.max(ab.length, bb.length); + let mismatch = ab.length ^ bb.length; + for (let i = 0; i < len; i++) { + mismatch |= (ab[i] ?? 0) ^ (bb[i] ?? 0); + } + return mismatch === 0; +} diff --git a/crates/core/src/infrastructure/http/middleware/error.ts b/crates/core/src/infrastructure/http/middleware/error.ts new file mode 100644 index 0000000..6db9e4f --- /dev/null +++ b/crates/core/src/infrastructure/http/middleware/error.ts @@ -0,0 +1,97 @@ +/** + * Error-handling middleware + JSON error helper. + * + * The middleware wraps the entire request chain. Any uncaught exception is + * captured, logged with its stack, and serialized to the canonical + * `IpcErrorBody` envelope (`@elly/shared`). Known `IpcError` instances + * preserve their `code`/`status`/`requestId`; everything else is rendered + * as a generic 500 to avoid leaking internals. + */ + +import { IPC_ERROR_STATUS, IpcError, IpcErrorCode } from "@elly/shared"; +import type { IpcErrorBody } from "@elly/shared"; + +import type { HttpMiddleware } from "../types.ts"; + +export function errorMiddleware(): HttpMiddleware { + return async (ctx, next) => { + try { + return await next(); + } catch (err) { + if (err instanceof IpcError) { + ctx.logger.warn("ipc handler threw IpcError", { + code: err.code, + status: err.status, + err: { name: err.name, message: err.message }, + }); + return jsonError({ + status: err.status, + code: err.code, + message: err.message, + requestId: ctx.requestId, + details: err.details, + }); + } + + const wrapped = err instanceof Error ? err : new Error(String(err)); + ctx.logger.error("ipc handler threw unexpectedly", { + err: wrapped, + }); + return jsonError({ + status: 500, + code: IpcErrorCode.INTERNAL, + message: "Internal core error", + requestId: ctx.requestId, + }); + } + }; +} + +export interface JsonErrorInput { + readonly status: number; + readonly code: string; + readonly message: string; + readonly requestId?: string; + readonly details?: unknown; +} + +/** + * Build a `Response` carrying the canonical `IpcErrorBody` envelope. + * + * The status falls back to `IPC_ERROR_STATUS[code]` when the caller passes + * `status: 0`, allowing handlers to specify only the semantic code. + */ +export function jsonError(input: JsonErrorInput): Response { + const status = input.status > 0 + ? input.status + : (IPC_ERROR_STATUS as Record)[input.code] ?? 500; + + const body: IpcErrorBody = { + error: { + code: input.code, + message: input.message, + requestId: input.requestId, + details: input.details, + }, + }; + return new Response(JSON.stringify(body), { + status, + headers: { + "content-type": "application/json; charset=utf-8", + ...(input.requestId ? { "x-request-id": input.requestId } : {}), + }, + }); +} + +/** + * Convenience for handlers: build a success JSON response. + */ +export function jsonOk(body: unknown, init: { status?: number; requestId?: string } = {}): Response { + return new Response(JSON.stringify(body), { + status: init.status ?? 200, + headers: { + "content-type": "application/json; charset=utf-8", + ...(init.requestId ? { "x-request-id": init.requestId } : {}), + }, + }); +} diff --git a/crates/core/src/infrastructure/http/middleware/logging.ts b/crates/core/src/infrastructure/http/middleware/logging.ts new file mode 100644 index 0000000..4e0154a --- /dev/null +++ b/crates/core/src/infrastructure/http/middleware/logging.ts @@ -0,0 +1,45 @@ +/** + * Request/response logging middleware. + * + * Emits two structured log lines per request: + * - `info` `ipc request started` — method, path, requestId + * - `info` `ipc request completed` — same fields + status, durationMs + * + * Failures (rejected promise inside `next`) are re-thrown after logging + * so the error middleware downstream can format them into envelopes. + * + * Per the architectural mandate, every request's latency is recorded — + * this is the foundation for production SLO dashboards in later phases. + */ + +import type { HttpMiddleware } from "../types.ts"; + +export function loggingMiddleware(): HttpMiddleware { + return async (ctx, next) => { + ctx.logger.info("ipc request started", { + method: ctx.method, + path: ctx.url.pathname, + }); + + try { + const response = await next(); + const durationMs = Number((performance.now() - ctx.startedAtMs).toFixed(2)); + ctx.logger.info("ipc request completed", { + method: ctx.method, + path: ctx.url.pathname, + status: response.status, + durationMs, + }); + return response; + } catch (err) { + const durationMs = Number((performance.now() - ctx.startedAtMs).toFixed(2)); + ctx.logger.error("ipc request failed", { + method: ctx.method, + path: ctx.url.pathname, + durationMs, + err: err instanceof Error ? err : new Error(String(err)), + }); + throw err; + } + }; +} diff --git a/crates/core/src/infrastructure/http/router.ts b/crates/core/src/infrastructure/http/router.ts new file mode 100644 index 0000000..abfbc53 --- /dev/null +++ b/crates/core/src/infrastructure/http/router.ts @@ -0,0 +1,147 @@ +/** + * Minimal dependency-free HTTP router. + * + * Compiles `:param` segments in path patterns into RegExp at registration + * time so dispatch is O(routes) per request. Returns: + * - The matching route plus extracted `params`, or + * - A 404 sentinel handler if no route matches the path, or + * - A 405 sentinel handler if the path matched but no method matched. + * + * Middleware compose left-to-right; the final layer is the route handler. + */ + +import type { + HttpContext, + HttpHandler, + HttpMethod, + HttpHandler as Handler, + HttpMiddleware, + RouteDefinition, +} from "./types.ts"; + +interface CompiledRoute { + readonly method: HttpMethod; + readonly pattern: RegExp; + readonly paramNames: ReadonlyArray; + readonly anonymous: boolean; + readonly handler: Handler; + readonly rawPath: string; +} + +export interface RouteMatch { + readonly handler: HttpHandler; + readonly params: Readonly>; + readonly anonymous: boolean; + readonly status: 200 | 404 | 405; +} + +export class HttpRouter { + private readonly routes: CompiledRoute[] = []; + private readonly middleware: HttpMiddleware[] = []; + + use(mw: HttpMiddleware): void { + this.middleware.push(mw); + } + + register(route: RouteDefinition): void { + this.routes.push(compile(route)); + } + + registerMany(routes: ReadonlyArray): void { + for (const r of routes) this.register(r); + } + + /** Find the best match for `(method, path)`. */ + resolve(method: HttpMethod, path: string): RouteMatch { + let methodMismatch = false; + for (const route of this.routes) { + const m = route.pattern.exec(path); + if (!m) continue; + if (route.method !== method) { + methodMismatch = true; + continue; + } + const params: Record = {}; + for (let i = 0; i < route.paramNames.length; i++) { + params[route.paramNames[i]] = decodeURIComponent(m[i + 1] ?? ""); + } + return { + handler: route.handler, + params, + anonymous: route.anonymous, + status: 200, + }; + } + return { + handler: methodMismatch ? methodNotAllowedHandler : notFoundHandler, + params: {}, + anonymous: true, + status: methodMismatch ? 405 : 404, + }; + } + + /** + * Run the middleware chain followed by the matched handler. + * `match.handler` is the final layer. + */ + dispatch(ctx: HttpContext, match: RouteMatch): Promise { + let index = -1; + const chain = this.middleware; + + const next = (): Promise => { + index++; + if (index < chain.length) { + return chain[index](ctx, next); + } + return Promise.resolve(match.handler(ctx)); + }; + + return next(); + } +} + +// ===================================================================== +// Helpers +// ===================================================================== + +function compile(route: RouteDefinition): CompiledRoute { + const paramNames: string[] = []; + // Escape literal characters except `:param` segments. + const regexBody = route.path + .split("/") + .map((segment) => { + if (segment.startsWith(":")) { + paramNames.push(segment.slice(1)); + return "([^/]+)"; + } + return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + }) + .join("/"); + + return { + method: route.method, + pattern: new RegExp(`^${regexBody}$`), + paramNames, + anonymous: route.anonymous ?? false, + handler: route.handler, + rawPath: route.path, + }; +} + +function notFoundHandler(): Response { + return new Response( + JSON.stringify({ + error: { code: "not_found", message: "Route not found" }, + }), + { status: 404, headers: { "content-type": "application/json" } }, + ); +} + +function methodNotAllowedHandler(): Response { + return new Response( + JSON.stringify({ + error: { code: "method_not_allowed", message: "Method not allowed for this route" }, + }), + { status: 405, headers: { "content-type": "application/json" } }, + ); +} diff --git a/crates/core/src/infrastructure/http/routes/system.ts b/crates/core/src/infrastructure/http/routes/system.ts new file mode 100644 index 0000000..daddfac --- /dev/null +++ b/crates/core/src/infrastructure/http/routes/system.ts @@ -0,0 +1,148 @@ +/** + * Phase 2 system routes: + * - `GET /health` Anonymous liveness probe (200 OK). + * - `GET /v1/version` Authenticated diagnostic payload. + * - `GET /v1/events` Authenticated SSE stream of domain events. + * + * These are the only routes Phase 2 ships. Phase 4 will add the feature + * routes (`/v1/applications/...`, `/v1/suggestions/...`, etc.). + */ + +import { IpcRoutes } from "@elly/shared"; +import type { AnyDomainEvent } from "@elly/shared"; + +import type { DbConnection } from "../../db/connection.ts"; +import type { KvHandle } from "../../kv/store.ts"; +import type { DomainEventBus } from "../../pubsub/bus.ts"; +import { jsonOk } from "../middleware/error.ts"; +import type { HttpContext, RouteDefinition } from "../types.ts"; + +export interface SystemRouteDeps { + readonly version: string; + readonly startedAt: number; + readonly db: DbConnection; + readonly kv: KvHandle; + readonly bus: DomainEventBus; +} + +const SSE_HEADERS: HeadersInit = { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache, no-transform", + "connection": "keep-alive", + // The bot's SSE consumer (Phase 3) doesn't use `x-accel-buffering`, but + // operators sitting behind nginx will thank us. + "x-accel-buffering": "no", +}; + +export function buildSystemRoutes(deps: SystemRouteDeps): ReadonlyArray { + return [ + { + method: "GET", + path: IpcRoutes.HEALTH, + anonymous: true, + handler: (ctx) => + jsonOk( + { status: "ok", uptimeMs: Date.now() - deps.startedAt }, + { requestId: ctx.requestId }, + ), + }, + + { + method: "GET", + path: IpcRoutes.VERSION, + handler: async (ctx) => { + const stats = await deps.db.getStats(); + return jsonOk( + { + version: deps.version, + pid: Deno.pid, + uptimeMs: Date.now() - deps.startedAt, + deno: Deno.version, + db: { + path: stats.path, + sizeBytes: stats.sizeBytes, + tables: stats.tables, + }, + kv: { + path: deps.kv.path, + }, + bus: { + subscribers: deps.bus.subscribers, + }, + }, + { requestId: ctx.requestId }, + ); + }, + }, + + { + method: "GET", + path: IpcRoutes.EVENTS, + handler: (ctx) => buildSseResponse(ctx, deps.bus), + }, + ]; +} + +function buildSseResponse(ctx: HttpContext, bus: DomainEventBus): Response { + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + start(controller) { + let closed = false; + const closeIfOpen = () => { + if (closed) return; + closed = true; + try { + controller.close(); + } catch { + // already closed + } + }; + const tryEnqueue = (chunk: Uint8Array) => { + if (closed) return; + try { + controller.enqueue(chunk); + } catch { + closed = true; + } + }; + + // Initial comment so proxies open the connection right away. + tryEnqueue(encoder.encode(`: connected requestId=${ctx.requestId}\n\n`)); + + // Replay nothing — Phase 2 has no backlog. Subscribe live. + const unsubscribe = bus.subscribe((event: AnyDomainEvent) => { + const payload = `event: ${event.type}\nid: ${event.id}\ndata: ${ + JSON.stringify(event) + }\n\n`; + tryEnqueue(encoder.encode(payload)); + }); + + // Periodic comment-line keep-alive so idle connections aren't reaped + // by intermediaries that close on inactivity. + const heartbeat = setInterval(() => { + tryEnqueue(encoder.encode(`: keep-alive ${Date.now()}\n\n`)); + }, 15_000); + + const onAbort = () => { + ctx.logger.debug("sse client disconnected", { requestId: ctx.requestId }); + clearInterval(heartbeat); + unsubscribe(); + closeIfOpen(); + }; + + if (ctx.signal.aborted) { + onAbort(); + } else { + ctx.signal.addEventListener("abort", onAbort, { once: true }); + } + + ctx.logger.info("sse client connected", { requestId: ctx.requestId }); + }, + }); + + return new Response(stream, { + status: 200, + headers: { ...SSE_HEADERS, "x-request-id": ctx.requestId }, + }); +} diff --git a/crates/core/src/infrastructure/http/server.ts b/crates/core/src/infrastructure/http/server.ts new file mode 100644 index 0000000..0ebf225 --- /dev/null +++ b/crates/core/src/infrastructure/http/server.ts @@ -0,0 +1,167 @@ +/** + * Core IPC HTTP server. + * + * Wires together: + * - `HttpRouter` — route resolution + middleware composition. + * - Middleware chain (in onion order, outer → inner): + * errorMiddleware → loggingMiddleware → authMiddleware → handler + * - `Deno.serve` — Deno's native server, bound to `host:port` from config. + * + * Exposes `start()` (returns when the server is accepting connections) and + * `shutdown()` (graceful drain with a hard timeout). + */ + +import { ulid } from "@std/ulid"; +import { IPC_REQUEST_ID_HEADER } from "@elly/shared"; +import type { Logger } from "@elly/shared"; + +import type { DomainEventBus } from "../pubsub/bus.ts"; +import { authMiddleware } from "./middleware/auth.ts"; +import { errorMiddleware } from "./middleware/error.ts"; +import { loggingMiddleware } from "./middleware/logging.ts"; +import { HttpRouter } from "./router.ts"; +import type { + HttpContext, + HttpMethod, + RouteDefinition, +} from "./types.ts"; + +export interface HttpServerOptions { + readonly host: string; + readonly port: number; + readonly ipcToken: string; + readonly logger: Logger; + readonly bus: DomainEventBus; + readonly routes: ReadonlyArray; + /** Hard timeout (ms) for shutdown to wait for in-flight requests. */ + readonly shutdownTimeoutMs?: number; +} + +export interface HttpServer { + readonly host: string; + readonly port: number; + start(): Promise; + shutdown(): Promise; +} + +export function createHttpServer(options: HttpServerOptions): HttpServer { + const log = options.logger.child({ component: "http" }); + const router = new HttpRouter(); + + // Outer-most middleware first. + router.use(errorMiddleware()); + router.use(loggingMiddleware()); + router.use(authMiddleware({ token: options.ipcToken })); + + router.registerMany(options.routes); + + const controller = new AbortController(); + let server: Deno.HttpServer | null = null; + let listening: Promise | null = null; + + return { + host: options.host, + port: options.port, + + start(): Promise { + if (listening) return listening; + + listening = new Promise((resolve, reject) => { + try { + server = Deno.serve( + { + hostname: options.host, + port: options.port, + signal: controller.signal, + onListen: ({ hostname, port }) => { + log.info("ipc http server listening", { hostname, port }); + resolve(); + }, + onError: (err) => { + const wrapped = err instanceof Error ? err : new Error(String(err)); + log.error("ipc http unhandled error", { err: wrapped }); + return new Response("internal", { status: 500 }); + }, + }, + (request) => handleRequest(request, router, options.bus, log), + ); + } catch (err) { + reject(err); + } + }); + + return listening; + }, + + async shutdown(): Promise { + log.info("ipc http server shutting down"); + controller.abort(); + if (server) { + const timeoutMs = options.shutdownTimeoutMs ?? 5_000; + await raceWithTimeout(server.finished, timeoutMs, () => { + log.warn("ipc http server shutdown timed out; forcing", { timeoutMs }); + }); + } + log.info("ipc http server stopped"); + }, + }; +} + +function handleRequest( + request: Request, + router: HttpRouter, + bus: DomainEventBus, + logger: Logger, +): Promise { + const url = new URL(request.url); + const method = request.method.toUpperCase() as HttpMethod; + const requestId = request.headers.get(IPC_REQUEST_ID_HEADER) ?? ulid(); + const startedAtMs = performance.now(); + + const match = router.resolve(method, url.pathname); + + const locals: Record = { + routeAnonymous: match.anonymous, + routeStatus: match.status, + }; + + const requestLogger = logger.child({ + requestId, + method, + path: url.pathname, + }); + + const ctx: HttpContext = { + request, + url, + method, + params: match.params, + requestId, + startedAtMs, + logger: requestLogger, + bus, + locals, + signal: request.signal, + }; + + return router.dispatch(ctx, match); +} + +async function raceWithTimeout( + promise: Promise, + timeoutMs: number, + onTimeout: () => void, +): Promise { + let timer: number | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => { + onTimeout(); + resolve(); + }, timeoutMs); + }); + try { + await Promise.race([promise, timeout]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} diff --git a/crates/core/src/infrastructure/http/types.ts b/crates/core/src/infrastructure/http/types.ts new file mode 100644 index 0000000..046ed94 --- /dev/null +++ b/crates/core/src/infrastructure/http/types.ts @@ -0,0 +1,53 @@ +/** + * Shared types for the IPC HTTP layer. + * + * The router is intentionally tiny and dependency-free — Phase 2 doesn't + * justify a framework. Middleware compose around handlers via a classic + * onion model: each middleware receives `(ctx, next)` and may short-circuit + * by returning a response without calling `next`. + */ + +import type { Logger } from "@elly/shared"; + +import type { DomainEventBus } from "../pubsub/bus.ts"; + +export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + +export interface HttpContext { + /** Inbound request. */ + readonly request: Request; + /** Parsed URL — cached so middleware/handlers don't re-parse. */ + readonly url: URL; + /** Method (uppercased). */ + readonly method: HttpMethod; + /** Path params extracted by the router (empty for no-param routes). */ + readonly params: Readonly>; + /** Correlation ID — generated or echoed from `x-request-id`. */ + readonly requestId: string; + /** Wall-clock start of the request in ms (for latency logging). */ + readonly startedAtMs: number; + /** Logger pre-bound with method, path, requestId. */ + readonly logger: Logger; + /** Domain event bus (handlers may publish from here). */ + readonly bus: DomainEventBus; + /** Mutable bag for cross-middleware state (rarely used). */ + readonly locals: Record; + /** Convenience accessor for the connection abort signal. */ + readonly signal: AbortSignal; +} + +export type HttpHandler = (ctx: HttpContext) => Promise | Response; + +export type HttpMiddleware = ( + ctx: HttpContext, + next: () => Promise, +) => Promise; + +export interface RouteDefinition { + readonly method: HttpMethod; + /** Path pattern, e.g. `"/v1/applications/:id"`. Compiled by the router. */ + readonly path: string; + /** Optional: marks a route as unauthenticated (default: requires auth). */ + readonly anonymous?: boolean; + readonly handler: HttpHandler; +} diff --git a/crates/core/src/infrastructure/kv/cache.ts b/crates/core/src/infrastructure/kv/cache.ts new file mode 100644 index 0000000..32525e9 --- /dev/null +++ b/crates/core/src/infrastructure/kv/cache.ts @@ -0,0 +1,85 @@ +/** + * Generic TTL cache backed by `Deno.Kv`. + * + * Used in Phase 4 by the PikaNetwork client to cache profile/leaderboard + * lookups across restarts. Each entry carries an explicit `cachedAt` so + * callers can detect "stale-but-acceptable" reads. + * + * Keys are namespaced by `cacheKind` (e.g. `"pika:profile"`) so unrelated + * features cannot collide. The actual KV key is + * `[ "cache", cacheKind, ...identifier ]`. + */ + +import type { KvHandle } from "./store.ts"; + +export interface CacheEntry { + readonly value: T; + readonly cachedAt: number; + readonly expiresAt: number; +} + +export interface CacheGetResult { + readonly hit: boolean; + readonly entry: CacheEntry | null; +} + +export class CacheStore { + constructor(private readonly handle: KvHandle) {} + + async get(cacheKind: string, identifier: Deno.KvKey): Promise> { + const key = this.buildKey(cacheKind, identifier); + const entry = await this.handle.kv.get>(key); + if (entry.value === null) { + return { hit: false, entry: null }; + } + if (entry.value.expiresAt <= Date.now()) { + // Best-effort cleanup of an expired entry that Kv hasn't reaped yet. + await this.handle.kv.delete(key); + return { hit: false, entry: null }; + } + return { hit: true, entry: entry.value }; + } + + async set(args: { + cacheKind: string; + identifier: Deno.KvKey; + value: T; + ttlMs: number; + }): Promise> { + if (args.ttlMs <= 0) { + throw new Error("CacheStore.set requires ttlMs > 0"); + } + const now = Date.now(); + const entry: CacheEntry = { + value: args.value, + cachedAt: now, + expiresAt: now + args.ttlMs, + }; + const key = this.buildKey(args.cacheKind, args.identifier); + await this.handle.kv.set(key, entry, { expireIn: args.ttlMs }); + return entry; + } + + async delete(cacheKind: string, identifier: Deno.KvKey): Promise { + const key = this.buildKey(cacheKind, identifier); + await this.handle.kv.delete(key); + } + + /** + * Drop every entry for a given `cacheKind`. Use sparingly — full prefix + * scans are O(n) over that kind. + */ + async deleteKind(cacheKind: string): Promise { + const prefix: Deno.KvKey = ["cache", cacheKind]; + let count = 0; + for await (const entry of this.handle.kv.list({ prefix })) { + await this.handle.kv.delete(entry.key); + count++; + } + return count; + } + + private buildKey(cacheKind: string, identifier: Deno.KvKey): Deno.KvKey { + return this.handle.key("cache", cacheKind, ...identifier); + } +} diff --git a/crates/core/src/infrastructure/kv/cooldown.ts b/crates/core/src/infrastructure/kv/cooldown.ts new file mode 100644 index 0000000..25d8008 --- /dev/null +++ b/crates/core/src/infrastructure/kv/cooldown.ts @@ -0,0 +1,93 @@ +/** + * Cooldown store backed by `Deno.Kv`. + * + * Survives process restarts (unlike the legacy in-memory `Collection` map). + * Each entry is keyed by `[ "cooldown", command, userId ]` and stores the + * epoch-ms timestamp at which the cooldown expires. We use Kv's `expireIn` + * to let the runtime garbage-collect expired entries. + * + * `checkAndSet` is atomic via Kv's compare-and-swap loop — concurrent + * invocations cannot both observe "no cooldown" and both set one. + */ + +import type { KvHandle } from "./store.ts"; + +export interface CooldownCheckResult { + /** True if the caller is currently rate-limited. */ + readonly limited: boolean; + /** Seconds remaining before the cooldown expires (rounded up). 0 if not limited. */ + readonly retryAfterSeconds: number; + /** Underlying epoch-ms expiry; 0 if not limited. */ + readonly expiresAtMs: number; +} + +export class CooldownStore { + constructor(private readonly handle: KvHandle) {} + + /** + * Atomically check whether `userId` is on cooldown for `command`. If not, + * set a new cooldown of `durationMs` and return `limited: false`. + * + * Retries up to 3 times on Kv contention before giving up. + */ + async checkAndSet(args: { + command: string; + userId: string; + durationMs: number; + }): Promise { + if (args.durationMs <= 0) { + return { limited: false, retryAfterSeconds: 0, expiresAtMs: 0 }; + } + + const key = this.handle.key("cooldown", args.command, args.userId); + + for (let attempt = 0; attempt < 3; attempt++) { + const existing = await this.handle.kv.get(key); + const now = Date.now(); + + if (existing.value !== null && existing.value > now) { + return { + limited: true, + retryAfterSeconds: Math.ceil((existing.value - now) / 1000), + expiresAtMs: existing.value, + }; + } + + const expiresAtMs = now + args.durationMs; + const result = await this.handle.kv.atomic() + .check(existing) + .set(key, expiresAtMs, { expireIn: args.durationMs }) + .commit(); + + if (result.ok) { + return { limited: false, retryAfterSeconds: 0, expiresAtMs }; + } + // Contention — retry. + } + + // Three CAS retries failed. Treat as limited so we never bypass cooldowns + // under load, and report a one-second back-off. + return { limited: true, retryAfterSeconds: 1, expiresAtMs: Date.now() + 1000 }; + } + + /** Peek at the current cooldown for a user/command without modifying it. */ + async peek(command: string, userId: string): Promise { + const key = this.handle.key("cooldown", command, userId); + const entry = await this.handle.kv.get(key); + const now = Date.now(); + if (entry.value === null || entry.value <= now) { + return { limited: false, retryAfterSeconds: 0, expiresAtMs: 0 }; + } + return { + limited: true, + retryAfterSeconds: Math.ceil((entry.value - now) / 1000), + expiresAtMs: entry.value, + }; + } + + /** Manually clear a cooldown — used by admin tooling. */ + async clear(command: string, userId: string): Promise { + const key = this.handle.key("cooldown", command, userId); + await this.handle.kv.delete(key); + } +} diff --git a/crates/core/src/infrastructure/kv/interactionState.ts b/crates/core/src/infrastructure/kv/interactionState.ts new file mode 100644 index 0000000..8bc34b7 --- /dev/null +++ b/crates/core/src/infrastructure/kv/interactionState.ts @@ -0,0 +1,76 @@ +/** + * Interaction state store. + * + * Discord component customIds are capped at 100 characters, which is far too + * small to embed arbitrary context. Per the architectural mandate, we keep + * interactions stateless by stashing context in Deno.Kv keyed by a short + * token (typically a ULID embedded in the customId). + * + * Records have a default TTL of 15 minutes to keep the KV store bounded — + * Discord interactions become invalid after 15 minutes anyway. + */ + +import { ulid } from "@std/ulid"; +import type { KvHandle } from "./store.ts"; + +const DEFAULT_TTL_MS = 15 * 60 * 1000; + +export interface InteractionStateRecord { + readonly token: string; + readonly value: T; + readonly storedAt: number; +} + +export class InteractionStateStore { + constructor(private readonly handle: KvHandle) {} + + /** + * Persist a context object and return a fresh ULID token. The token is + * what callers should embed in customIds — never the value itself. + */ + async create(value: T, ttlMs: number = DEFAULT_TTL_MS): Promise { + const token = ulid(); + const key = this.handle.key("interaction", token); + const record: InteractionStateRecord = { + token, + value, + storedAt: Date.now(), + }; + await this.handle.kv.set(key, record, { expireIn: ttlMs }); + return token; + } + + /** + * Read a stored context. Returns `null` if the token doesn't exist or + * has already expired/been consumed. + */ + async read(token: string): Promise | null> { + const key = this.handle.key("interaction", token); + const entry = await this.handle.kv.get>(key); + return entry.value; + } + + /** + * Read-and-delete (consume-once semantics). Use this for one-shot + * confirm/cancel flows so a stale token can't replay the action. + */ + async consume(token: string): Promise | null> { + const key = this.handle.key("interaction", token); + const entry = await this.handle.kv.get>(key); + if (entry.value === null) return null; + + // Atomically delete only if the version we just read still matches. + const result = await this.handle.kv.atomic() + .check(entry) + .delete(key) + .commit(); + + return result.ok ? entry.value : null; + } + + /** Manually invalidate a token (e.g. when the parent message is deleted). */ + async revoke(token: string): Promise { + const key = this.handle.key("interaction", token); + await this.handle.kv.delete(key); + } +} diff --git a/crates/core/src/infrastructure/kv/store.ts b/crates/core/src/infrastructure/kv/store.ts new file mode 100644 index 0000000..6bc6458 --- /dev/null +++ b/crates/core/src/infrastructure/kv/store.ts @@ -0,0 +1,73 @@ +/** + * `Deno.Kv` lifecycle wrapper. + * + * Owns the single `Deno.Kv` handle for the core process and exposes typed + * helpers used by: + * - `CooldownStore` (per-command, per-user TTL gating) + * - `InteractionStateStore` (ephemeral context for stateless components) + * - `CacheStore` (generic TTL cache, e.g. for the Pika client) + * + * Why one file? + * The three concerns share the same KV handle and the same notion of + * `KvKey` prefixes. Keeping them in one module makes the prefix table + * explicit and prevents two features from accidentally colliding. + */ + +import { ensureDir } from "@std/fs"; +import { dirname } from "@std/path"; +import type { Logger } from "@elly/shared"; + +export type KvNamespace = "cooldown" | "interaction" | "cache" | "meta"; + +export const KV_NAMESPACES: ReadonlyArray = [ + "cooldown", + "interaction", + "cache", + "meta", +]; + +export interface KvHandle { + /** The underlying `Deno.Kv` instance. */ + readonly kv: Deno.Kv; + /** Filesystem path the KV store was opened from. */ + readonly path: string; + /** Build a fully-qualified KV key by namespace + segments. */ + key(namespace: KvNamespace, ...segments: Deno.KvKey): Deno.KvKey; + /** Close the underlying connection. Safe to call multiple times. */ + close(): Promise; +} + +export interface OpenKvOptions { + readonly path: string; + readonly logger: Logger; +} + +/** + * Open (or create) the on-disk Deno.Kv store at `path`. + */ +export async function openKv(options: OpenKvOptions): Promise { + const log = options.logger.child({ component: "kv" }); + await ensureDir(dirname(options.path)); + + log.debug("opening deno.kv store", { path: options.path }); + const kv = await Deno.openKv(options.path); + log.info("deno.kv store opened", { path: options.path }); + + let closed = false; + + return { + kv, + path: options.path, + key(namespace: KvNamespace, ...segments: Deno.KvKey): Deno.KvKey { + return [namespace, ...segments]; + }, + async close(): Promise { + if (closed) return; + closed = true; + log.debug("closing deno.kv store"); + kv.close(); + // close() on Deno.Kv is sync; await Promise.resolve to keep the async signature. + await Promise.resolve(); + }, + }; +} diff --git a/crates/core/src/infrastructure/pubsub/bus.ts b/crates/core/src/infrastructure/pubsub/bus.ts new file mode 100644 index 0000000..01c9ed9 --- /dev/null +++ b/crates/core/src/infrastructure/pubsub/bus.ts @@ -0,0 +1,86 @@ +/** + * In-process domain event bus. + * + * Core services publish typed domain events here; the HTTP `events` route + * (Server-Sent Events) subscribes and forwards each event to the bot. + * + * Implementation: + * - Backed by `EventTarget`/`CustomEvent` — Deno's stdlib event primitive, + * no external deps. + * - Listeners are synchronous from the bus's perspective. Subscribers + * wrap their handler in their own queue if they need backpressure + * (the SSE bridge does this via the response stream). + * - Every published event is automatically stamped with a ULID `id` and + * `timestamp` if the publisher didn't supply them. + */ + +import { ulid } from "@std/ulid"; +import type { AnyDomainEvent, Logger } from "@elly/shared"; + +const EVENT_NAME = "domain"; + +export type EventListener = (event: AnyDomainEvent) => void; + +export type EventPublishInput = + & Omit + & Partial>; + +export class DomainEventBus { + private readonly target = new EventTarget(); + private readonly log: Logger; + private listenerCount = 0; + + constructor(logger: Logger) { + this.log = logger.child({ component: "pubsub" }); + } + + /** Publish a fully-formed or partial event. Returns the final event object. */ + publish(input: EventPublishInput): AnyDomainEvent { + const event = { + ...input, + id: input.id ?? ulid(), + timestamp: input.timestamp ?? Date.now(), + } as AnyDomainEvent; + + this.log.debug("publishing domain event", { + type: event.type, + id: event.id, + listeners: this.listenerCount, + }); + + this.target.dispatchEvent(new CustomEvent(EVENT_NAME, { detail: event })); + return event; + } + + /** + * Subscribe to every event. Returns an unsubscribe function — call it + * exactly once when the consumer is torn down (e.g. SSE client disconnect). + */ + subscribe(handler: EventListener): () => void { + const wrapped = (e: Event) => { + const ev = (e as CustomEvent).detail; + try { + handler(ev); + } catch (err) { + this.log.error("subscriber threw", { + eventType: ev.type, + eventId: ev.id, + err: err instanceof Error ? err : new Error(String(err)), + }); + } + }; + + this.target.addEventListener(EVENT_NAME, wrapped); + this.listenerCount++; + + return () => { + this.target.removeEventListener(EVENT_NAME, wrapped); + this.listenerCount = Math.max(0, this.listenerCount - 1); + }; + } + + /** Active listener count — useful for /v1/version diagnostics. */ + get subscribers(): number { + return this.listenerCount; + } +} diff --git a/crates/core/src/main.ts b/crates/core/src/main.ts new file mode 100644 index 0000000..e33614a --- /dev/null +++ b/crates/core/src/main.ts @@ -0,0 +1,165 @@ +/** + * @elly/core entrypoint — Phase 2 boot. + * + * Lifecycle: + * 1. Load + validate `config.toml` (Zod). + * 2. Load + validate environment (Zod). + * 3. Build the root structured logger. + * 4. Build the DI container (DB, migrations, KV, bus, HTTP server). + * 5. Start the IPC HTTP server. + * 6. Publish `server.ready` on the domain bus. + * 7. Block on SIGINT/SIGTERM, then gracefully shut down the container. + */ + +import { + ConfigError, + ConfigValidationError, + CoreEnvSchema, + createLogger, + EnvValidationError, + loadConfig, + loadEnv, + type Config, + type CoreEnv, + type Logger, +} from "@elly/shared"; + +import { buildContainer, type CoreContainer } from "./container.ts"; + +const CONFIG_PATH = Deno.env.get("CONFIG_PATH") ?? "./config.toml"; +const LOGGER_NAME = "@elly/core"; +const VERSION = "0.1.0"; + +async function main(): Promise { + const config = await loadConfigOrExit(); + const env = loadEnvOrExit(); + const logger = buildLogger(config, env); + + logger.info("phase 2 boot starting", { + crate: "core", + nodeEnv: env.NODE_ENV, + version: VERSION, + }); + + let container: CoreContainer | null = null; + try { + container = await buildContainer({ config, env, logger, version: VERSION }); + } catch (err) { + logger.fatal("container build failed", { + err: err instanceof Error ? err : new Error(String(err)), + }); + await logger.flush(); + Deno.exit(1); + } + + installSignalHandlers(container, logger); + + try { + await container.http.start(); + } catch (err) { + logger.fatal("ipc server failed to start", { + err: err instanceof Error ? err : new Error(String(err)), + }); + await container.shutdown(); + await logger.flush(); + Deno.exit(1); + } + + container.bus.publish({ + type: "server.ready", + payload: { version: VERSION, pid: Deno.pid }, + }); + + logger.info("phase 2 boot complete", { + pid: Deno.pid, + ipc: `${config.ipc.host}:${config.ipc.port}`, + }); + + // Hold the process open until a signal handler tears the container down. + await new Promise(() => {}); +} + +// ===================================================================== +// Boot helpers +// ===================================================================== + +async function loadConfigOrExit(): Promise { + try { + return await loadConfig(CONFIG_PATH); + } catch (err) { + bootFail(err, "config"); + } +} + +function loadEnvOrExit(): CoreEnv { + try { + return loadEnv(CoreEnvSchema); + } catch (err) { + bootFail(err, "env"); + } +} + +function buildLogger(config: Config, env: CoreEnv): Logger { + const level = env.LOG_LEVEL ?? config.logging.level; + const isProd = env.NODE_ENV === "production"; + + return createLogger({ + name: LOGGER_NAME, + level, + format: isProd ? "json" : config.logging.format, + file: config.logging.file + ? { + path: config.logging.file, + maxBytes: config.logging.file_max_bytes, + maxBackups: config.logging.file_max_backups, + } + : undefined, + }); +} + +function installSignalHandlers(container: CoreContainer, logger: Logger): void { + let shuttingDown = false; + const shutdown = (signal: string) => { + if (shuttingDown) { + logger.warn("shutdown already in progress; ignoring signal", { signal }); + return; + } + shuttingDown = true; + logger.info("shutdown signal received", { signal }); + container + .shutdown() + .catch((err) => { + logger.error("shutdown error", { + err: err instanceof Error ? err : new Error(String(err)), + }); + }) + .finally(() => { + logger.flush().finally(() => Deno.exit(0)); + }); + }; + Deno.addSignalListener("SIGINT", () => shutdown("SIGINT")); + Deno.addSignalListener("SIGTERM", () => shutdown("SIGTERM")); +} + +function bootFail(err: unknown, stage: "config" | "env"): never { + if (err instanceof ConfigValidationError || err instanceof EnvValidationError) { + console.error(`[@elly/core] ${stage} validation failed:`); + for (const issue of err.issues) { + console.error(` - ${issue.path}: ${issue.message}`); + } + Deno.exit(1); + } + if (err instanceof ConfigError) { + console.error(`[@elly/core] ${stage} error: ${err.message}`); + Deno.exit(1); + } + console.error(`[@elly/core] unexpected ${stage} error:`, err); + Deno.exit(1); +} + +if (import.meta.main) { + main().catch((err) => { + console.error("[@elly/core] fatal boot error:", err); + Deno.exit(1); + }); +} diff --git a/crates/shared/deno.json b/crates/shared/deno.json new file mode 100644 index 0000000..0beb543 --- /dev/null +++ b/crates/shared/deno.json @@ -0,0 +1,5 @@ +{ + "name": "@elly/shared", + "version": "0.1.0", + "exports": "./mod.ts" +} diff --git a/crates/shared/mod.ts b/crates/shared/mod.ts new file mode 100644 index 0000000..50a8b0b --- /dev/null +++ b/crates/shared/mod.ts @@ -0,0 +1,106 @@ +/** + * @elly/shared — Public API barrel. + * + * Both `@elly/core` and `@elly/bot` import from here. This barrel is the only + * sanctioned cross-crate surface. Anything not re-exported is considered + * implementation detail and may change without notice. + */ + +// ----- Config -------------------------------------------------------- +export { + ApiSchema, + BotSchema, + ChannelsSchema, + ColorsSchema, + ConfigSchema, + DatabaseSchema, + FeaturesSchema, + GuildSchema, + IpcSchema, + KvSchema, + LimitsSchema, + LoggingSchema, + RolesSchema, +} from "./src/config/schema.ts"; +export type { Config } from "./src/config/schema.ts"; +export type { + ApiConfig, + BotConfig, + ChannelsConfig, + ColorsConfig, + ConfigInput, + DatabaseConfig, + FeaturesConfig, + GuildConfig, + IpcConfig, + KvConfig, + LimitsConfig, + LoggingConfig, + RolesConfig, +} from "./src/config/types.ts"; +export { loadConfig, validateConfig } from "./src/config/loader.ts"; +export { + BotEnvSchema, + CoreEnvSchema, + loadEnv, + SharedEnvSchema, +} from "./src/config/env.ts"; +export type { BotEnv, CoreEnv, SharedEnv } from "./src/config/env.ts"; +export { + ConfigError, + ConfigValidationError, + EnvValidationError, +} from "./src/config/errors.ts"; +export type { ConfigValidationIssue } from "./src/config/errors.ts"; + +// ----- Logger -------------------------------------------------------- +export { createConsoleLogger, createLogger } from "./src/logger/factory.ts"; +export { LOG_LEVEL_ORDER } from "./src/logger/types.ts"; +export type { + LogFileOptions, + Logger, + LoggerOptions, + LogLevel, + LogRecord, +} from "./src/logger/types.ts"; + +// ----- IPC contract -------------------------------------------------- +export { + IPC_AUTH_HEADER, + IPC_REQUEST_ID_HEADER, + IpcRoutes, +} from "./src/ipc/routes.ts"; +export type { IpcRoute } from "./src/ipc/routes.ts"; +export { + IPC_ERROR_STATUS, + IpcError, + IpcErrorBodySchema, + IpcErrorCode, +} from "./src/ipc/errors.ts"; +export type { IpcErrorBody } from "./src/ipc/errors.ts"; +export { + AnyDomainEventSchema, + HeartbeatEventSchema, + ServerReadyEventSchema, +} from "./src/ipc/events.ts"; +export type { + AnyDomainEvent, + BaseDomainEvent, + DomainEventType, + HeartbeatEvent, + ServerReadyEvent, +} from "./src/ipc/events.ts"; + +// ----- Util ---------------------------------------------------------- +export { + err, + isErr, + isOk, + map, + mapErr, + ok, + tryCatch, + tryCatchAsync, + unwrap, +} from "./src/util/result.ts"; +export type { Err, Ok, Result } from "./src/util/result.ts"; diff --git a/crates/shared/src/config/env.ts b/crates/shared/src/config/env.ts new file mode 100644 index 0000000..4386310 --- /dev/null +++ b/crates/shared/src/config/env.ts @@ -0,0 +1,69 @@ +/** + * Environment variable schemas and loader. + * + * Each crate validates only the env it actually needs: + * - `@elly/core` → `CoreEnvSchema` + * - `@elly/bot` → `BotEnvSchema` + * + * Loading is fail-fast: a missing or malformed required variable aborts + * the process before any side-effectful work begins. + */ + +import { z } from "zod"; +import { EnvValidationError, type ConfigValidationIssue } from "./errors.ts"; + +export const SharedEnvSchema = z.object({ + NODE_ENV: z.enum(["development", "production", "test"]).default("development"), + LOG_LEVEL: z.enum(["debug", "info", "warn", "error", "fatal"]).optional(), +}); + +export const CoreEnvSchema = SharedEnvSchema.extend({ + IPC_TOKEN: z + .string() + .min(16, "IPC_TOKEN must be at least 16 characters (use a long random string)"), +}); + +export const BotEnvSchema = SharedEnvSchema.extend({ + DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"), + IPC_TOKEN: z + .string() + .min(16, "IPC_TOKEN must be at least 16 characters (must match the core crate)"), +}); + +export type SharedEnv = z.infer; +export type CoreEnv = z.infer; +export type BotEnv = z.infer; + +/** + * Load and validate environment variables against a Zod object schema. + * + * Only keys declared on the schema's `shape` are read; everything else in + * `Deno.env` is ignored to prevent accidental coupling. + * + * @throws {EnvValidationError} If any required variable is missing or invalid. + */ +export function loadEnv>(schema: S): z.output { + const raw: Record = {}; + for (const key of Object.keys(schema.shape)) { + const value = Deno.env.get(key); + if (value !== undefined) { + raw[key] = value; + } + } + + const result = schema.safeParse(raw); + if (result.success) { + return result.data; + } + + const issues: ConfigValidationIssue[] = result.error.issues.map((issue) => ({ + path: issue.path.length === 0 ? "" : String(issue.path[0]), + message: issue.message, + code: issue.code, + })); + + const summary = `Environment validation failed:\n${ + issues.map((i) => ` - ${i.path}: ${i.message}`).join("\n") + }`; + throw new EnvValidationError(summary, issues); +} diff --git a/crates/shared/src/config/errors.ts b/crates/shared/src/config/errors.ts new file mode 100644 index 0000000..62dacdb --- /dev/null +++ b/crates/shared/src/config/errors.ts @@ -0,0 +1,43 @@ +/** + * Error types raised by the config and env loaders. + * + * These are deliberately distinct from `Error` subclasses inside `@elly/core` + * or `@elly/bot` — they cross the workspace boundary and must be importable + * by both runtimes without dragging unrelated dependencies along. + */ + +export class ConfigError extends Error { + readonly path: string; + + constructor(message: string, path: string) { + super(message); + this.name = "ConfigError"; + this.path = path; + } +} + +export class ConfigValidationError extends ConfigError { + readonly issues: readonly ConfigValidationIssue[]; + + constructor(message: string, path: string, issues: readonly ConfigValidationIssue[]) { + super(message, path); + this.name = "ConfigValidationError"; + this.issues = issues; + } +} + +export interface ConfigValidationIssue { + readonly path: string; + readonly message: string; + readonly code: string; +} + +export class EnvValidationError extends Error { + readonly issues: readonly ConfigValidationIssue[]; + + constructor(message: string, issues: readonly ConfigValidationIssue[]) { + super(message); + this.name = "EnvValidationError"; + this.issues = issues; + } +} diff --git a/crates/shared/src/config/loader.ts b/crates/shared/src/config/loader.ts new file mode 100644 index 0000000..a38050f --- /dev/null +++ b/crates/shared/src/config/loader.ts @@ -0,0 +1,79 @@ +/** + * TOML config loader for the Elly Discord Bot. + * + * Loads the config file from disk, parses it as TOML, validates it against + * `ConfigSchema`, and returns a fully-typed `Config`. Validation failures + * throw `ConfigValidationError` with structured per-path issues so the + * boot scripts can render a helpful error and exit with a non-zero code. + */ + +import { parse as parseToml } from "@std/toml"; +import type { z } from "zod"; +import { ConfigSchema, type Config } from "./schema.ts"; +import { + ConfigError, + ConfigValidationError, + type ConfigValidationIssue, +} from "./errors.ts"; + +/** + * Read, parse, and validate the config file at `path`. + * + * @throws {ConfigError} If the file is missing or cannot be parsed as TOML. + * @throws {ConfigValidationError} If the parsed object doesn't satisfy the schema. + */ +export async function loadConfig(path: string): Promise { + const text = await readFile(path); + const raw = parseTomlText(text, path); + return validateConfig(raw, path); +} + +/** + * Validate an already-parsed object against `ConfigSchema`. Exposed so tests + * and tooling can validate config objects produced by other sources without + * going through the filesystem. + */ +export function validateConfig(input: unknown, path = ""): Config { + const result = ConfigSchema.safeParse(input); + if (result.success) return result.data; + + const issues = zodIssuesToConfigIssues(result.error); + const summary = `Config validation failed for ${path}:\n${ + issues.map((i) => ` - ${i.path}: ${i.message}`).join("\n") + }`; + throw new ConfigValidationError(summary, path, issues); +} + +async function readFile(path: string): Promise { + try { + return await Deno.readTextFile(path); + } catch (err) { + if (err instanceof Deno.errors.NotFound) { + throw new ConfigError(`Config file not found at ${path}`, path); + } + if (err instanceof Deno.errors.PermissionDenied) { + throw new ConfigError( + `Permission denied reading config at ${path}. Did you grant --allow-read for this path?`, + path, + ); + } + throw err; + } +} + +function parseTomlText(text: string, path: string): Record { + try { + return parseToml(text); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw new ConfigError(`Failed to parse TOML at ${path}: ${reason}`, path); + } +} + +function zodIssuesToConfigIssues(error: z.ZodError): ConfigValidationIssue[] { + return error.issues.map((issue) => ({ + path: issue.path.length === 0 ? "" : issue.path.join("."), + message: issue.message, + code: issue.code, + })); +} diff --git a/crates/shared/src/config/schema.ts b/crates/shared/src/config/schema.ts new file mode 100644 index 0000000..8d95d24 --- /dev/null +++ b/crates/shared/src/config/schema.ts @@ -0,0 +1,154 @@ +/** + * Zod schema for the Elly Discord Bot's TOML configuration. + * + * This is the canonical contract between the operator (`config.toml`) and + * both `@elly/core` and `@elly/bot`. Parsing happens once at boot — if it + * fails, the process exits with a structured error. + * + * Type inference (`Config = z.infer`) lives in + * `./types.ts` and is re-exported from the crate's barrel. + */ + +import { z } from "zod"; + +const DiscordId = z + .string() + .regex(/^\d{17,19}$/, "Must be a 17–19 digit Discord snowflake ID"); + +const NonEmpty = z.string().min(1, "Must not be empty"); + +const Color = z + .number() + .int() + .min(0, "Color must be >= 0") + .max(0xFFFFFF, "Color must be <= 0xFFFFFF"); + +export const BotSchema = z.object({ + name: NonEmpty, + prefix: NonEmpty, + status: z.string().default(""), + activity_type: z + .enum(["playing", "streaming", "listening", "watching", "competing"]) + .default("watching"), + owners: z + .object({ + ids: z.array(DiscordId).default([]), + }) + .default({ ids: [] }), +}); + +export const DatabaseSchema = z.object({ + path: NonEmpty, +}); + +export const KvSchema = z + .object({ + path: z.string().default("./data/kv"), + }) + .default({ path: "./data/kv" }); + +export const IpcSchema = z + .object({ + host: z.string().default("127.0.0.1"), + port: z.number().int().min(1).max(65535).default(8787), + request_timeout_ms: z.number().int().positive().default(10_000), + }) + .default({ host: "127.0.0.1", port: 8787, request_timeout_ms: 10_000 }); + +export const ApiSchema = z.object({ + pika_cache_ttl: z.number().int().positive().default(3_600_000), + pika_request_timeout: z.number().int().positive().default(10_000), +}); + +export const GuildSchema = z.object({ + id: DiscordId, + name: NonEmpty, +}); + +export const ChannelsSchema = z.object({ + applications: NonEmpty, + application_logs: NonEmpty, + suggestions: NonEmpty, + suggestion_logs: NonEmpty, + guild_updates: NonEmpty, + discord_changelog: NonEmpty, + inactivity: NonEmpty, + development_logs: NonEmpty, + donations: NonEmpty, + reminders: NonEmpty, +}); + +export const RolesSchema = z.object({ + admin: NonEmpty, + leader: NonEmpty, + officer: NonEmpty, + developer: NonEmpty, + guild_member: NonEmpty, + champion: NonEmpty, + away: NonEmpty, + applications_blacklisted: NonEmpty, + suggestions_blacklisted: NonEmpty, + manageable: z + .object({ + ids: z.array(DiscordId).default([]), + }) + .default({ ids: [] }), +}); + +export const FeaturesSchema = z.object({ + applications: z.boolean().default(true), + suggestions: z.boolean().default(true), + statistics: z.boolean().default(true), + family: z.boolean().default(true), + qotd: z.boolean().default(true), + reminders: z.boolean().default(true), + staff_simulator: z.boolean().default(true), + channel_filtering: z.boolean().default(true), + auto_moderation: z.boolean().default(false), + welcome_system: z.boolean().default(false), + level_system: z.boolean().default(false), +}); + +export const LimitsSchema = z.object({ + champion_max_days: z.number().int().positive().default(366), + away_max_days: z.number().int().positive().default(355), + purge_max_messages: z.number().int().min(1).max(100).default(100), + reminder_max_duration_days: z.number().int().positive().default(365), +}); + +export const ColorsSchema = z.object({ + primary: Color.default(0x5865F2), + success: Color.default(0x57F287), + warning: Color.default(0xFEE75C), + error: Color.default(0xED4245), + info: Color.default(0x5865F2), +}); + +export const LoggingSchema = z.object({ + level: z.enum(["debug", "info", "warn", "error", "fatal"]).default("info"), + format: z.enum(["console", "json"]).default("console"), + file: z.string().optional(), + file_max_bytes: z + .number() + .int() + .positive() + .default(10 * 1024 * 1024), + file_max_backups: z.number().int().min(0).default(5), +}); + +export const ConfigSchema = z.object({ + bot: BotSchema, + database: DatabaseSchema, + kv: KvSchema, + ipc: IpcSchema, + api: ApiSchema, + guild: GuildSchema, + channels: ChannelsSchema, + roles: RolesSchema, + features: FeaturesSchema, + limits: LimitsSchema, + colors: ColorsSchema, + logging: LoggingSchema, +}); + +export type Config = z.infer; diff --git a/crates/shared/src/config/types.ts b/crates/shared/src/config/types.ts new file mode 100644 index 0000000..4c30790 --- /dev/null +++ b/crates/shared/src/config/types.ts @@ -0,0 +1,38 @@ +/** + * Type re-exports for consumers that only need the inferred types + * (no runtime cost — schemas are tree-shaken out for type-only imports). + */ + +import type { + ApiSchema, + BotSchema, + ChannelsSchema, + ColorsSchema, + Config, + ConfigSchema, + DatabaseSchema, + FeaturesSchema, + GuildSchema, + IpcSchema, + KvSchema, + LimitsSchema, + LoggingSchema, + RolesSchema, +} from "./schema.ts"; +import type { z } from "zod"; + +export type { Config }; +export type ConfigInput = z.input; + +export type BotConfig = z.infer; +export type DatabaseConfig = z.infer; +export type KvConfig = z.infer; +export type IpcConfig = z.infer; +export type ApiConfig = z.infer; +export type GuildConfig = z.infer; +export type ChannelsConfig = z.infer; +export type RolesConfig = z.infer; +export type FeaturesConfig = z.infer; +export type LimitsConfig = z.infer; +export type ColorsConfig = z.infer; +export type LoggingConfig = z.infer; diff --git a/crates/shared/src/ipc/errors.ts b/crates/shared/src/ipc/errors.ts new file mode 100644 index 0000000..7bc0e1c --- /dev/null +++ b/crates/shared/src/ipc/errors.ts @@ -0,0 +1,81 @@ +/** + * IPC error envelope — the canonical JSON shape returned for any non-2xx + * response from `@elly/core`. The bot's `CoreClient` parses this and + * surfaces it as a typed error to callers. + */ + +import { z } from "zod"; + +/** + * Stable machine-readable error codes. Adding a code is a non-breaking + * change; renaming/removing one is a breaking change. + */ +export const IpcErrorCode = { + UNAUTHORIZED: "unauthorized", + FORBIDDEN: "forbidden", + NOT_FOUND: "not_found", + METHOD_NOT_ALLOWED: "method_not_allowed", + BAD_REQUEST: "bad_request", + VALIDATION_FAILED: "validation_failed", + CONFLICT: "conflict", + RATE_LIMITED: "rate_limited", + UPSTREAM_FAILURE: "upstream_failure", + INTERNAL: "internal", +} as const; + +export type IpcErrorCode = typeof IpcErrorCode[keyof typeof IpcErrorCode]; + +export const IpcErrorBodySchema = z.object({ + error: z.object({ + code: z.string(), + message: z.string(), + requestId: z.string().optional(), + details: z.unknown().optional(), + }), +}); + +export type IpcErrorBody = z.infer; + +/** + * Map of `IpcErrorCode` -> default HTTP status code. The server overrides + * the status per response when needed; this map exists so callers (and the + * default error middleware) have a sensible fallback. + */ +export const IPC_ERROR_STATUS: Record = { + unauthorized: 401, + forbidden: 403, + not_found: 404, + method_not_allowed: 405, + bad_request: 400, + validation_failed: 422, + conflict: 409, + rate_limited: 429, + upstream_failure: 502, + internal: 500, +}; + +/** + * Error thrown by `CoreClient` (Phase 3) when an IPC response carries an + * `IpcErrorBody`. Exported here so both crates can `instanceof`-check. + */ +export class IpcError extends Error { + readonly code: IpcErrorCode | string; + readonly status: number; + readonly requestId?: string; + readonly details?: unknown; + + constructor(args: { + code: IpcErrorCode | string; + message: string; + status: number; + requestId?: string; + details?: unknown; + }) { + super(args.message); + this.name = "IpcError"; + this.code = args.code; + this.status = args.status; + this.requestId = args.requestId; + this.details = args.details; + } +} diff --git a/crates/shared/src/ipc/events.ts b/crates/shared/src/ipc/events.ts new file mode 100644 index 0000000..e825c5a --- /dev/null +++ b/crates/shared/src/ipc/events.ts @@ -0,0 +1,71 @@ +/** + * Domain event contract. + * + * Core publishes typed events to an in-process bus and bridges them to the + * bot over the SSE endpoint (`IpcRoutes.EVENTS`). Each event has: + * - `type`: a string literal discriminator (`"applications.approved"`, …) + * - `id`: a ULID, unique per event, useful for idempotency on the consumer + * - `timestamp`: epoch milliseconds + * - `payload`: type-narrowed by `type` + * + * Phase 4 extends `AnyDomainEvent` with feature-specific events. Phase 2 + * ships only `heartbeat` and `server.ready` for SSE plumbing verification. + */ + +import { z } from "zod"; + +export interface BaseDomainEvent { + readonly type: TType; + readonly id: string; + readonly timestamp: number; + readonly payload: TPayload; +} + +// --------------------------------------------------------------------- +// Phase 2 events +// --------------------------------------------------------------------- + +export interface ServerReadyEvent + extends BaseDomainEvent<"server.ready", { version: string; pid: number }> {} + +export interface HeartbeatEvent + extends BaseDomainEvent<"heartbeat", { uptimeMs: number }> {} + +// --------------------------------------------------------------------- +// Discriminated union of every event the bot can receive +// --------------------------------------------------------------------- + +export type AnyDomainEvent = ServerReadyEvent | HeartbeatEvent; + +export type DomainEventType = AnyDomainEvent["type"]; + +// --------------------------------------------------------------------- +// Runtime validation (used by the bot when parsing SSE messages) +// --------------------------------------------------------------------- + +const BaseEventShape = { + id: z.string().min(1), + timestamp: z.number().int().nonnegative(), +}; + +export const ServerReadyEventSchema = z.object({ + type: z.literal("server.ready"), + ...BaseEventShape, + payload: z.object({ + version: z.string(), + pid: z.number().int(), + }), +}); + +export const HeartbeatEventSchema = z.object({ + type: z.literal("heartbeat"), + ...BaseEventShape, + payload: z.object({ + uptimeMs: z.number().int().nonnegative(), + }), +}); + +export const AnyDomainEventSchema = z.discriminatedUnion("type", [ + ServerReadyEventSchema, + HeartbeatEventSchema, +]); diff --git a/crates/shared/src/ipc/routes.ts b/crates/shared/src/ipc/routes.ts new file mode 100644 index 0000000..e1165ed --- /dev/null +++ b/crates/shared/src/ipc/routes.ts @@ -0,0 +1,30 @@ +/** + * IPC route constants. + * + * Single source of truth for all HTTP paths exposed by `@elly/core`. Both + * the server (route registration) and the bot's `CoreClient` (URL construction) + * import from here so the two cannot drift. + * + * Versioned under `/v1/...` — future incompatible changes ship as `/v2/...` + * while keeping `/v1/` alive during transitions. + */ + +export const IpcRoutes = { + /** Unauthenticated liveness probe. Returns 200 if the process is alive. */ + HEALTH: "/health", + + /** Authenticated version/diagnostic payload. */ + VERSION: "/v1/version", + + /** Server-sent events stream of domain events for the bot to react to. */ + EVENTS: "/v1/events", +} as const; + +export type IpcRoute = typeof IpcRoutes[keyof typeof IpcRoutes]; + +/** Standard request header carrying the IPC bearer token. */ +export const IPC_AUTH_HEADER = "authorization"; + +/** Header for correlating a request across crates and log lines. */ +export const IPC_REQUEST_ID_HEADER = "x-request-id"; + \ No newline at end of file diff --git a/crates/shared/src/logger/factory.ts b/crates/shared/src/logger/factory.ts new file mode 100644 index 0000000..6f10631 --- /dev/null +++ b/crates/shared/src/logger/factory.ts @@ -0,0 +1,141 @@ +/** + * Universal structured logger factory. + * + * Construct one logger per crate at boot and pass it through the dependency + * container. Child loggers share their parent's sinks — call `.child()` + * freely for per-request, per-command, or per-job context. + */ + +import { + LOG_LEVEL_ORDER, + type LogLevel, + type Logger, + type LoggerOptions, + type LogRecord, + type LogSink, +} from "./types.ts"; +import { ConsoleSink, RotatingFileSink } from "./sinks.ts"; + +/** + * Build a configured root logger. + * + * Sinks created: + * - one `ConsoleSink` (always) + * - one `RotatingFileSink` (only if `options.file` is provided) + */ +export function createLogger(options: LoggerOptions): Logger { + const sinks: LogSink[] = [new ConsoleSink(options.format)]; + + if (options.file) { + sinks.push( + new RotatingFileSink(options.file.path, { + maxBytes: options.file.maxBytes, + maxBackups: options.file.maxBackups, + }), + ); + } + + return new StructuredLogger(options.name, options.level, sinks, {}); +} + +/** + * Convenience helper for crates that want a logger without any persistent + * file sink (e.g. tests, ad-hoc scripts). Output is colorized console only. + */ +export function createConsoleLogger(name: string, level: LogLevel = "info"): Logger { + return createLogger({ name, level, format: "console" }); +} + +// ===================================================================== +// Implementation +// ===================================================================== + +class StructuredLogger implements Logger { + constructor( + private readonly name: string, + private readonly level: LogLevel, + private readonly sinks: ReadonlyArray, + private readonly context: Readonly>, + ) {} + + debug(msg: string, fields?: Record): void { + this.log("debug", msg, fields); + } + + info(msg: string, fields?: Record): void { + this.log("info", msg, fields); + } + + warn(msg: string, fields?: Record): void { + this.log("warn", msg, fields); + } + + error(msg: string, fields?: Record): void { + this.log("error", msg, fields); + } + + fatal(msg: string, fields?: Record): void { + this.log("fatal", msg, fields); + } + + child(context: Record): Logger { + return new StructuredLogger(this.name, this.level, this.sinks, { + ...this.context, + ...context, + }); + } + + async flush(): Promise { + await Promise.all(this.sinks.map((s) => s.flush())); + } + + private log(level: LogLevel, msg: string, fields?: Record): void { + if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[this.level]) return; + + const fieldsNormalized = normalizeFields(fields); + const record: LogRecord = { + time: new Date().toISOString(), + level, + logger: this.name, + msg, + ...this.context, + ...fieldsNormalized, + }; + + for (const sink of this.sinks) { + sink.write(record); + } + } +} + +/** + * Normalize special values in user-supplied fields: + * - Errors are expanded into `{ name, message, stack, cause? }` for JSON + * serialization. The console formatter receives the same expanded shape. + */ +function normalizeFields( + fields: Record | undefined, +): Record | undefined { + if (!fields) return undefined; + let mutated: Record | null = null; + for (const [k, v] of Object.entries(fields)) { + if (v instanceof Error) { + mutated ??= { ...fields }; + mutated[k] = serializeError(v); + } + } + return mutated ?? fields; +} + +function serializeError(err: Error): Record { + const out: Record = { + name: err.name, + message: err.message, + }; + if (err.stack) out.stack = err.stack; + const cause = (err as { cause?: unknown }).cause; + if (cause !== undefined) { + out.cause = cause instanceof Error ? serializeError(cause) : cause; + } + return out; +} diff --git a/crates/shared/src/logger/sinks.ts b/crates/shared/src/logger/sinks.ts new file mode 100644 index 0000000..b552b22 --- /dev/null +++ b/crates/shared/src/logger/sinks.ts @@ -0,0 +1,202 @@ +/** + * Logger sink implementations. + * + * - `ConsoleSink`: synchronous writes to stdout/stderr, optionally + * ANSI-colorized for `console` format or compact JSON for `json` format. + * - `RotatingFileSink`: asynchronous, append-only JSON lines with size-based + * rotation. Writes are serialized through a promise queue so concurrent + * `write()` calls cannot interleave. + * + * Both sinks survive transient errors (logged to stderr) — a failing log + * sink must never crash the host process. + */ + +import { dirname } from "@std/path"; +import { ensureDir } from "@std/fs"; +import { + bold, + cyan, + gray, + green, + magenta, + red, + yellow, +} from "@std/fmt/colors"; +import { + LOG_LEVEL_ORDER, + type LogLevel, + type LogRecord, + type LogSink, +} from "./types.ts"; + +const encoder = new TextEncoder(); + +const LEVEL_COLOR: Record string> = { + debug: cyan, + info: green, + warn: yellow, + error: red, + fatal: (s) => bold(magenta(s)), +}; + +// ===================================================================== +// ConsoleSink +// ===================================================================== + +export class ConsoleSink implements LogSink { + constructor(private readonly format: "console" | "json") {} + + write(record: LogRecord): void { + const line = this.format === "json" + ? JSON.stringify(record) + "\n" + : this.formatConsole(record) + "\n"; + + const useStderr = LOG_LEVEL_ORDER[record.level] >= LOG_LEVEL_ORDER.warn; + const writer = useStderr ? Deno.stderr : Deno.stdout; + try { + writer.writeSync(encoder.encode(line)); + } catch { + // Never propagate sink failures to user code. + } + } + + flush(): Promise { + return Promise.resolve(); + } + + private formatConsole(record: LogRecord): string { + const { time, level, logger, msg, ...rest } = record; + const ts = gray(time); + const lvl = LEVEL_COLOR[level](level.toUpperCase().padEnd(5)); + const name = cyan(`[${logger}]`); + + let fields = ""; + const keys = Object.keys(rest); + if (keys.length > 0) { + const parts: string[] = []; + for (const k of keys) { + parts.push(`${k}=${formatValue(rest[k])}`); + } + fields = " " + gray(parts.join(" ")); + } + + return `${ts} ${lvl} ${name} ${msg}${fields}`; + } +} + +function formatValue(v: unknown): string { + if (v === null) return "null"; + if (v === undefined) return "undefined"; + switch (typeof v) { + case "string": + return /\s/.test(v) ? JSON.stringify(v) : v; + case "number": + case "boolean": + case "bigint": + return String(v); + case "function": + return "[function]"; + } + if (v instanceof Error) { + return `${v.name}: ${v.message}`; + } + try { + return JSON.stringify(v); + } catch { + return String(v); + } +} + +// ===================================================================== +// RotatingFileSink +// ===================================================================== + +export class RotatingFileSink implements LogSink { + private queue: Promise = Promise.resolve(); + private readonly maxBytes: number; + private readonly maxBackups: number; + private dirEnsured = false; + + constructor( + private readonly path: string, + options: { maxBytes?: number; maxBackups?: number } = {}, + ) { + this.maxBytes = options.maxBytes ?? 10 * 1024 * 1024; + this.maxBackups = options.maxBackups ?? 5; + } + + write(record: LogRecord): void { + const line = JSON.stringify(record) + "\n"; + this.queue = this.queue.then(() => this.doWrite(line)).catch((err) => { + const msg = err instanceof Error ? err.message : String(err); + try { + Deno.stderr.writeSync( + encoder.encode(`[logger] RotatingFileSink write failed: ${msg}\n`), + ); + } catch { + // give up + } + }); + } + + async flush(): Promise { + await this.queue; + } + + private async doWrite(line: string): Promise { + if (!this.dirEnsured) { + await ensureDir(dirname(this.path)); + this.dirEnsured = true; + } + + try { + const stat = await Deno.stat(this.path); + if (stat.size + line.length > this.maxBytes) { + await this.rotate(); + } + } catch (err) { + if (!(err instanceof Deno.errors.NotFound)) throw err; + } + + await Deno.writeTextFile(this.path, line, { append: true }); + } + + private async rotate(): Promise { + // Remove the oldest backup if it would be pushed past the retention limit. + if (this.maxBackups > 0) { + const oldest = `${this.path}.${this.maxBackups}`; + try { + await Deno.remove(oldest); + } catch (err) { + if (!(err instanceof Deno.errors.NotFound)) throw err; + } + } + + // Shift .{i} -> .{i+1} for i in [maxBackups-1 .. 1]. + for (let i = this.maxBackups - 1; i >= 1; i--) { + const src = `${this.path}.${i}`; + const dst = `${this.path}.${i + 1}`; + try { + await Deno.rename(src, dst); + } catch (err) { + if (!(err instanceof Deno.errors.NotFound)) throw err; + } + } + + // Rotate the active log to .1 (only if there is at least one backup slot). + if (this.maxBackups > 0) { + try { + await Deno.rename(this.path, `${this.path}.1`); + } catch (err) { + if (!(err instanceof Deno.errors.NotFound)) throw err; + } + } else { + // No retention — just truncate. + try { + await Deno.remove(this.path); + } catch (err) { + if (!(err instanceof Deno.errors.NotFound)) throw err; + } + } + } +} diff --git a/crates/shared/src/logger/types.ts b/crates/shared/src/logger/types.ts new file mode 100644 index 0000000..5cc4ecb --- /dev/null +++ b/crates/shared/src/logger/types.ts @@ -0,0 +1,76 @@ +/** + * Public type contract for the universal structured logger. + * + * Both `@elly/core` and `@elly/bot` consume this. Sinks (console, file) + * are implementation details and never appear in user code. + */ + +export type LogLevel = "debug" | "info" | "warn" | "error" | "fatal"; + +export const LOG_LEVEL_ORDER: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40, + fatal: 50, +}; + +export interface LogRecord { + /** ISO 8601 timestamp (always UTC). */ + readonly time: string; + /** Severity level. */ + readonly level: LogLevel; + /** Logger name (typically a crate identifier, e.g. `@elly/core`). */ + readonly logger: string; + /** Human-readable message. */ + readonly msg: string; + /** Arbitrary structured context — flattened into the record. */ + readonly [key: string]: unknown; +} + +export interface LogFileOptions { + readonly path: string; + readonly maxBytes?: number; + readonly maxBackups?: number; +} + +export interface LoggerOptions { + /** Logger name attached to every record. */ + readonly name: string; + /** Minimum level to emit (records below this are silently dropped). */ + readonly level: LogLevel; + /** Console output format. File output is always JSON. */ + readonly format: "console" | "json"; + /** Optional rotating-JSON file sink configuration. */ + readonly file?: LogFileOptions; +} + +export interface Logger { + debug(msg: string, fields?: Record): void; + info(msg: string, fields?: Record): void; + warn(msg: string, fields?: Record): void; + error(msg: string, fields?: Record): void; + fatal(msg: string, fields?: Record): void; + + /** + * Derive a child logger with extra static context merged into every record. + * The parent's sinks are shared — child loggers do not allocate new file + * handles or buffers. + */ + child(context: Record): Logger; + + /** + * Wait for any buffered/asynchronous sink writes (e.g. the rotating file + * sink) to flush. Call before process exit to avoid losing log lines. + */ + flush(): Promise; +} + +/** + * Sinks are the pluggable output backends used by the logger. Internal — + * not exported from the crate barrel. + */ +export interface LogSink { + write(record: LogRecord): void; + flush(): Promise; +} diff --git a/crates/shared/src/util/result.ts b/crates/shared/src/util/result.ts new file mode 100644 index 0000000..0e01904 --- /dev/null +++ b/crates/shared/src/util/result.ts @@ -0,0 +1,75 @@ +/** + * Result — a lightweight, framework-agnostic result type used across + * `@elly/shared`, `@elly/core`, and `@elly/bot`. + * + * Prefer this over throwing for expected/recoverable failures crossing + * module or IPC boundaries. Reserve exceptions for genuinely exceptional + * conditions (programmer error, invariant violation, infrastructure failure). + */ + +export type Ok = { readonly ok: true; readonly value: T }; +export type Err = { readonly ok: false; readonly error: E }; +export type Result = Ok | Err; + +export function ok(value: T): Ok { + return { ok: true, value }; +} + +export function err(error: E): Err { + return { ok: false, error }; +} + +export function isOk(result: Result): result is Ok { + return result.ok; +} + +export function isErr(result: Result): result is Err { + return !result.ok; +} + +/** + * Unwrap a Result or throw the error. Use sparingly — defeats the purpose of + * Result in most call sites. + */ +export function unwrap(result: Result): T { + if (result.ok) return result.value; + throw result.error instanceof Error + ? result.error + : new Error(`Result.unwrap on Err: ${String(result.error)}`); +} + +/** + * Map the success value of a Result without affecting the error branch. + */ +export function map(result: Result, fn: (value: T) => U): Result { + return result.ok ? ok(fn(result.value)) : result; +} + +/** + * Map the error value of a Result without affecting the success branch. + */ +export function mapErr(result: Result, fn: (error: E) => F): Result { + return result.ok ? result : err(fn(result.error)); +} + +/** + * Wrap a throwing synchronous function in a Result. + */ +export function tryCatch(fn: () => T): Result { + try { + return ok(fn()); + } catch (e) { + return err(e instanceof Error ? e : new Error(String(e))); + } +} + +/** + * Wrap a throwing async function in a Result. + */ +export async function tryCatchAsync(fn: () => Promise): Promise> { + try { + return ok(await fn()); + } catch (e) { + return err(e instanceof Error ? e : new Error(String(e))); + } +} diff --git a/deno.json b/deno.json index fd5f6ef..4c45900 100644 --- a/deno.json +++ b/deno.json @@ -1,34 +1,57 @@ { + "workspace": [ + "./crates/shared", + "./crates/core", + "./crates/bot" + ], "compilerOptions": { "strict": true, - "lib": ["deno.window", "esnext"], + "lib": ["deno.window", "deno.unstable", "esnext"], "noImplicitAny": true, "strictNullChecks": true, "strictFunctionTypes": true, - "noImplicitReturns": true + "noImplicitReturns": true, + "noUncheckedIndexedAccess": false }, "imports": { - "discord.js": "npm:discord.js@^14.14.1", - "@discordjs/rest": "npm:@discordjs/rest@^2.2.0", - "@toml-tools/parser": "npm:@toml-tools/parser@^1.0.0" + "zod": "npm:zod@^3.23.8", + "kysely": "npm:kysely@^0.27.4", + "@db/sqlite": "jsr:@db/sqlite@^0.12.0", + "@std/toml": "jsr:@std/toml@^1.0.2", + "@std/path": "jsr:@std/path@^1.0.8", + "@std/fs": "jsr:@std/fs@^1.0.6", + "@std/ulid": "jsr:@std/ulid@^1.0.0", + "@std/fmt": "jsr:@std/fmt@^1.0.3" }, "tasks": { - "start": "deno run --allow-net --allow-read --allow-write --allow-env --allow-ffi --unstable-ffi --env src/index.ts", - "dev": "deno run --watch --allow-net --allow-read --allow-write --allow-env --allow-ffi --unstable-ffi --env src/index.ts", - "check": "deno check src/index.ts", - "lint": "deno lint", - "fmt": "deno fmt" + "check": "deno check crates/shared/mod.ts crates/core/src/main.ts crates/bot/src/main.ts", + "lint": "deno lint crates/", + "fmt": "deno fmt crates/", + "fmt:check": "deno fmt --check crates/", + + "core:start": "deno run --allow-read=./config.toml,./data,./logs --allow-write=./data,./logs --allow-net=127.0.0.1:8787,0.0.0.0:8787 --allow-env --allow-ffi --unstable-kv --unstable-cron --env-file=.env crates/core/src/main.ts", + "core:dev": "deno run --watch --allow-read=./config.toml,./data,./logs --allow-write=./data,./logs --allow-net=127.0.0.1:8787,0.0.0.0:8787 --allow-env --allow-ffi --unstable-kv --unstable-cron --env-file=.env crates/core/src/main.ts", + + "bot:start": "deno run --allow-read=./config.toml,./logs --allow-write=./logs --allow-env --env-file=.env crates/bot/src/main.ts", + "bot:dev": "deno run --watch --allow-read=./config.toml,./logs --allow-write=./logs --allow-env --env-file=.env crates/bot/src/main.ts" }, "fmt": { "useTabs": false, "lineWidth": 100, "indentWidth": 2, - "singleQuote": true, - "proseWrap": "preserve" + "singleQuote": false, + "proseWrap": "preserve", + "exclude": ["src/", "data/", "logs/"] }, "lint": { "rules": { "tags": ["recommended"] - } - } + }, + "exclude": ["src/", "data/", "logs/"] + }, + "exclude": [ + "src/", + "data/", + "logs/" + ] } diff --git a/deno.lock b/deno.lock index 785f78f..2fa32f0 100644 --- a/deno.lock +++ b/deno.lock @@ -4,16 +4,21 @@ "jsr:@db/sqlite@0.12": "0.12.0", "jsr:@denosaurs/plug@1": "1.1.0", "jsr:@std/assert@0.217": "0.217.0", + "jsr:@std/collections@^1.1.3": "1.2.0", "jsr:@std/encoding@1": "1.0.10", "jsr:@std/fmt@1": "1.0.8", + "jsr:@std/fmt@^1.0.3": "1.0.8", "jsr:@std/fs@1": "1.0.20", + "jsr:@std/fs@^1.0.6": "1.0.20", "jsr:@std/internal@^1.0.12": "1.0.12", "jsr:@std/path@0.217": "0.217.0", "jsr:@std/path@1": "1.1.3", + "jsr:@std/path@^1.0.8": "1.1.3", "jsr:@std/path@^1.1.3": "1.1.3", - "npm:@discordjs/rest@^2.2.0": "2.6.0", - "npm:@toml-tools/parser@1": "1.0.0", - "npm:discord.js@^14.14.1": "14.25.1" + "jsr:@std/toml@^1.0.2": "1.0.11", + "jsr:@std/ulid@1": "1.0.0", + "npm:kysely@~0.27.4": "0.27.6", + "npm:zod@^3.23.8": "3.25.76" }, "jsr": { "@db/sqlite@0.12.0": { @@ -27,14 +32,17 @@ "integrity": "eb2f0b7546c7bca2000d8b0282c54d50d91cf6d75cb26a80df25a6de8c4bc044", "dependencies": [ "jsr:@std/encoding", - "jsr:@std/fmt", - "jsr:@std/fs", + "jsr:@std/fmt@1", + "jsr:@std/fs@1", "jsr:@std/path@1" ] }, "@std/assert@0.217.0": { "integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642" }, + "@std/collections@1.2.0": { + "integrity": "47627a21d3a13138b77fd0e4d790ba9d2e603c3510b686cde6b132fe9aa98a88" + }, "@std/encoding@1.0.10": { "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" }, @@ -62,200 +70,35 @@ "dependencies": [ "jsr:@std/internal" ] + }, + "@std/toml@1.0.11": { + "integrity": "e084988b872ca4bad6aedfb7350f6eeed0e8ba88e9ee5e1590621c5b5bb8f715", + "dependencies": [ + "jsr:@std/collections" + ] + }, + "@std/ulid@1.0.0": { + "integrity": "d41c3d27a907714413649fee864b7cde8d42ee68437d22b79d5de4f81d808780" } }, "npm": { - "@chevrotain/cst-dts-gen@11.0.3": { - "integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==", - "dependencies": [ - "@chevrotain/gast", - "@chevrotain/types", - "lodash-es" - ] + "kysely@0.27.6": { + "integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==" }, - "@chevrotain/gast@11.0.3": { - "integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==", - "dependencies": [ - "@chevrotain/types", - "lodash-es" - ] - }, - "@chevrotain/regexp-to-ast@11.0.3": { - "integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==" - }, - "@chevrotain/types@11.0.3": { - "integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==" - }, - "@chevrotain/utils@11.0.3": { - "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==" - }, - "@discordjs/builders@1.13.0": { - "integrity": "sha512-COK0uU6ZaJI+LA67H/rp8IbEkYwlZf3mAoBI5wtPh5G5cbEQGNhVpzINg2f/6+q/YipnNIKy6fJDg6kMUKUw4Q==", - "dependencies": [ - "@discordjs/formatters", - "@discordjs/util", - "@sapphire/shapeshift", - "discord-api-types", - "fast-deep-equal", - "ts-mixer", - "tslib" - ] - }, - "@discordjs/collection@1.5.3": { - "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==" - }, - "@discordjs/collection@2.1.1": { - "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==" - }, - "@discordjs/formatters@0.6.2": { - "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", - "dependencies": [ - "discord-api-types" - ] - }, - "@discordjs/rest@2.6.0": { - "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", - "dependencies": [ - "@discordjs/collection@2.1.1", - "@discordjs/util", - "@sapphire/async-queue", - "@sapphire/snowflake", - "@vladfrangu/async_event_emitter", - "discord-api-types", - "magic-bytes.js", - "tslib", - "undici" - ] - }, - "@discordjs/util@1.2.0": { - "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", - "dependencies": [ - "discord-api-types" - ] - }, - "@discordjs/ws@1.2.3": { - "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", - "dependencies": [ - "@discordjs/collection@2.1.1", - "@discordjs/rest", - "@discordjs/util", - "@sapphire/async-queue", - "@types/ws", - "@vladfrangu/async_event_emitter", - "discord-api-types", - "tslib", - "ws" - ] - }, - "@sapphire/async-queue@1.5.5": { - "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==" - }, - "@sapphire/shapeshift@4.0.0": { - "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", - "dependencies": [ - "fast-deep-equal", - "lodash" - ] - }, - "@sapphire/snowflake@3.5.3": { - "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==" - }, - "@toml-tools/lexer@1.0.0": { - "integrity": "sha512-rVoOC9FibF2CICwCBWQnYcjAEOmLCJExer178K2AsY0Nk9FjJNVoVJuR5UAtuq42BZOajvH+ainf6Gj2GpCnXQ==", - "dependencies": [ - "chevrotain" - ] - }, - "@toml-tools/parser@1.0.0": { - "integrity": "sha512-j8cd3A3ccLHppGoWI69urbiVJslrpwI6sZ61ySDUPxM/FTkQWRx/JkkF8aipnl0Ds0feWXyjyvmWzn70mIohYg==", - "dependencies": [ - "@toml-tools/lexer", - "chevrotain" - ] - }, - "@types/node@24.2.0": { - "integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==", - "dependencies": [ - "undici-types" - ] - }, - "@types/ws@8.18.1": { - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dependencies": [ - "@types/node" - ] - }, - "@vladfrangu/async_event_emitter@2.4.7": { - "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==" - }, - "chevrotain@11.0.3": { - "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", - "dependencies": [ - "@chevrotain/cst-dts-gen", - "@chevrotain/gast", - "@chevrotain/regexp-to-ast", - "@chevrotain/types", - "@chevrotain/utils", - "lodash-es" - ] - }, - "discord-api-types@0.38.34": { - "integrity": "sha512-muq7xKGznj5MSFCzuIm/2TO7DpttuomUTemVM82fRqgnMl70YRzEyY24jlbiV6R9tzOTq6A6UnZ0bsfZeKD38Q==" - }, - "discord.js@14.25.1": { - "integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==", - "dependencies": [ - "@discordjs/builders", - "@discordjs/collection@1.5.3", - "@discordjs/formatters", - "@discordjs/rest", - "@discordjs/util", - "@discordjs/ws", - "@sapphire/snowflake", - "discord-api-types", - "fast-deep-equal", - "lodash.snakecase", - "magic-bytes.js", - "tslib", - "undici" - ] - }, - "fast-deep-equal@3.1.3": { - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" - }, - "lodash-es@4.17.21": { - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" - }, - "lodash.snakecase@4.1.1": { - "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==" - }, - "lodash@4.17.21": { - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" - }, - "magic-bytes.js@1.12.1": { - "integrity": "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==" - }, - "ts-mixer@6.0.4": { - "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==" - }, - "tslib@2.8.1": { - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" - }, - "undici-types@7.10.0": { - "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==" - }, - "undici@6.21.3": { - "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==" - }, - "ws@8.18.3": { - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==" + "zod@3.25.76": { + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" } }, "workspace": { "dependencies": [ - "npm:@discordjs/rest@^2.2.0", - "npm:@toml-tools/parser@1", - "npm:discord.js@^14.14.1" + "jsr:@db/sqlite@0.12", + "jsr:@std/fmt@^1.0.3", + "jsr:@std/fs@^1.0.6", + "jsr:@std/path@^1.0.8", + "jsr:@std/toml@^1.0.2", + "jsr:@std/ulid@1", + "npm:kysely@~0.27.4", + "npm:zod@^3.23.8" ] } } diff --git a/master_doc_follow_1.md b/master_doc_follow_1.md new file mode 100644 index 0000000..5374755 --- /dev/null +++ b/master_doc_follow_1.md @@ -0,0 +1,94 @@ +# Role & Objective + +You are an elite Software Architect and Expert Deno/TypeScript Developer specializing in highly scalable Discord bots using `discord.js`. Your task is to architect and rewrite the "Elly Discord Bot" from the ground up. + +The previous architecture was tightly coupled, suffered from technical debt, used unscalable inline message collectors, and maintained two conflicting database implementations. You will design a modern, decoupled, scalable application using a "Crates" (Deno Workspace/Microservices) architecture, explicit design patterns, and modern Discord UI components. + +**Constraints:** + +- Do NOT write tests. Focus entirely on robust, production-ready application code. +- NO Redis or external dependencies for caching/pubsub. Utilize Deno's native capabilities (`Deno.Kv`, memory-based PubSub) instead. +- Implement the architecture in the strict phases outlined below. Provide exact file structures and code for each phase only when requested. + +--- + +## 1. Architectural Mandates + +### 1.1 "Crates" Structure & Deno Workspaces (Separation of Concerns) + +The application must be split into decoupled, standalone modules using **Deno Workspaces** (a single root `deno.json` defining workspace members). The Discord bot is strictly a "frontend" that communicates with the Core engine. + +- **`@elly/core` (The Engine):** A standalone backend service handling all business logic, database operations (SQLite), scheduled jobs, external API polling (PikaNetwork), and domain entities. +- **`@elly/bot` (The Discord Frontend):** A pure presentation layer handling Discord.js connections, command routing, and UI rendering. It contains NO business logic, NO direct DB connections, and NO state manipulation. +- **`@elly/shared` (The Contract):** Shared types, interfaces, constants, and IPC payload schemas used by both crates. +- **Communication (IPC):** Use lightweight HTTP REST via `Deno.serve()` for synchronous requests from the Bot to the Core, and WebSockets or a strict Memory-Based PubSub for asynchronous events. + +### 1.2 Native Deno Features to Utilize + +- **`Deno.Kv`:** Use Deno's native Key-Value store for handling command cooldowns, temporary interaction states, and the PikaNetwork API cache. This replaces Redis entirely. +- **`Deno.cron()`:** Replace legacy `setInterval` garbage with native Deno cron jobs for scheduled tasks (e.g., Reminders, QOTD queues, cache clearing). +- **`Deno.serve()`:** Use Deno's native, high-performance HTTP server for the Core crate's IPC API. +- **Standard Library / Node Compat:** Use `npm:discord.js` securely. Utilize modern standard library features (`@std/fs`, `@std/path`, `@std/log`). + +### 1.3 Design Patterns to Implement Explicitly + +- **Dependency Injection (DI):** Services, API clients, and Repositories must be injected. No hardcoded singletons scattered across files. +- **Repository Pattern:** A single, unified SQLite database layer for persistence (e.g., using Kysely or Drizzle ORM). The legacy JSON DB is strictly forbidden. +- **Strategy Pattern:** For routing slash commands and interactions efficiently. +- **Event-Driven Architecture:** When Core processes a domain event (e.g., "Application Approved"), it emits an IPC event to the Bot crate to handle the Discord UI updates/logs. + +### 1.4 Central Interaction & UI Router + +- **No Message Collectors:** Inline `awaitMessageComponent` or attached collectors are FORBIDDEN. They cause memory leaks and break upon bot restarts. +- **Central Interaction Router:** All Buttons, Select Menus, and Modals must route through a central `interactionCreate` handler using structured `customId`s (format: `action:entity:id` — e.g., `applications:approve:12345`). +- **Stateless Interactions:** If an interaction requires prior context, store that context temporarily in `Deno.Kv` keyed by the interaction ID, not in application memory. + +### 1.5 Comprehensive Logging + +- Implement structured, level-based logging (e.g., `@std/log` or Deno-compatible Pino). +- **Log Everything:** IPC HTTP request latencies, SQLite query execution times, Pika API rate-limiting hits, command invocations, and full error stack traces. +- Logs must output colored ANSI text to the console in development, and structured JSON to rotating files in production. + +--- + +## 2. Common Pitfalls to Strictly Avoid + +1. **Bleeding Domain Logic into Discord Handlers:** The `execute(interaction)` function should parse the Discord input, call a Core service via IPC/DI, and format the output. It should never compute stats or write to a database directly. +2. **Config Key Mismatches & Silent Fails:** The old bot had bugs where `features.channelFiltering` didn't match the TOML `channel_filtering`. Implement strict config validation (e.g., using Zod) at boot. Fail fast and hard on invalid configs. +3. **PikaNetwork Rate Limits:** Over-fetching or parallel unbounded fetching of the Pika API. Implement a robust Queue/Batching system with exponential backoff in the Core crate. +4. **Ghost/Zombie Intervals:** Do not use `setInterval` for database background jobs. Use `Deno.cron` to ensure clear lifecycle management and avoid overlap. +5. **Deno Permission Laziness:** Do not use `-A` (allow all). Design the startup scripts to explicitly request only necessary permissions (`--allow-net`, `--allow-read`, `--allow-env`, `--allow-ffi` for SQLite). + +--- + +## 3. Execution Plan (Phased Implementation) + +We will build this iteratively. I will prompt you for each phase. **Do not generate the entire bot at once.** Wait for my cue to proceed to the next phase. + +### Phase 1: Workspace Foundation, Config, & Logging + +- Define the Deno workspace `deno.json`, folder structure, and module layout. +- Implement strict Zod-based TOML config loading and validation. +- Setup the universal structured logger. +- Provide the boot scripts. + +### Phase 2: Core Crate - DB, Deno.Kv, & IPC Server + +- Initialize the SQLite DB layer (Repositories) and `Deno.Kv` (Cache/Cooldowns). +- Set up the `Deno.serve()` REST API for the Core crate to expose domain actions. +- Implement the Memory-based Event Emitter for bridging domain events to the Discord Bot. + +### Phase 3: Bot Crate - Framework & Interaction Router + +- Create the Discord.js client wrapper. +- Implement the Central Interaction Router parsing `customId`s. +- Implement permission middleware and command loading fetching data strictly from the Core IPC. + +### Phase 4: Feature Migration (Domain by Domain) + +- *Phase 4a:* PikaNetwork API Wrapper (with Deno.Kv cache) & Statistics commands. +- *Phase 4b:* Applications & Suggestions (Using stateless components and Deno.Kv state). +- *Phase 4c:* Deno.cron Jobs (Reminders & QOTD). + +--- +**Reply with "ACKNOWLEDGED" if you understand these instructions. Provide a high-level folder tree of the proposed Deno Workspace architecture. Wait for my command to begin Phase 1.** \ No newline at end of file diff --git a/src/commands/utility/away.ts b/src/commands/utility/away.ts index b7f597f..f281f6f 100644 --- a/src/commands/utility/away.ts +++ b/src/commands/utility/away.ts @@ -4,16 +4,16 @@ */ import { - SlashCommandBuilder, - EmbedBuilder, type ChatInputCommandInteraction, + EmbedBuilder, type GuildMember, + SlashCommandBuilder, } from 'discord.js'; import type { Command } from '../../types/index.ts'; import { PermissionLevel } from '../../types/index.ts'; import type { EllyClient } from '../../client/EllyClient.ts'; import { AwayRepository } from '../../database/repositories/AwayRepository.ts'; -import { parseTime, formatDuration, discordTimestamp } from '../../utils/time.ts'; +import { discordTimestamp, formatDuration, parseTime } from '../../utils/time.ts'; export const awayCommand: Command = { data: new SlashCommandBuilder() @@ -109,7 +109,7 @@ export const awayCommand: Command = { async function handleAdd( interaction: ChatInputCommandInteraction, client: EllyClient, - awayRepo: AwayRepository + awayRepo: AwayRepository, ): Promise { const targetUser = interaction.options.getUser('user', true); const durationStr = interaction.options.getString('duration', true); @@ -140,7 +140,7 @@ async function handleAdd( .setColor(client.config.colors.error) .setTitle('❌ Duration Too Long') .setDescription( - `Away status cannot exceed ${client.config.limits.away_max_days} days.` + `Away status cannot exceed ${client.config.limits.away_max_days} days.`, ), ], ephemeral: true, @@ -191,7 +191,7 @@ async function handleAdd( { name: 'Member', value: `${targetUser}`, inline: true }, { name: 'Duration', value: formatDuration(durationMs), inline: true }, { name: 'Returns', value: discordTimestamp(expiresAt, 'R'), inline: true }, - { name: 'Reason', value: reason, inline: false } + { name: 'Reason', value: reason, inline: false }, ) .setFooter({ text: `Set by ${interaction.user.tag}`, @@ -215,7 +215,7 @@ async function handleAdd( .addFields( { name: 'Duration', value: formatDuration(durationMs), inline: true }, { name: 'Returns', value: discordTimestamp(expiresAt, 'R'), inline: true }, - { name: 'Reason', value: reason, inline: false } + { name: 'Reason', value: reason, inline: false }, ), ], }); @@ -227,7 +227,7 @@ async function handleAdd( async function handleRemove( interaction: ChatInputCommandInteraction, client: EllyClient, - awayRepo: AwayRepository + awayRepo: AwayRepository, ): Promise { const targetUser = interaction.options.getUser('user', true); @@ -272,7 +272,7 @@ async function handleRemove( async function handleList( interaction: ChatInputCommandInteraction, client: EllyClient, - awayRepo: AwayRepository + awayRepo: AwayRepository, ): Promise { const allAway = awayRepo.getAll().filter((s) => new Date(s.expiresAt) > new Date()); @@ -295,7 +295,9 @@ async function handleList( .slice(0, 15) .map((s) => { const reason = s.reason.length > 30 ? s.reason.slice(0, 27) + '...' : s.reason; - return `<@${s.userId}> - Returns ${discordTimestamp(new Date(s.expiresAt), 'R')}\n└ ${reason}`; + return `<@${s.userId}> - Returns ${ + discordTimestamp(new Date(s.expiresAt), 'R') + }\n└ ${reason}`; }) .join('\n\n'); @@ -318,7 +320,7 @@ async function handleList( async function handleCheck( interaction: ChatInputCommandInteraction, client: EllyClient, - awayRepo: AwayRepository + awayRepo: AwayRepository, ): Promise { const targetUser = interaction.options.getUser('user', true); const status = awayRepo.getByUserId(targetUser.id); @@ -342,7 +344,7 @@ async function handleCheck( .addFields( { name: 'Member', value: `${targetUser}`, inline: true }, { name: 'Returns', value: discordTimestamp(new Date(status.expiresAt), 'R'), inline: true }, - { name: 'Reason', value: status.reason, inline: false } + { name: 'Reason', value: status.reason, inline: false }, ) .setTimestamp(new Date(status.createdAt));