(Init): Added shit

This commit is contained in:
2026-05-28 23:46:40 +00:00
parent a5250706cb
commit 8410600c63
46 changed files with 3898 additions and 228 deletions

View File

@@ -17,6 +17,10 @@ logs/
# Environment files
.env.example
config.example.toml
master_doc_follow_1.md
README.md
ARCHITECTURE.md
# Misc
GEM/

View File

@@ -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

363
ARCHITECTURE.md Normal file
View File

@@ -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<string, unknown>` 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<T>(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<string, Command>` — registry populated by `registerCommand`.
- `cooldowns: Collection<string, Collection<string, number>>``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<T>`, `queryOne<T>`, `execute`, `exec` returning `QueryResult<T>` (`{ 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<T,E> = {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<Reminder,'id'|'createdAt'>)` 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<table, Map<key, value>>` 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<T[]>(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/<ip>`.
### `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<T>(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 `<span>Username</span><span>Role</span>` 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<T>`:
- `AdvancedCache<T>`: TTL+LRU `Map<string, CacheEntry<T>>` 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<void>;
autocomplete?(interaction): Promise<void>;
}
```
Categories and notable behaviours:
### Statistics (`PermissionLevel.User`, cooldown 5s typical)
- **`/bedwars <username> [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/<user>/right` for thumbnail.
- **`/skywars`** — analogous to bedwars.
- **`/guild <name>`** — 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 <subcmd>` (`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 <subcmd>` (`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 <subcmd>` (`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 <amount> [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 <code> [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 <command> [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 <cmd>` via `Deno.Command` with a default 10s `AbortController` timeout (max 30s).
- **`/database` (Developer)** — `stats`, `backup` (writes to `./data/backups/elly_<ts>.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')` (`<t:unix:style>`).
- `isPast`, `isFuture`, `addTime(date, ms)`, `startOfDay`, `endOfDay`. `parseDuration` re-exported as alias of `parseTime`.
### `pagination.ts`
`ButtonPaginator<T = EmbedBuilder>` 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<T>`.
- `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<T>`, `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.

View File

@@ -30,20 +30,20 @@ git clone <repository-url>
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

View File

@@ -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 1719 digit snowflakes. Channel and role names match
# guild entities by display name (case-insensitive).
[bot]
name = "<your 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.

5
crates/bot/deno.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "@elly/bot",
"version": "0.1.0",
"exports": "./src/main.ts"
}

144
crates/bot/src/main.ts Normal file
View File

@@ -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<void> {
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<Config> {
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 ?? "<from config>",
discordTokenPresent: Boolean(env.DISCORD_TOKEN),
ipcTokenPresent: Boolean(env.IPC_TOKEN),
});
}
function countEnabled(features: Record<string, boolean>): 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);
});
}

5
crates/core/deno.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "@elly/core",
"version": "0.1.0",
"exports": "./src/main.ts"
}

View File

@@ -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<void>;
}
export interface BuildContainerOptions {
readonly config: Config;
readonly env: CoreEnv;
readonly logger: Logger;
readonly version: string;
}
export async function buildContainer(options: BuildContainerOptions): Promise<CoreContainer> {
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<void> {
const errors: unknown[] = [];
const tryClose = async (label: string, fn: () => Promise<void>) => {
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");
}
},
};
}

View File

