Files
EllyDiscordBot/ARCHITECTURE.md
2026-05-28 23:46:40 +00:00

364 lines
32 KiB
Markdown

# 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.