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

32 KiB

Elly Discord Bot — Architecture

Runtime & Tooling

  • Runtime: Deno (compilerOptions in deno.json: strict, esnext, deno.window).
  • Dependencies via Deno imports map (deno.json):
    • discord.jsnpm:discord.js@^14.14.1
    • @discordjs/restnpm:@discordjs/rest@^2.2.0
    • @toml-tools/parsernpm:@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/)

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):

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.tsSQLiteDatabase 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: DatabaseErrorConnectionError, 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:
    • FamilyRepositorySQLitegetOrCreate(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).
    • ReminderRepositorySQLitecreate(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.tsPikaNetworkAPI

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

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):

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 wiredCommand.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 (Collections 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.