@@ -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<Database>`.
*
* 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<Database>;
/** 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<void>;
/** Run `VACUUM` to compact the on-disk file. */
vacuum(): Promise<void>;
/** Copy the live database file to `targetPath` (safe under WAL). */
backup(targetPath: string): Promise<void>;
/** Return useful runtime stats (path, byte size, table count). */
getStats(): Promise<DbStats>;
}
export interface DbStats {
readonly path: string;
readonly sizeBytes: number;
readonly tables: ReadonlyArray<string>;
}
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<DbConnection> {
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<Database>({ dialect: new DenoSqliteDialect(raw) });
log.info("sqlite database opened", { path: options.path });
return {
db,
raw,
path: options.path,
async close(): Promise<void> {
log.debug("closing sqlite database");
await db.destroy();
// Kysely's destroy() calls driver.destroy(), which closes `raw`.
},
async vacuum(): Promise<void> {
log.debug("vacuuming sqlite database");
await sql`VACUUM`.execute(db);
},
async backup(targetPath: string): Promise<void> {
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<DbStats> {
return collectStats(db, options.path);
},
};
}
async function collectStats(
db: Kysely<Database>,
path: string,
): Promise<DbStats> {
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),
};
}

View File

@@ -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<unknown>): DatabaseIntrospector {
return new SqliteIntrospector(db);
}
createQueryCompiler(): QueryCompiler {
return new SqliteQueryCompiler();
}
}
class DenoSqliteDriver implements Driver {
constructor(private readonly db: SqliteDatabase) {}
init(): Promise<void> {
return Promise.resolve();
}
acquireConnection(): Promise<DatabaseConnection> {
return Promise.resolve(new DenoSqliteConnection(this.db));
}
async beginTransaction(conn: DatabaseConnection): Promise<void> {
await conn.executeQuery(CompiledQuery.raw("BEGIN"));
}
async commitTransaction(conn: DatabaseConnection): Promise<void> {
await conn.executeQuery(CompiledQuery.raw("COMMIT"));
}
async rollbackTransaction(conn: DatabaseConnection): Promise<void> {
await conn.executeQuery(CompiledQuery.raw("ROLLBACK"));
}
releaseConnection(): Promise<void> {
// FFI driver shares one connection — nothing to release per-statement.
return Promise.resolve();
}
destroy(): Promise<void> {
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<R>(query: CompiledQuery): Promise<QueryResult<R>> {
// 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<R>(_query: CompiledQuery): AsyncIterableIterator<QueryResult<R>> {
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<string, unknown>;
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);
}

View File

@@ -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<unknown>): Promise<void> {
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<unknown>): Promise<void> {
await db.schema.dropTable("schema_migrations").ifExists().execute();
}

View File

@@ -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<unknown>): Promise<void>;
down(db: Kysely<unknown>): Promise<void>;
}
import * as m0001 from "./0001_initial.ts";
export const MIGRATIONS: ReadonlyArray<Migration> = [
m0001,
] as const;

View File

@@ -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<Database>;
readonly logger: Logger;
/** Override the migration list — useful for ad-hoc tooling. */
readonly migrations?: ReadonlyArray<Migration>;
}
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<Database>;
private readonly log: Logger;
private readonly migrations: ReadonlyArray<Migration>;
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<MigratorRunSummary> {
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<number> {
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<void> {
// 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<void> {
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<unknown>` 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<unknown>);
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<number>();
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);
}
}
}

View File

