# Elly Discord Bot — Architecture ## Runtime & Tooling - **Runtime:** Deno (compilerOptions in `deno.json`: strict, esnext, deno.window). - **Dependencies via Deno imports map (`deno.json`):** - `discord.js` → `npm:discord.js@^14.14.1` - `@discordjs/rest` → `npm:@discordjs/rest@^2.2.0` - `@toml-tools/parser` → `npm:@toml-tools/parser@^1.0.0` (declared but unused; project ships its own TOML parser) - SQLite via `jsr:@db/sqlite@0.12` (imported directly in `src/database/sqlite.ts`). - **Tasks:** `start`, `dev` (with `--watch`), `check`, `lint`, `fmt`. Required permissions: `--allow-net`, `--allow-read`, `--allow-write`, `--allow-env`, `--allow-ffi --unstable-ffi` (FFI is needed for `@db/sqlite`). - **Container:** `Dockerfile` builds on `denoland/deno:latest`, runs `deno install --frozen`, copies `src/`, `config.toml`, `.env`, creates `/app/data` and `/app/logs`, then runs `src/index.ts`. `docker-compose.yml` mounts `./data`, `./logs`, `./config.toml` (read-only) into the container. - **Secrets:** `DISCORD_TOKEN` is read from env (loaded by Deno's `--env` flag from `.env`). No other secrets. --- ## Top-level Layout (`src/`) ```bash src/ ├── index.ts # entry point, command registry, interactionCreate handler ├── client/EllyClient.ts # extended discord.js Client ├── config/ # config types + custom TOML loader/validator ├── api/pika/ # PikaNetwork API client + cache + types ├── database/ # SQLite layer + legacy JSON layer + repositories ├── events/ # ready, interactionCreate, messageCreate ├── services/PermissionService.ts ├── commands/ # slash commands grouped by category ├── utils/ # logger, errors, embeds, time, pagination └── types/index.ts # core type definitions (Command, PermissionLevel, etc.) ``` --- ## Entry Point — `src/index.ts` `main()`: 1. Calls `loadConfig('./config.toml')` then `validateConfig(config)`. Errors abort (`Deno.exit(1)`); warnings only logged. 2. Constructs `new EllyClient(config)`. 3. Builds an in-source array of `{ cmd, category }` pairs (28 commands across Statistics, Utility, Suggestions, QOTD, Applications, Family, Moderation, Developer) and calls `client.registerCommand(cmd)` for each. 4. Registers `messageCreateEvent` (filter / auto-mod) on the client. 5. Calls `client.initialize()` (loads DBs, sets event handlers, starts refresh intervals). 6. Registers a single `interactionCreate` listener inline in `index.ts` that: - Returns early unless `interaction.isChatInputCommand()`. - Looks up `client.commands.get(name)`. - Cooldown check via `client.isOnCooldown(userId, name)`. - Permission check via `client.permissions.hasPermission(member, command.permission)`. - Calls `command.execute(interaction)` and sets cooldown on success. - Routes errors to `client.errorHandler.handleCommandError(error, interaction)`. 7. Adds `SIGINT`/`SIGTERM` handlers calling `client.shutdown()`. 8. Calls `client.login(token)` (token from `Deno.env.get('DISCORD_TOKEN')`). 9. After login, builds a `REST` client and PUTs `Routes.applicationGuildCommands(clientId, guildId)` with `cmd.data.toJSON()` for every registered command (guild-scoped sync for instant updates). Note: `src/events/interactionCreate.ts` and `src/events/ready.ts` exist with richer logic (autocomplete, button/modal/select-menu stubs, ready-event command sync) but are **not wired up** — the actual handlers in use are the inline listener in `index.ts` plus the `ready` listener inside `EllyClient.setupEventHandlers()`. --- ## Configuration — `src/config/` ### `types.ts` Defines `Config` as a composition of: `BotConfig`, `DatabaseConfig`, `APIConfig`, `GuildConfig`, `ChannelsConfig`, `RolesConfig`, `FeaturesConfig`, `LimitsConfig`, `ColorsConfig`, `LoggingConfig`. All channels/roles are referenced **by name** (string), not ID, except `bot.owners.ids`, `guild.id`, and `roles.manageable.ids`. ### `config.ts` - **Custom TOML parser** (`parseTOML`): line-based, supports `[section]`, `[section.subsection]`, double/single quoted strings, hex (`0x…`) and decimal numbers, booleans, single-line and multi-line arrays, inline `#` comments (string-aware). No table arrays, no datetime types. Returns `Record` and is type-cast to `Config`. - **`loadConfig(path)`:** reads file via `Deno.readTextFile`, parses, returns `Config`. Throws on `Deno.errors.NotFound`. - **`validateConfig(config)` → `ConfigValidationResult`:** returns `{ valid, errors[], warnings[] }`. Required fields: `bot.name`, `bot.prefix`, `database.path`, `guild.id`. Validates Discord ID regex `^\d{17,19}$` for owners and guild ID, activity type enum, color range `0..0xFFFFFF`, log level enum, `purge_max_messages ∈ [1,100]`, etc. Missing channels/roles/features only emit warnings. - **`validateConfigOrThrow`** and **`getConfigValue(config, 'a.b.c')`** are also exported. `config.example.toml` shows defaults: bot/owners, database path `./data/elly.db`, channel/role names, feature toggles, `limits` (champion 366d, away 355d, purge 100, reminder 365d), colors, logging. --- ## Client — `src/client/EllyClient.ts` `EllyClient extends discord.js Client`. Constructed with a `Config`. Hard-coded gateway intents: `Guilds`, `GuildMembers`, `GuildMessages`, `GuildMessageReactions`, `MessageContent`, `DirectMessages`. Partials: `Message`, `Channel`, `Reaction`, `User`, `GuildMember`. Public fields: - `config: Config` - `pikaAPI: PikaNetworkAPI` — constructed with `timeout = config.api.pika_request_timeout` and `cache.profileTTL = config.api.pika_cache_ttl`, `leaderboardTTL = pika_cache_ttl/2`. - `database: JsonDatabase` — legacy JSON DB at `config.database.path.replace('.db', '.json')`. - `dbManager: DatabaseManager | null` — SQLite manager at `config.database.path.replace('.json', '.sqlite')`. Created in `initialize()`; falls back to JSON if SQLite init throws. - `permissions: PermissionService` - `logger: Logger` (level + file from `config.logging`) - `errorHandler: ErrorHandler` (singleton from `getErrorHandler()`). - `commands: Collection` — registry populated by `registerCommand`. - `cooldowns: Collection>` — `commandName → userId → expiresAtMs`. - `mainGuild: Guild | null` - `roles` cache: `admin, leader, officer, developer, guildMember, champion, away, applicationsBlacklisted, suggestionsBlacklisted` (all `Role | null`, resolved by name). - `channels_cache`: `applications, applicationLogs, suggestions, suggestionLogs, guildUpdates, discordChangelog, inactivity, developmentLogs, donations, reminders` (all `TextChannel | null`, resolved by name). Lifecycle: - `initialize()`: `database.load()` → tries `createDatabaseManager(sqlitePath)` → `setupEventHandlers()` → `startRefreshInterval()`. - `setupEventHandlers()`: `once('ready', onReady)`, error/warn loggers. - `onReady()`: sets presence (activity from config), calls `refreshCache()`. - `refreshCache()`: fetches `mainGuild`, then resolves all roles/channels by lowercased name lookup against `mainGuild.roles.cache` / `mainGuild.channels.cache` (text-channel filter via `channel.isTextBased()`). - `startRefreshInterval()`: - `refreshCache()` every 10 minutes. - `pikaAPI.clearCache()` every 1 hour. - Cooldowns: `isOnCooldown(userId, commandName)` returns remaining seconds (rounded up) or 0. `setCooldown(userId, commandName, seconds)` lazily creates the inner `Collection`. - `shutdown()`: clears intervals, `dbManager.close()`, `database.close()` (saves dirty JSON), `pikaAPI.destroy()`, `super.destroy()`. --- ## Permission System — `src/services/PermissionService.ts` + `PermissionLevel` enum Numeric ladder (`src/types/index.ts`): ```bash User=0, GuildMember=1, Officer=2, Leader=3, Admin=4, Developer=5, Owner=6 ``` `PermissionService.getPermissionLevel(member)`: 1. Returns `Owner` if `member.id ∈ config.bot.owners.ids`. 2. Otherwise iterates a fixed priority list (Developer → Admin → Leader → Officer → GuildMember) and returns the first matching role (by lowercased name). 3. Falls back to `User`. Helpers: `hasPermission(member, level)` (`>=` comparison), `isOwner/isStaff/isLeader/isAdmin/isDeveloper`, `isApplicationsBlacklisted`, `isSuggestionsBlacklisted`, `isChampion`, `isAway`, `isManageableRole(roleId)` (checks `config.roles.manageable.ids`). Static `getLevelName(level)` and `formatDeniedMessage(required)` produce the user-visible permission error string used by `index.ts`. A `requirePermission(level)` decorator factory is also exported but not used by any command (commands declare `permission: PermissionLevel.X` on the `Command` object instead). --- ## Database Layer — `src/database/` The codebase contains **two parallel database implementations** that coexist: ### 1. SQLite (current/canonical) - **`sqlite.ts`** — `SQLiteDatabase` wrapper around `Database` from `jsr:@db/sqlite@0.12`. On `connect()` it `mkdir -p` the parent directory, opens the DB, and applies `PRAGMA journal_mode=WAL`, `PRAGMA foreign_keys=ON`, `PRAGMA busy_timeout=5000`. Exposes `query`, `queryOne`, `execute`, `exec` returning `QueryResult` (`{ success, data?, error?, rowsAffected?, lastInsertRowId? }`). Manual transaction API (`beginTransaction/commit/rollback`) plus a `transaction(fn)` wrapper that rolls back on throw. Custom error hierarchy: `DatabaseError` → `ConnectionError`, `QueryError`, `TransactionError`. Module-level singleton via `createSQLiteDatabase(path)` / `getDatabase()`. - **`schema.ts`** — declarative `TABLES` map (15 tables) created with `CREATE TABLE IF NOT EXISTS` plus 13 `CREATE INDEX IF NOT EXISTS` statements. Tables: - `schema_info` (key/value, used to track `version=1`). - `reminders`, `families`, `family_children` (M:N), `away_status`. - `suggestions`, `suggestion_votes` (FK→suggestions ON DELETE CASCADE). - `applications` (with embedded form fields: `minecraft_username`, `discord_age`, `timezone`, `activity`, `why_join`, `experience`, `extra`). - `champions`, `qotd_questions`, `qotd_config` (single-row enforced via `CHECK (id = 1)`). - `channel_filters`, `filter_allowed_roles` (FK CASCADE), `filter_actions`. - `staff_progress`, `staff_actions`, `blacklists`, `counters`. - `initializeSchema(db)` runs all CREATEs and inserts `version` into `schema_info`. - `getSchemaVersion(db)` and `runMigrations(db)` — migration framework with no migrations yet (currently `SCHEMA_VERSION = 1`). - **`BaseRepository.ts`** — generic abstract class. Subclasses provide `tableName` and the abstract `rowToEntity` / `entityToRow` mappers. Provides: - Result type: `Result = {ok:true, value:T} | {ok:false, error:E}` with `ok()`/`err()` helpers. - Error subclasses: `RepositoryError`, `NotFoundError`, `DuplicateError`, `ValidationError`. - Helpers: `generateId(prefix)` (timestamp-base36 + random suffix), `now()`, `handleQueryResult`, `handleExecuteResult`. - Generic CRUD: `findById`, `findAll(limit?, offset?)`, `count`, `deleteById`, `exists`, plus protected `findWhere`, `findOneWhere`, `updateWhere`, `deleteWhere`. - **`DatabaseManager.ts`** — orchestrates `SQLiteDatabase` + schema + repositories. On `initialize()`: creates the `SQLiteDatabase`, runs `initializeSchema`, runs `runMigrations`, instantiates `FamilyRepositorySQLite` and `ReminderRepositorySQLite` (the only two SQLite-backed repositories). Exposes `families`, `reminders`, raw `connection`, `transaction(fn)`, `getStats()` (path, file size, table list, connected), `vacuum()`, `backup(path)` (`Deno.copyFile`), `restore(path)` (close → copy → re-init), `close()`. Module-level singleton via `createDatabaseManager(path)`. - **SQLite repositories implemented:** - `FamilyRepositorySQLite` — `getOrCreate(userId)`, `findByUserId` (eagerly loads children via secondary query against `family_children`), `getChildren`, `setPartner` (validates self-marriage and existing partner; updates both rows in a single transaction), `removePartner`, `setParent`/`removeParent` (uses `family_children` join table inside a transaction), `getFamilyTree(userId)` (returns `{ user, partner?, parent?, children: Family[] }`), `areRelated`, `deleteFamily` (clears partner refs, parent refs, child rows, then deletes the row — all in one transaction). - `ReminderRepositorySQLite` — `create(Omit)` generates `rem_…` IDs, `findByUserId` (sorted by `remind_at ASC`), `findDue()` (`remind_at <= now()`), `findUpcoming(userId, limit=10)`, `updateRemindAt`, `delete`, `deleteByUserId`, `countByUserId`, `processRecurring(id)` (deletes if non-recurring, else advances `remindAt += recurrenceInterval`). ### 2. Legacy JSON layer - **`connection.ts`** — exports two classes: - `Database` — stub class (`isConnected` flag, no real storage) — unused. - `JsonDatabase` — in-memory `Map>` persisted to a JSON file (path is `config.database.path.replace('.db', '.json')`). Methods: `load()`, `save()`, `insert(table, key, value)`, `get(table, key)`, `update(table, key, partial)`, `delete(table, key)`, `find(table, predicate)`, `getAll(table)`, `count(table)`, `clearTable(table)`, `close()` (flushes pending save). `scheduleSave()` debounces writes by 5 seconds. - **JSON-backed repositories** (`src/database/repositories/`): `ApplicationRepository`, `SuggestionRepository`, `ChampionRepository`, `QOTDRepository`, `FilterRepository`, `StaffRepository`, `AwayRepository`, `ReminderRepository`, `FamilyRepository`. These are constructed ad-hoc inside command handlers (e.g. `new ApplicationRepository(client.database)`). - **API mismatch:** Most legacy repos (`Application`, `Suggestion`, `Champion`, `QOTD`, `Filter`, `Staff`) call `this.db.set(collection, array)` and `this.db.get(collection)` — i.e. they treat tables as a single keyed value. `JsonDatabase` does **not** implement `set`; its `get` requires `(table, key)`. As a result these repositories are not actually functional against `JsonDatabase` as written; only `AwayRepository`, `ReminderRepository` (legacy) and `FamilyRepository` use the correct k-v API (`insert/get/update/delete/find/getAll`). ### `index.ts` (database barrel) Re-exports everything: SQLite types, `DatabaseManager`, schema helpers, `BaseRepository` + `Result/ok/err`, both SQLite repositories, the legacy `JsonDatabase`, and all legacy JSON repositories. --- ## PikaNetwork API — `src/api/pika/` External integration with `https://stats.pika-network.net/api` plus HTML scraping of `https://pika-network.net/` and uptime checks via `https://api.mcstatus.io/v2/status/java/`. ### `client.ts` — `PikaNetworkAPI` Configurable via `PikaAPIOptions`: `timeout` (10s default), `userAgent` (`Elly Discord Bot/1.0`), `rateLimitDelay` (200ms), `batchSize` (5), `cache`, `debug`. Maintains in-instance request stats (`totalRequests`, `successful`, `failed`, `totalLatency`). Internals: - `request(endpoint, baseUrl?)` — `fetch` with `AbortController` timeout; expects JSON, returns `null` on error/empty/invalid JSON; updates stats. - `fetchHtml(url)` — same pattern but returns text. - `delay(ms)` — used between batches. Public API (each method first checks the cache): - **Profile:** `getProfile`, `playerExists`, `getFriendList`, `getLevellingInfo`, `getGuildInfo`, `getRankInfo`, `getMiscInfo`, `getJoinInfo` (computes "estimated first join" from earliest punishment date). - **Clan:** `getClan`, `getClanMembers`. - **Leaderboards / stats:** `getLeaderboard(username, gamemode, interval='lifetime', mode='all_modes')`, `getRatioData` (KDR/WLR/WPR/AHSR + FKDR for bedwars), `getBedWarsStats`, `getSkyWarsStats`. Stat parsing helpers (`getStatValue`, `getStatPosition`, `parseBedWarsStats`, `parseSkyWarsStats`) extract the `entries[0].value` / `entries[0].place` from the keyed `LeaderboardResponse`. - **Batch:** `getMinimalBatchLeaderboard(usernames, interval)` (returns `[{username, bedwars_wins, skywars_wins, total_wins}]`), `getBatchLeaderboard(usernames, gamemode, interval)` returning a `Map`. Both chunk by 5 and `delay(200)` between batches. - **Total leaderboard:** `getTotalLeaderboard(options)` → `/leaderboards?type=…&interval=…&stat=…&mode=…&offset=…&limit=…`. - **Forum scraping (regex-based, no DOM parser):** `getStaffList()` (matches `UsernameRole` pairs; valid roles are an internal `Set`: owner, manager, lead developer, developer, admin, sr mod, moderator, helper, trial), `isStaff(username)`, `getVoteLeaderboard()` (parses winning voters and runners-up sections), `getPunishments(username, filter?, includeConsole=true)` (regex over `class="row"` blocks; cleans Minecraft `&`/`§` color codes via `cleanReason`). - **Server status:** `getServerStatus(serverIP='play.pika-network.net')` queries `mcstatus.io`, augments with a fixed icon URL (`eu.mc-api.net/v3/server/favicon`), banner URL (`api.loohpjames.com/serverbanner.png`), and (for the Pika IP) website + Discord links. - **Cache management:** `clearCache`, `clearCacheType(type)`, `getCacheStats`, `getSimpleCacheStats`, `getRequestStats` (computes `successRate` and `averageLatency`), `resetStats`, `destroy()` (calls `cache.destroy()` to clear cleanup interval), `healthCheck()` (HEAD `/profile/Technoblade`). ### `cache.ts` — `PikaCache` Composes 8 instances of an internal `AdvancedCache`: - `AdvancedCache`: TTL+LRU `Map>` with `defaultTTL` (1h fallback), `maxSize` (1000), `enableLRU`. Tracks `hits`, `misses`, `evictions`. Keys are normalized to lowercase. Methods: `get`, `set` (evicts LRU when full), `has`, `delete`, `clear`, `cleanup` (purges expired), `keys`, `getMetadata`, `touch`, plus a `getStats()` returning `hitRate`. - **TTL defaults (overridable):** profile 10m, clan 15m, leaderboard 5m, staff 1h, vote 30m, server 1m, punishments 10m. Generic 5m. `cleanupIntervalMs` 5m runs `cleanup()` over all sub-caches. - **Per-domain key formats** (`getProfile`/`setProfile`, `getClan`/`setClan`, `getLeaderboard(username, gamemode, mode, interval)` keyed as `${u}:${gm}:${m}:${i}`, `getStaff/setStaff` keyed `'list'`, `getVotes` keyed `'leaderboard'`, etc.). - `clear()` and `clearType(type)` clear all/one sub-cache. `destroy()` clears the cleanup interval. ### `types.ts` & `index.ts` Pure type definitions plus `isProfileResponse`/`isClanResponse`/`isLeaderboardResponse` runtime guards. `index.ts` is a barrel re-exporting everything. --- ## Events — `src/events/` ### `messageCreate.ts` (wired up via `client.on(messageCreateEvent.name, …)` in `index.ts`) Fires only for non-bot guild messages. Skips if `config.features.channelFiltering` is false (note: source reads `config.features.channelFiltering`, but the config key is `channel_filtering` — naming mismatch). Constructs a fresh `FilterRepository(client.database)` per message and: 1. `repo.checkMessage(channelId, content, userRoleIds)` runs through enabled filters for that channel (`getChannelFilters`); skips filters whose `allowedRoles` overlap the user's roles; tests content via `matchesFilter`: - `links`: `/https?:\/\/[^\s]+/i` - `images`: image-extension URL ending pattern - `invites`: `(discord\.gg|discord\.com\/invite)\/[a-zA-Z0-9]+` - `attachments`: returns false here (handled separately) - `custom`: user-supplied `pattern` compiled to RegExp 2. If no content match and the message has `attachments`, scans for an `attachments`-type filter and applies the same allowed-roles logic. 3. On match: `message.delete()`, `repo.logAction(…)` (truncates content to 500 chars; ring-buffer keeps last 1000 actions), DMs the user a warning embed (silently swallowed if DMs disabled), and posts a log embed to the channel named `config.channels.development_logs`. ### `interactionCreate.ts` & `ready.ts` Defined but **not registered** by `index.ts`. The `interactionCreate.ts` file has handlers for `isChatInputCommand`, `isAutocomplete`, `isButton`, `isModalSubmit`, `isStringSelectMenu` with `customId` prefix routing (`paginator:`, `application:`, `suggestion:`, `family:`, `feedback:`, `stats:`) — but the button/modal/select branches are TODO placeholders. The `ready.ts` file replicates the post-login command sync logic that already lives in `index.ts`. Actual ready behavior comes from `EllyClient.onReady()` (cache refresh + presence). --- ## Commands — `src/commands/` Every command file exports a `Command` object (`src/types/index.ts`): ```ts interface Command { data: SlashCommandBuilder | …; permission: PermissionLevel; cooldown?: number; // seconds guildOnly?: boolean; ownerOnly?: boolean; execute(interaction): Promise; autocomplete?(interaction): Promise; } ``` Categories and notable behaviours: ### Statistics (`PermissionLevel.User`, cooldown 5s typical) - **`/bedwars [mode] [interval]`** — `bedwars.ts`. `Promise.all([getProfile, getBedWarsStats])`, picks an embed colour from a hardcoded `rankColors` map keyed by lowercased rank `displayName`, builds a 12-field embed with K/D, FKDR, W/L, beds, games, winstreak. Uses `mc-heads.net/head//right` for thumbnail. - **`/skywars`** — analogous to bedwars. - **`/guild `** — looks up clan via `pikaAPI.getClan`. - **`/server`** — calls `pikaAPI.getServerStatus(...)`. ### Utility - **`/remind` (set/list/cancel)** — `utility/remind.ts`. Uses **legacy** `ReminderRepository` (NOT the SQLite one). `parseTime` handles `15m`, `2h30m`, `1d 2h 30m`. Enforces `config.limits.reminder_max_duration_days` and a hard cap of 25 active reminders/user. `discordTimestamp(date,'R')` for relative time. Uses ISO-string `remindAt` (legacy schema). - **`/away`**, **`/champion`**, **`/role`** — manage role/state with `away_max_days`/`champion_max_days` limits and the manageable-role allowlist for `/role`. - **`/staff`** — staff simulator game. Hardcoded `SCENARIOS` table for `appeal`/`report`/`assist` categories with multiple-choice answers, scoring via `StaffRepository`. Uses level thresholds `[0,50,150,300,500,750,1000,1500,2000,3000]` and per-action point values (`appeal:10, report:8, punishment:5, assist:3`). ### Suggestions — `/suggestions ` (`commands/suggestions/index.ts`, 989 lines) Subcommands: `create`, `view`, `edit`, `delete`, `my`, `approve`, `deny`, `consider`, `implement`, `list` (filter by status, sort by newest/oldest/votes/controversial), `stats`, `top`. User submits via modal; backed by legacy `SuggestionRepository` with auto-incrementing `orderNum` via the JSON `counters` table; supports up/down voting via reactions/buttons. ### Applications — `/applications ` (`commands/applications/`) Multi-file module. `index.ts` defines all subcommands and dispatches to handlers in `handlers/`: - `apply.ts` (708 lines): blacklist check (reads `client.database.get('blacklists')`), pending-application guard, 7-day cooldown after denial. Shows a 5-field modal (`minecraft_username`, `timezone`, `activity`, `why_join`, `experience`). On submit, creates the application via `ApplicationRepository`, posts an embed with two action rows (Accept/Deny/Interview/Note + ViewStats/UserHistory) to `client.channels_cache.applications`, sets up a 14-day `createMessageComponentCollector`. Officer-permission gate inside the collector. Deny opens a reason modal; Accept assigns `client.roles.guildMember` and DMs the applicant. Notes are stored in the legacy DB collection `application_notes`. - `view.ts`, `review.ts` (accept/deny), `list.ts` (list/search/history), `stats.ts` (stats/leaderboard), `settings.ts`, `admin.ts` (export to CSV, purge old applications). ### QOTD — `/qotd ` (`commands/qotd/index.ts`, 611 lines) User: `answer`, `suggest`, `streak`. Staff: `add` (with category), `queue`, `remove`, `send`, `stats`, `leaderboard`. Admin: `config` (channel, role, time HH:MM, enabled). Persists to `qotd_questions` and `qotd_config` (legacy). ### Family - **`/marry`**, **`/divorce`**, **`/adopt`**, **`/relationship`** — use legacy `FamilyRepository` (note: SQLite `FamilyRepositorySQLite` exists and is initialized on `dbManager`, but commands use the legacy class). `/marry` validates self/bot/parent-child constraints, posts a proposal with Accept/Decline buttons (60s timeout, restricted to target user via filter), commits via `setPartner` on accept. ### Moderation - **`/purge [user] [contains]`** — Officer perm. Filters out messages older than 14 days, supports user and content filters, requires `ManageMessages` for the bot. Posts a result embed and logs to `developmentLogs`. - **`/filter`** — channel-filter management (create/list/edit/delete/toggle filters of types `links/images/attachments/invites/custom`). ### Developer All require `Owner`/`Developer`/`Admin` permission level. - **`/eval [silent] [async]`** (Owner) — uses `new Function(...)` with an injected context (`client, interaction, guild, channel, user, member`). Pre-checks code against `SENSITIVE_PATTERNS` (`token, secret, password, api_key, auth, credential`); also redacts those patterns from the output. Output formatted with `Deno.inspect`, truncated to 1900 chars. - **`/shell [timeout]`** (Owner) — strict whitelist of base commands (`ls,pwd,whoami,date,uptime,df,free,cat,head,tail,wc,echo,deno`) plus blocklist regexes (`rm`, `sudo`, `chmod/chown`, `mv`, `cp`, `wget`, `curl`, package managers, redirects to `/`, pipes to `sh/bash`, `eval/exec`, `$(...)`, backticks). Spawns `sh -c ` via `Deno.Command` with a default 10s `AbortController` timeout (max 30s). - **`/database` (Developer)** — `stats`, `backup` (writes to `./data/backups/elly_.sqlite`), `query` (only `SELECT`/`PRAGMA`, blocks DML/DDL by regex, runs against `client.dbManager.connection`), `tables` (lists tables + row counts), `vacuum`. - **`/sync` (Admin)** — Manually re-PUTs commands or clears them via `Routes.applicationGuildCommands` / `Routes.applicationCommands`. - **`/reload` (Owner)** — Same idea, simpler scoping. - **`/blacklist` (Admin)** — add/remove/check/list users on `bot|applications|suggestions|commands` blacklists in the legacy `blacklists` collection. - **`/debug`**, **`/emit`** — diagnostic helpers (`debug.ts`, `emit.ts`). --- ## Utilities — `src/utils/` ### `logger.ts` `Logger` with levels `debug|info|warn|error` (numeric thresholds). Console output is ANSI-coloured per level; file output (when `logFile` set) is newline-delimited JSON appended via `Deno.writeTextFile(path, line, {append:true})`. `child(context)` derives a sub-logger with concatenated context (`a:b`). `createLogger(context, options?)` factory. ### `errors.ts` - **`BotError`** base class: `code`, `userMessage`, `isOperational`, `timestamp`, `context`, optional `cause`. `toJSON()` produces a serialisable record (used for logging). - **Subclasses:** `CommandError`, `PermissionError`, `ValidationError`, `APIError`, `RateLimitError`, `ConfigError`. - **`ErrorHandler`** singleton (`getErrorHandler()`): - `handle(error, context?)` normalises to `BotError`, logs (warn for operational, error otherwise), stores in a ring buffer (max 100), increments `errorCount`. - `handleCommandError(error, interaction)` — used by the inline `interactionCreate` handler. Builds a red embed with `userMessage`, `code` field, and timestamp, then `followUp` if replied/deferred or `reply` ephemerally. - `getStats()`, `getRecentErrors(limit=10)`, `clearErrors()`. - **`withErrorHandling(fn, context?)`**, **`assert(condition, …)`** (throws `ValidationError`), **`assertDefined(value, …)`**, **`tryAsync(fn)`** (returns Result-style), **`retry(fn, {maxAttempts, initialDelay, maxDelay, backoffFactor})`** (exponential backoff). ### `embeds.ts` Factories for consistent embeds: `successEmbed`, `errorEmbed`, `warningEmbed`, `infoEmbed`, `primaryEmbed`, `loadingEmbed`, plus higher-level helpers: `statsEmbed` (with `mc-heads.net` thumbnail), `guildInfoEmbed`, `applicationEmbed`, `suggestionEmbed`, `relationshipEmbed`, `reminderEmbed`, `modLogEmbed` (per-action colour map). `withUserFooter(embed, user, text?)` and `withTimestamp(embed, date?)` are mixins. `DEFAULT_COLORS` is exported as fallback. ### `time.ts` - `parseTime(input)` — accepts unitless integer (interpreted as minutes) or compound strings like `1d 2h 30m`. Unit aliases cover `s/sec/second(s)`, `m/min/minute(s)`, `h/hr/hour(s)`, `d/day(s)`, `w/week(s)`, `mo/month(s)` (=30d), `y/year(s)` (=365d). Returns `number | null`. - `formatDuration(ms)` — verbose (`1 day, 2 hours, and 30 minutes`). - `formatDurationShort(ms)` — compact (`1d 2h 30m`). - `relativeTime(date)` — string like `2 hours ago`, `in 3 days`. - `formatDate`, `formatDateTime`, `discordTimestamp(date, style='f')` (``). - `isPast`, `isFuture`, `addTime(date, ms)`, `startOfDay`, `endOfDay`. `parseDuration` re-exported as alias of `parseTime`. ### `pagination.ts` `ButtonPaginator` with prev/page-counter/next/stop button row (custom IDs prefixed `paginator:`). Auto-appends `Page X/Y` to embed footers when `showPageNumbers`. Restricts interactions to `authorId` (if set), uses a `MessageComponentCollector` with `timeout` (default 60s). Disables buttons on the boundary pages and on stop/end. --- ## Type Definitions — `src/types/index.ts` Centralises: - `PermissionLevel` enum. - `Command`, `CommandGroup`, `BotEvent`. - `ButtonHandler`, `ModalHandler`, `SelectMenuHandler` (interfaces; `customId` may be `string | RegExp`). Currently unused — present for the not-wired-up `interactionCreate.ts` event. - Domain entities (legacy/JSON shape): `Application`, `ApplicationFeedback`, `Suggestion`, `FamilyRelationship`, `Reminder`, `Champion`, `AwayStatus`, `QOTDQuestion`, `QOTDChannel`, `StaffProgress`, `FilteredChannel`, `Blacklist`, `ModLog`. Note the SQLite repositories define their own narrower entity types (e.g. `Family`, `Reminder`) inside their respective files with numeric epoch timestamps; the legacy types use ISO strings. - Utility types: `EmbedColors`, `CooldownEntry`, `PaginatorOptions`, `APIResponse`, `AuditAction`, `AuditActionType` union. --- ## Cross-cutting Notes / Inconsistencies - **Two parallel persistence layers.** `EllyClient` initializes both, but the SQLite `DatabaseManager` only wires up Family and Reminder repositories. All other domain code (suggestions, applications, QOTD, filters, staff, champions, away, blacklists) still reads/writes through the legacy `JsonDatabase`. - **Broken legacy API contract.** Repositories listed under "API mismatch" above call `db.set` (which doesn't exist on `JsonDatabase`) and `db.get(table)` with one argument. Either there was a prior version of `JsonDatabase` exposing array semantics, or these features are non-functional at runtime. - **Family commands use legacy repo** even though `FamilyRepositorySQLite` is initialized — `/marry` and friends construct `new FamilyRepository(client.database)`. - **Reminder commands use legacy repo** with ISO-string `remindAt`, while `ReminderRepositorySQLite` uses numeric epoch — there is no scheduled job in this codebase that actually fires due reminders (`findDue` exists but no caller invokes it). - **Two separate ready/sync paths.** `index.ts` syncs commands directly after `client.login`; `events/ready.ts` would do the same but is never registered. `EllyClient.setupEventHandlers()` registers its own `once('ready', onReady)` for cache + presence. - **`messageCreate` config-key bug.** Reads `config.features.channelFiltering` but the TOML key is `channel_filtering` (snake_case), so the feature toggle never enables filtering through config — it's always falsy unless the TOML loader stores it under both keys (it does not). - **No autocomplete handler is wired** — `Command.autocomplete` is defined in the type but the live `interactionCreate` listener in `index.ts` only handles `isChatInputCommand`. - **Button/modal/select-menu interactions** for applications, suggestions, family use **per-message component collectors** (`createMessageComponentCollector` / `awaitMessageComponent` / `awaitModalSubmit`) rather than a global router; each command attaches its own collector on the messages it sends. - **Cooldown storage** is in-process only (`Collection`s on `EllyClient`); restarts reset all cooldowns. - **PikaNetwork API key rotation, rate-limit headers, and 429 backoff** are not implemented; the `RateLimitError` class exists in `utils/errors.ts` but is unreferenced.