(Init): Added shit
This commit is contained in:
@@ -17,6 +17,10 @@ logs/
|
||||
|
||||
# Environment files
|
||||
.env.example
|
||||
config.example.toml
|
||||
master_doc_follow_1.md
|
||||
README.md
|
||||
ARCHITECTURE.md
|
||||
|
||||
# Misc
|
||||
GEM/
|
||||
|
||||
26
.env.example
26
.env.example
@@ -1,6 +1,24 @@
|
||||
# Elly Discord Bot Environment Variables
|
||||
# ========================================
|
||||
# Elly Discord Bot — Environment Variables
|
||||
# =========================================
|
||||
#
|
||||
# Copy this file to `.env` and fill in real values. Both `@elly/core` and
|
||||
# `@elly/bot` validate their environment with Zod at boot — missing or
|
||||
# malformed values will abort the process before any side effects occur.
|
||||
|
||||
# Discord Bot Token (required)
|
||||
# Get this from https://discord.com/developers/applications
|
||||
# ---- Shared (both crates) ----
|
||||
# Controls log formatting and other dev-vs-prod behaviour.
|
||||
NODE_ENV=development
|
||||
|
||||
# Optional: override the level from [logging] in config.toml.
|
||||
# One of: debug | info | warn | error | fatal
|
||||
# LOG_LEVEL=debug
|
||||
|
||||
# ---- @elly/bot ----
|
||||
# Discord bot token. Get it from https://discord.com/developers/applications.
|
||||
DISCORD_TOKEN=your_discord_bot_token_here
|
||||
|
||||
# ---- IPC (shared between @elly/core and @elly/bot) ----
|
||||
# Shared secret used to authenticate Bot -> Core HTTP requests.
|
||||
# Must be the same value in both processes. Use a long random string.
|
||||
# Generate one with: `openssl rand -hex 32`
|
||||
IPC_TOKEN=change-me-to-a-long-random-secret-of-at-least-16-chars
|
||||
|
||||
363
ARCHITECTURE.md
Normal file
363
ARCHITECTURE.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Elly Discord Bot Configuration
|
||||
# Elly Discord Bot — Configuration
|
||||
# ================================
|
||||
#
|
||||
# Copy this file to `config.toml` and fill in real values. The file is
|
||||
# validated against the shared Zod schema (`@elly/shared/src/config/schema.ts`)
|
||||
# at boot — missing or malformed values will abort the process before any
|
||||
# side effects occur.
|
||||
#
|
||||
# Discord IDs must be 17–19 digit snowflakes. Channel and role names match
|
||||
# guild entities by display name (case-insensitive).
|
||||
|
||||
[bot]
|
||||
name = "<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
5
crates/bot/deno.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "@elly/bot",
|
||||
"version": "0.1.0",
|
||||
"exports": "./src/main.ts"
|
||||
}
|
||||
144
crates/bot/src/main.ts
Normal file
144
crates/bot/src/main.ts
Normal 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
5
crates/core/deno.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "@elly/core",
|
||||
"version": "0.1.0",
|
||||
"exports": "./src/main.ts"
|
||||
}
|
||||
148
crates/core/src/container.ts
Normal file
148
crates/core/src/container.ts
Normal 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");
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
128
crates/core/src/infrastructure/db/connection.ts
Normal file
128
crates/core/src/infrastructure/db/connection.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
144
crates/core/src/infrastructure/db/kysely-dialect.ts
Normal file
144
crates/core/src/infrastructure/db/kysely-dialect.ts
Normal 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);
|
||||
}
|
||||
26
crates/core/src/infrastructure/db/migrations/0001_initial.ts
Normal file
26
crates/core/src/infrastructure/db/migrations/0001_initial.ts
Normal 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();
|
||||
}
|
||||
27
crates/core/src/infrastructure/db/migrations/index.ts
Normal file
27
crates/core/src/infrastructure/db/migrations/index.ts
Normal 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;
|
||||
141
crates/core/src/infrastructure/db/migrator.ts
Normal file
141
crates/core/src/infrastructure/db/migrator.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
45
crates/core/src/infrastructure/db/schema.ts
Normal file
45
crates/core/src/infrastructure/db/schema.ts
Normal 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;
|
||||
76
crates/core/src/infrastructure/http/middleware/auth.ts
Normal file
76
crates/core/src/infrastructure/http/middleware/auth.ts
Normal 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;
|
||||
}
|
||||
97
crates/core/src/infrastructure/http/middleware/error.ts
Normal file
97
crates/core/src/infrastructure/http/middleware/error.ts
Normal 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 } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
45
crates/core/src/infrastructure/http/middleware/logging.ts
Normal file
45
crates/core/src/infrastructure/http/middleware/logging.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
147
crates/core/src/infrastructure/http/router.ts
Normal file
147
crates/core/src/infrastructure/http/router.ts
Normal 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" } },
|
||||
);
|
||||
}
|
||||
148
crates/core/src/infrastructure/http/routes/system.ts
Normal file
148
crates/core/src/infrastructure/http/routes/system.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
167
crates/core/src/infrastructure/http/server.ts
Normal file
167
crates/core/src/infrastructure/http/server.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
53
crates/core/src/infrastructure/http/types.ts
Normal file
53
crates/core/src/infrastructure/http/types.ts
Normal 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;
|
||||
}
|
||||
85
crates/core/src/infrastructure/kv/cache.ts
Normal file
85
crates/core/src/infrastructure/kv/cache.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
93
crates/core/src/infrastructure/kv/cooldown.ts
Normal file
93
crates/core/src/infrastructure/kv/cooldown.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
76
crates/core/src/infrastructure/kv/interactionState.ts
Normal file
76
crates/core/src/infrastructure/kv/interactionState.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
73
crates/core/src/infrastructure/kv/store.ts
Normal file
73
crates/core/src/infrastructure/kv/store.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
86
crates/core/src/infrastructure/pubsub/bus.ts
Normal file
86
crates/core/src/infrastructure/pubsub/bus.ts
Normal 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
165
crates/core/src/main.ts
Normal 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
5
crates/shared/deno.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "@elly/shared",
|
||||
"version": "0.1.0",
|
||||
"exports": "./mod.ts"
|
||||
}
|
||||
106
crates/shared/mod.ts
Normal file
106
crates/shared/mod.ts
Normal 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";
|
||||
69
crates/shared/src/config/env.ts
Normal file
69
crates/shared/src/config/env.ts
Normal 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);
|
||||
}
|
||||
43
crates/shared/src/config/errors.ts
Normal file
43
crates/shared/src/config/errors.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
79
crates/shared/src/config/loader.ts
Normal file
79
crates/shared/src/config/loader.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
154
crates/shared/src/config/schema.ts
Normal file
154
crates/shared/src/config/schema.ts
Normal 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 17–19 digit Discord snowflake ID");
|
||||
|
||||
const NonEmpty = z.string().min(1, "Must not be empty");
|
||||
|
||||
const Color = z
|
||||
.number()
|
||||
.int()
|
||||
.min(0, "Color must be >= 0")
|
||||
.max(0xFFFFFF, "Color must be <= 0xFFFFFF");
|
||||
|
||||
export const BotSchema = z.object({
|
||||
name: NonEmpty,
|
||||
prefix: NonEmpty,
|
||||
status: z.string().default(""),
|
||||
activity_type: z
|
||||
.enum(["playing", "streaming", "listening", "watching", "competing"])
|
||||
.default("watching"),
|
||||
owners: z
|
||||
.object({
|
||||
ids: z.array(DiscordId).default([]),
|
||||
})
|
||||
.default({ ids: [] }),
|
||||
});
|
||||
|
||||
export const DatabaseSchema = z.object({
|
||||
path: NonEmpty,
|
||||
});
|
||||
|
||||
export const KvSchema = z
|
||||
.object({
|
||||
path: z.string().default("./data/kv"),
|
||||
})
|
||||
.default({ path: "./data/kv" });
|
||||
|
||||
export const IpcSchema = z
|
||||
.object({
|
||||
host: z.string().default("127.0.0.1"),
|
||||
port: z.number().int().min(1).max(65535).default(8787),
|
||||
request_timeout_ms: z.number().int().positive().default(10_000),
|
||||
})
|
||||
.default({ host: "127.0.0.1", port: 8787, request_timeout_ms: 10_000 });
|
||||
|
||||
export const ApiSchema = z.object({
|
||||
pika_cache_ttl: z.number().int().positive().default(3_600_000),
|
||||
pika_request_timeout: z.number().int().positive().default(10_000),
|
||||
});
|
||||
|
||||
export const GuildSchema = z.object({
|
||||
id: DiscordId,
|
||||
name: NonEmpty,
|
||||
});
|
||||
|
||||
export const ChannelsSchema = z.object({
|
||||
applications: NonEmpty,
|
||||
application_logs: NonEmpty,
|
||||
suggestions: NonEmpty,
|
||||
suggestion_logs: NonEmpty,
|
||||
guild_updates: NonEmpty,
|
||||
discord_changelog: NonEmpty,
|
||||
inactivity: NonEmpty,
|
||||
development_logs: NonEmpty,
|
||||
donations: NonEmpty,
|
||||
reminders: NonEmpty,
|
||||
});
|
||||
|
||||
export const RolesSchema = z.object({
|
||||
admin: NonEmpty,
|
||||
leader: NonEmpty,
|
||||
officer: NonEmpty,
|
||||
developer: NonEmpty,
|
||||
guild_member: NonEmpty,
|
||||
champion: NonEmpty,
|
||||
away: NonEmpty,
|
||||
applications_blacklisted: NonEmpty,
|
||||
suggestions_blacklisted: NonEmpty,
|
||||
manageable: z
|
||||
.object({
|
||||
ids: z.array(DiscordId).default([]),
|
||||
})
|
||||
.default({ ids: [] }),
|
||||
});
|
||||
|
||||
export const FeaturesSchema = z.object({
|
||||
applications: z.boolean().default(true),
|
||||
suggestions: z.boolean().default(true),
|
||||
statistics: z.boolean().default(true),
|
||||
family: z.boolean().default(true),
|
||||
qotd: z.boolean().default(true),
|
||||
reminders: z.boolean().default(true),
|
||||
staff_simulator: z.boolean().default(true),
|
||||
channel_filtering: z.boolean().default(true),
|
||||
auto_moderation: z.boolean().default(false),
|
||||
welcome_system: z.boolean().default(false),
|
||||
level_system: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const LimitsSchema = z.object({
|
||||
champion_max_days: z.number().int().positive().default(366),
|
||||
away_max_days: z.number().int().positive().default(355),
|
||||
purge_max_messages: z.number().int().min(1).max(100).default(100),
|
||||
reminder_max_duration_days: z.number().int().positive().default(365),
|
||||
});
|
||||
|
||||
export const ColorsSchema = z.object({
|
||||
primary: Color.default(0x5865F2),
|
||||
success: Color.default(0x57F287),
|
||||
warning: Color.default(0xFEE75C),
|
||||
error: Color.default(0xED4245),
|
||||
info: Color.default(0x5865F2),
|
||||
});
|
||||
|
||||
export const LoggingSchema = z.object({
|
||||
level: z.enum(["debug", "info", "warn", "error", "fatal"]).default("info"),
|
||||
format: z.enum(["console", "json"]).default("console"),
|
||||
file: z.string().optional(),
|
||||
file_max_bytes: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(10 * 1024 * 1024),
|
||||
file_max_backups: z.number().int().min(0).default(5),
|
||||
});
|
||||
|
||||
export const ConfigSchema = z.object({
|
||||
bot: BotSchema,
|
||||
database: DatabaseSchema,
|
||||
kv: KvSchema,
|
||||
ipc: IpcSchema,
|
||||
api: ApiSchema,
|
||||
guild: GuildSchema,
|
||||
channels: ChannelsSchema,
|
||||
roles: RolesSchema,
|
||||
features: FeaturesSchema,
|
||||
limits: LimitsSchema,
|
||||
colors: ColorsSchema,
|
||||
logging: LoggingSchema,
|
||||
});
|
||||
|
||||
export type Config = z.infer<typeof ConfigSchema>;
|
||||
38
crates/shared/src/config/types.ts
Normal file
38
crates/shared/src/config/types.ts
Normal 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>;
|
||||
81
crates/shared/src/ipc/errors.ts
Normal file
81
crates/shared/src/ipc/errors.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
71
crates/shared/src/ipc/events.ts
Normal file
71
crates/shared/src/ipc/events.ts
Normal 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,
|
||||
]);
|
||||
30
crates/shared/src/ipc/routes.ts
Normal file
30
crates/shared/src/ipc/routes.ts
Normal 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";
|
||||
|
||||
141
crates/shared/src/logger/factory.ts
Normal file
141
crates/shared/src/logger/factory.ts
Normal 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;
|
||||
}
|
||||
202
crates/shared/src/logger/sinks.ts
Normal file
202
crates/shared/src/logger/sinks.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
76
crates/shared/src/logger/types.ts
Normal file
76
crates/shared/src/logger/types.ts
Normal 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>;
|
||||
}
|
||||
75
crates/shared/src/util/result.ts
Normal file
75
crates/shared/src/util/result.ts
Normal 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)));
|
||||
}
|
||||
}
|
||||
51
deno.json
51
deno.json
@@ -1,34 +1,57 @@
|
||||
{
|
||||
"workspace": [
|
||||
"./crates/shared",
|
||||
"./crates/core",
|
||||
"./crates/bot"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"lib": ["deno.window", "esnext"],
|
||||
"lib": ["deno.window", "deno.unstable", "esnext"],
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"noImplicitReturns": true
|
||||
"noImplicitReturns": true,
|
||||
"noUncheckedIndexedAccess": false
|
||||
},
|
||||
"imports": {
|
||||
"discord.js": "npm:discord.js@^14.14.1",
|
||||
"@discordjs/rest": "npm:@discordjs/rest@^2.2.0",
|
||||
"@toml-tools/parser": "npm:@toml-tools/parser@^1.0.0"
|
||||
"zod": "npm:zod@^3.23.8",
|
||||
"kysely": "npm:kysely@^0.27.4",
|
||||
"@db/sqlite": "jsr:@db/sqlite@^0.12.0",
|
||||
"@std/toml": "jsr:@std/toml@^1.0.2",
|
||||
"@std/path": "jsr:@std/path@^1.0.8",
|
||||
"@std/fs": "jsr:@std/fs@^1.0.6",
|
||||
"@std/ulid": "jsr:@std/ulid@^1.0.0",
|
||||
"@std/fmt": "jsr:@std/fmt@^1.0.3"
|
||||
},
|
||||
"tasks": {
|
||||
"start": "deno run --allow-net --allow-read --allow-write --allow-env --allow-ffi --unstable-ffi --env src/index.ts",
|
||||
"dev": "deno run --watch --allow-net --allow-read --allow-write --allow-env --allow-ffi --unstable-ffi --env src/index.ts",
|
||||
"check": "deno check src/index.ts",
|
||||
"lint": "deno lint",
|
||||
"fmt": "deno fmt"
|
||||
"check": "deno check crates/shared/mod.ts crates/core/src/main.ts crates/bot/src/main.ts",
|
||||
"lint": "deno lint crates/",
|
||||
"fmt": "deno fmt crates/",
|
||||
"fmt:check": "deno fmt --check crates/",
|
||||
|
||||
"core:start": "deno run --allow-read=./config.toml,./data,./logs --allow-write=./data,./logs --allow-net=127.0.0.1:8787,0.0.0.0:8787 --allow-env --allow-ffi --unstable-kv --unstable-cron --env-file=.env crates/core/src/main.ts",
|
||||
"core:dev": "deno run --watch --allow-read=./config.toml,./data,./logs --allow-write=./data,./logs --allow-net=127.0.0.1:8787,0.0.0.0:8787 --allow-env --allow-ffi --unstable-kv --unstable-cron --env-file=.env crates/core/src/main.ts",
|
||||
|
||||
"bot:start": "deno run --allow-read=./config.toml,./logs --allow-write=./logs --allow-env --env-file=.env crates/bot/src/main.ts",
|
||||
"bot:dev": "deno run --watch --allow-read=./config.toml,./logs --allow-write=./logs --allow-env --env-file=.env crates/bot/src/main.ts"
|
||||
},
|
||||
"fmt": {
|
||||
"useTabs": false,
|
||||
"lineWidth": 100,
|
||||
"indentWidth": 2,
|
||||
"singleQuote": true,
|
||||
"proseWrap": "preserve"
|
||||
"singleQuote": false,
|
||||
"proseWrap": "preserve",
|
||||
"exclude": ["src/", "data/", "logs/"]
|
||||
},
|
||||
"lint": {
|
||||
"rules": {
|
||||
"tags": ["recommended"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"exclude": ["src/", "data/", "logs/"]
|
||||
},
|
||||
"exclude": [
|
||||
"src/",
|
||||
"data/",
|
||||
"logs/"
|
||||
]
|
||||
}
|
||||
|
||||
225
deno.lock
generated
225
deno.lock
generated
@@ -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
94
master_doc_follow_1.md
Normal 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.**
|
||||
@@ -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));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user