@@ -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<Database>`
// =====================================================================
export interface Database {
schema_migrations: SchemaMigrationsTable;
}
// =====================================================================
// Helper aliases for migrations
// =====================================================================
export type GeneratedId = Generated<string>;
export type Timestamp = string;

View File

@@ -0,0 +1,76 @@
/**
* Bearer-token authentication middleware.
*
* Compares the `Authorization: Bearer <token>` 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<Response> => {
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;
}

View File

@@ -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<string, number>)[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 } : {}),
},
});
}

View File

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

View File

@@ -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<string>;
readonly anonymous: boolean;
readonly handler: Handler;
readonly rawPath: string;
}
export interface RouteMatch {
readonly handler: HttpHandler;
readonly params: Readonly<Record<string, string>>;
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<RouteDefinition>): 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<string, string> = {};
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<Response> {
let index = -1;
const chain = this.middleware;
const next = (): Promise<Response> => {
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" } },
);
}

View File

@@ -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<RouteDefinition> {
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<Uint8Array>({
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 },
});
}

View File

@@ -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<RouteDefinition>;
/** 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<void>;
shutdown(): Promise<void>;
}
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<void> | null = null;
return {
host: options.host,
port: options.port,
start(): Promise<void> {
if (listening) return listening;
listening = new Promise<void>((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<void> {
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<Response> {
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<string, unknown> = {
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<void>,
timeoutMs: number,
onTimeout: () => void,
): Promise<void> {
let timer: number | undefined;
const timeout = new Promise<void>((resolve) => {
timer = setTimeout(() => {
onTimeout();
resolve();
}, timeoutMs);
});
try {
await Promise.race([promise, timeout]);
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}

View File

@@ -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<Record<string, string>>;
/** 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<string, unknown>;
/** Convenience accessor for the connection abort signal. */
readonly signal: AbortSignal;
}
export type HttpHandler = (ctx: HttpContext) => Promise<Response> | Response;
export type HttpMiddleware = (
ctx: HttpContext,
next: () => Promise<Response>,
) => Promise<Response>;
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;
}

View File

@@ -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<T> {
readonly value: T;
readonly cachedAt: number;
readonly expiresAt: number;
}
export interface CacheGetResult<T> {
readonly hit: boolean;
readonly entry: CacheEntry<T> | null;
}
export class CacheStore {
constructor(private readonly handle: KvHandle) {}
async get<T>(cacheKind: string, identifier: Deno.KvKey): Promise<CacheGetResult<T>> {
const key = this.buildKey(cacheKind, identifier);
const entry = await this.handle.kv.get<CacheEntry<T>>(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<T>(args: {
cacheKind: string;
identifier: Deno.KvKey;
value: T;
ttlMs: number;
}): Promise<CacheEntry<T>> {
if (args.ttlMs <= 0) {
throw new Error("CacheStore.set requires ttlMs > 0");
}
const now = Date.now();
const entry: CacheEntry<T> = {
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<void> {
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<number> {
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);
}
}

View File

@@ -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<CooldownCheckResult> {
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<number>(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<CooldownCheckResult> {
const key = this.handle.key("cooldown", command, userId);
const entry = await this.handle.kv.get<number>(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<void> {
const key = this.handle.key("cooldown", command, userId);
await this.handle.kv.delete(key);
}
}

View File

@@ -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<T> {
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<T>(value: T, ttlMs: number = DEFAULT_TTL_MS): Promise<string> {
const token = ulid();
const key = this.handle.key("interaction", token);
const record: InteractionStateRecord<T> = {
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<T>(token: string): Promise<InteractionStateRecord<T> | null> {
const key = this.handle.key("interaction", token);
const entry = await this.handle.kv.get<InteractionStateRecord<T>>(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<T>(token: string): Promise<InteractionStateRecord<T> | null> {
const key = this.handle.key("interaction", token);
const entry = await this.handle.kv.get<InteractionStateRecord<T>>(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<void> {
const key = this.handle.key("interaction", token);
await this.handle.kv.delete(key);
}
}

View File

@@ -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<KvNamespace> = [
"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<void>;
}
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<KvHandle> {
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<void> {
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();
},
};
}

View File

@@ -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<AnyDomainEvent, "id" | "timestamp">
& Partial<Pick<AnyDomainEvent, "id" | "timestamp">>;
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<AnyDomainEvent>(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<AnyDomainEvent>).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;
}
}

165
crates/core/src/main.ts Normal file
View File

@@ -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<void> {
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<void>(() => {});
}
// =====================================================================
// Boot helpers
// =====================================================================
async function loadConfigOrExit(): Promise<Config> {
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);
});
}

5
crates/shared/deno.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "@elly/shared",
"version": "0.1.0",
"exports": "./mod.ts"
}

106
crates/shared/mod.ts Normal file
View File

@@ -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";

View File

@@ -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<typeof SharedEnvSchema>;
export type CoreEnv = z.infer<typeof CoreEnvSchema>;
export type BotEnv = z.infer<typeof BotEnvSchema>;
/**
* 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<S extends z.ZodObject<z.ZodRawShape>>(schema: S): z.output<S> {
const raw: Record<string, string> = {};
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 ? "<env>" : 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);
}

View File

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

View File

@@ -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<Config> {
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 = "<in-memory>"): 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<string> {
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<string, unknown> {
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 ? "<root>" : issue.path.join("."),
message: issue.message,
code: issue.code,
}));
}

View File

@@ -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<typeof ConfigSchema>`) 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 1719 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<typeof ConfigSchema>;

View File

@@ -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<typeof ConfigSchema>;
export type BotConfig = z.infer<typeof BotSchema>;
export type DatabaseConfig = z.infer<typeof DatabaseSchema>;
export type KvConfig = z.infer<typeof KvSchema>;
export type IpcConfig = z.infer<typeof IpcSchema>;
export type ApiConfig = z.infer<typeof ApiSchema>;
export type GuildConfig = z.infer<typeof GuildSchema>;
export type ChannelsConfig = z.infer<typeof ChannelsSchema>;
export type RolesConfig = z.infer<typeof RolesSchema>;
export type FeaturesConfig = z.infer<typeof FeaturesSchema>;
export type LimitsConfig = z.infer<typeof LimitsSchema>;
export type ColorsConfig = z.infer<typeof ColorsSchema>;
export type LoggingConfig = z.infer<typeof LoggingSchema>;

View File

@@ -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<typeof IpcErrorBodySchema>;
/**
* 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<IpcErrorCode, number> = {
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;
}
}

View File

@@ -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<TType extends string, TPayload> {
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,
]);

View File

@@ -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";

View File

@@ -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<LogSink>,
private readonly context: Readonly<Record<string, unknown>>,
) {}
debug(msg: string, fields?: Record<string, unknown>): void {
this.log("debug", msg, fields);
}
info(msg: string, fields?: Record<string, unknown>): void {
this.log("info", msg, fields);
}
warn(msg: string, fields?: Record<string, unknown>): void {
this.log("warn", msg, fields);
}
error(msg: string, fields?: Record<string, unknown>): void {
this.log("error", msg, fields);
}
fatal(msg: string, fields?: Record<string, unknown>): void {
this.log("fatal", msg, fields);
}
child(context: Record<string, unknown>): Logger {
return new StructuredLogger(this.name, this.level, this.sinks, {
...this.context,
...context,
});
}
async flush(): Promise<void> {
await Promise.all(this.sinks.map((s) => s.flush()));
}
private log(level: LogLevel, msg: string, fields?: Record<string, unknown>): 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<string, unknown> | undefined,
): Record<string, unknown> | undefined {
if (!fields) return undefined;
let mutated: Record<string, unknown> | 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<string, unknown> {
const out: Record<string, unknown> = {
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;
}

View File

@@ -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<LogLevel, (s: string) => 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<void> {
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<void> = 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<void> {
await this.queue;
}
private async doWrite(line: string): Promise<void> {
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<void> {
// 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;
}
}
}
}

View File

@@ -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<LogLevel, number> = {
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<string, unknown>): void;
info(msg: string, fields?: Record<string, unknown>): void;
warn(msg: string, fields?: Record<string, unknown>): void;
error(msg: string, fields?: Record<string, unknown>): void;
fatal(msg: string, fields?: Record<string, unknown>): 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<string, unknown>): 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<void>;
}
/**
* 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<void>;
}

View File

@@ -0,0 +1,75 @@
/**
* Result<T, E> — 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<T> = { readonly ok: true; readonly value: T };
export type Err<E> = { readonly ok: false; readonly error: E };
export type Result<T, E = Error> = Ok<T> | Err<E>;
export function ok<T>(value: T): Ok<T> {
return { ok: true, value };
}
export function err<E>(error: E): Err<E> {
return { ok: false, error };
}
export function isOk<T, E>(result: Result<T, E>): result is Ok<T> {
return result.ok;
}
export function isErr<T, E>(result: Result<T, E>): result is Err<E> {
return !result.ok;
}
/**
* Unwrap a Result or throw the error. Use sparingly — defeats the purpose of
* Result in most call sites.
*/
export function unwrap<T, E>(result: Result<T, E>): 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<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
return result.ok ? ok(fn(result.value)) : result;
}
/**
* Map the error value of a Result without affecting the success branch.
*/
export function mapErr<T, E, F>(result: Result<T, E>, fn: (error: E) => F): Result<T, F> {
return result.ok ? result : err(fn(result.error));
}
/**
* Wrap a throwing synchronous function in a Result.
*/
export function tryCatch<T>(fn: () => T): Result<T, Error> {
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<T>(fn: () => Promise<T>): Promise<Result<T, Error>> {
try {
return ok(await fn());
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)));
}
}

View File

@@ -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/"
]
}

225
deno.lock generated
View File

@@ -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"
]
}
}

94
master_doc_follow_1.md Normal file
View File

@@ -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.**

View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
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));