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.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 insrc/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:
Dockerfilebuilds ondenoland/deno:latest, runsdeno install --frozen, copiessrc/,config.toml,.env, creates/app/dataand/app/logs, then runssrc/index.ts.docker-compose.ymlmounts./data,./logs,./config.toml(read-only) into the container. - Secrets:
DISCORD_TOKENis read from env (loaded by Deno's--envflag 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():
- Calls
loadConfig('./config.toml')thenvalidateConfig(config). Errors abort (Deno.exit(1)); warnings only logged. - Constructs
new EllyClient(config). - Builds an in-source array of
{ cmd, category }pairs (28 commands across Statistics, Utility, Suggestions, QOTD, Applications, Family, Moderation, Developer) and callsclient.registerCommand(cmd)for each. - Registers
messageCreateEvent(filter / auto-mod) on the client. - Calls
client.initialize()(loads DBs, sets event handlers, starts refresh intervals). - Registers a single
interactionCreatelistener inline inindex.tsthat:- 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).
- Returns early unless
- Adds
SIGINT/SIGTERMhandlers callingclient.shutdown(). - Calls
client.login(token)(token fromDeno.env.get('DISCORD_TOKEN')). - After login, builds a
RESTclient and PUTsRoutes.applicationGuildCommands(clientId, guildId)withcmd.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. ReturnsRecord<string, unknown>and is type-cast toConfig. loadConfig(path): reads file viaDeno.readTextFile, parses, returnsConfig. Throws onDeno.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 range0..0xFFFFFF, log level enum,purge_max_messages ∈ [1,100], etc. Missing channels/roles/features only emit warnings.validateConfigOrThrowandgetConfigValue<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: ConfigpikaAPI: PikaNetworkAPI— constructed withtimeout = config.api.pika_request_timeoutandcache.profileTTL = config.api.pika_cache_ttl,leaderboardTTL = pika_cache_ttl/2.database: JsonDatabase— legacy JSON DB atconfig.database.path.replace('.db', '.json').dbManager: DatabaseManager | null— SQLite manager atconfig.database.path.replace('.json', '.sqlite'). Created ininitialize(); falls back to JSON if SQLite init throws.permissions: PermissionServicelogger: Logger(level + file fromconfig.logging)errorHandler: ErrorHandler(singleton fromgetErrorHandler()).commands: Collection<string, Command>— registry populated byregisterCommand.cooldowns: Collection<string, Collection<string, number>>—commandName → userId → expiresAtMs.mainGuild: Guild | nullrolescache:admin, leader, officer, developer, guildMember, champion, away, applicationsBlacklisted, suggestionsBlacklisted(allRole | null, resolved by name).channels_cache:applications, applicationLogs, suggestions, suggestionLogs, guildUpdates, discordChangelog, inactivity, developmentLogs, donations, reminders(allTextChannel | null, resolved by name).
Lifecycle:
initialize():database.load()→ triescreateDatabaseManager(sqlitePath)→setupEventHandlers()→startRefreshInterval().setupEventHandlers():once('ready', onReady), error/warn loggers.onReady(): sets presence (activity from config), callsrefreshCache().refreshCache(): fetchesmainGuild, then resolves all roles/channels by lowercased name lookup againstmainGuild.roles.cache/mainGuild.channels.cache(text-channel filter viachannel.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 innerCollection. 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):
- Returns
Ownerifmember.id ∈ config.bot.owners.ids. - Otherwise iterates a fixed priority list (Developer → Admin → Leader → Officer → GuildMember) and returns the first matching role (by lowercased name).
- 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—SQLiteDatabasewrapper aroundDatabasefromjsr:@db/sqlite@0.12. Onconnect()itmkdir -pthe parent directory, opens the DB, and appliesPRAGMA journal_mode=WAL,PRAGMA foreign_keys=ON,PRAGMA busy_timeout=5000. Exposesquery<T>,queryOne<T>,execute,execreturningQueryResult<T>({ success, data?, error?, rowsAffected?, lastInsertRowId? }). Manual transaction API (beginTransaction/commit/rollback) plus atransaction(fn)wrapper that rolls back on throw. Custom error hierarchy:DatabaseError→ConnectionError,QueryError,TransactionError. Module-level singleton viacreateSQLiteDatabase(path)/getDatabase().schema.ts— declarativeTABLESmap (15 tables) created withCREATE TABLE IF NOT EXISTSplus 13CREATE INDEX IF NOT EXISTSstatements. Tables:schema_info(key/value, used to trackversion=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 viaCHECK (id = 1)).channel_filters,filter_allowed_roles(FK CASCADE),filter_actions.staff_progress,staff_actions,blacklists,counters.initializeSchema(db)runs all CREATEs and insertsversionintoschema_info.getSchemaVersion(db)andrunMigrations(db)— migration framework with no migrations yet (currentlySCHEMA_VERSION = 1).
BaseRepository.ts— generic abstract class. Subclasses providetableNameand the abstractrowToEntity/entityToRowmappers. Provides:- Result type:
Result<T,E> = {ok:true, value:T} | {ok:false, error:E}withok()/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 protectedfindWhere,findOneWhere,updateWhere,deleteWhere.
- Result type:
DatabaseManager.ts— orchestratesSQLiteDatabase+ schema + repositories. Oninitialize(): creates theSQLiteDatabase, runsinitializeSchema, runsrunMigrations, instantiatesFamilyRepositorySQLiteandReminderRepositorySQLite(the only two SQLite-backed repositories). Exposesfamilies,reminders, rawconnection,transaction(fn),getStats()(path, file size, table list, connected),vacuum(),backup(path)(Deno.copyFile),restore(path)(close → copy → re-init),close(). Module-level singleton viacreateDatabaseManager(path).- SQLite repositories implemented:
FamilyRepositorySQLite—getOrCreate(userId),findByUserId(eagerly loads children via secondary query againstfamily_children),getChildren,setPartner(validates self-marriage and existing partner; updates both rows in a single transaction),removePartner,setParent/removeParent(usesfamily_childrenjoin 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'>)generatesrem_…IDs,findByUserId(sorted byremind_at ASC),findDue()(remind_at <= now()),findUpcoming(userId, limit=10),updateRemindAt,delete,deleteByUserId,countByUserId,processRecurring(id)(deletes if non-recurring, else advancesremindAt += recurrenceInterval).
2. Legacy JSON layer
connection.ts— exports two classes:Database— stub class (isConnectedflag, no real storage) — unused.JsonDatabase— in-memoryMap<table, Map<key, value>>persisted to a JSON file (path isconfig.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) callthis.db.set(collection, array)andthis.db.get<T[]>(collection)— i.e. they treat tables as a single keyed value.JsonDatabasedoes not implementset; itsgetrequires(table, key). As a result these repositories are not actually functional againstJsonDatabaseas written; onlyAwayRepository,ReminderRepository(legacy) andFamilyRepositoryuse 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?)—fetchwithAbortControllertimeout; expects JSON, returnsnullon 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 theentries[0].value/entries[0].placefrom the keyedLeaderboardResponse. - Batch:
getMinimalBatchLeaderboard(usernames, interval)(returns[{username, bedwars_wins, skywars_wins, total_wins}]),getBatchLeaderboard(usernames, gamemode, interval)returning aMap. Both chunk by 5 anddelay(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 internalSet: 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 overclass="row"blocks; cleans Minecraft&/§color codes viacleanReason). - Server status:
getServerStatus(serverIP='play.pika-network.net')queriesmcstatus.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(computessuccessRateandaverageLatency),resetStats,destroy()(callscache.destroy()to clear cleanup interval),healthCheck()(HEAD/profile/Technoblade).
cache.ts — PikaCache
Composes 8 instances of an internal AdvancedCache<T>:
AdvancedCache<T>: TTL+LRUMap<string, CacheEntry<T>>withdefaultTTL(1h fallback),maxSize(1000),enableLRU. Trackshits,misses,evictions. Keys are normalized to lowercase. Methods:get,set(evicts LRU when full),has,delete,clear,cleanup(purges expired),keys,getMetadata,touch, plus agetStats()returninghitRate.- TTL defaults (overridable): profile 10m, clan 15m, leaderboard 5m, staff 1h, vote 30m, server 1m, punishments 10m. Generic 5m.
cleanupIntervalMs5m runscleanup()over all sub-caches. - Per-domain key formats (
getProfile/setProfile,getClan/setClan,getLeaderboard(username, gamemode, mode, interval)keyed as${u}:${gm}:${m}:${i},getStaff/setStaffkeyed'list',getVoteskeyed'leaderboard', etc.). clear()andclearType(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:
repo.checkMessage(channelId, content, userRoleIds)runs through enabled filters for that channel (getChannelFilters); skips filters whoseallowedRolesoverlap the user's roles; tests content viamatchesFilter:links:/https?:\/\/[^\s]+/iimages: image-extension URL ending patterninvites:(discord\.gg|discord\.com\/invite)\/[a-zA-Z0-9]+attachments: returns false here (handled separately)custom: user-suppliedpatterncompiled to RegExp
- If no content match and the message has
attachments, scans for anattachments-type filter and applies the same allowed-roles logic. - 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 namedconfig.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 hardcodedrankColorsmap keyed by lowercased rankdisplayName, builds a 12-field embed with K/D, FKDR, W/L, beds, games, winstreak. Usesmc-heads.net/head/<user>/rightfor thumbnail./skywars— analogous to bedwars./guild <name>— looks up clan viapikaAPI.getClan./server— callspikaAPI.getServerStatus(...).
Utility
/remind(set/list/cancel) —utility/remind.ts. Uses legacyReminderRepository(NOT the SQLite one).parseTimehandles15m,2h30m,1d 2h 30m. Enforcesconfig.limits.reminder_max_duration_daysand a hard cap of 25 active reminders/user.discordTimestamp(date,'R')for relative time. Uses ISO-stringremindAt(legacy schema)./away,/champion,/role— manage role/state withaway_max_days/champion_max_dayslimits and the manageable-role allowlist for/role./staff— staff simulator game. HardcodedSCENARIOStable forappeal/report/assistcategories with multiple-choice answers, scoring viaStaffRepository. 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 (readsclient.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 viaApplicationRepository, posts an embed with two action rows (Accept/Deny/Interview/Note + ViewStats/UserHistory) toclient.channels_cache.applications, sets up a 14-daycreateMessageComponentCollector. Officer-permission gate inside the collector. Deny opens a reason modal; Accept assignsclient.roles.guildMemberand DMs the applicant. Notes are stored in the legacy DB collectionapplication_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 legacyFamilyRepository(note: SQLiteFamilyRepositorySQLiteexists and is initialized ondbManager, but commands use the legacy class)./marryvalidates self/bot/parent-child constraints, posts a proposal with Accept/Decline buttons (60s timeout, restricted to target user via filter), commits viasetPartneron accept.
Moderation
/purge <amount> [user] [contains]— Officer perm. Filters out messages older than 14 days, supports user and content filters, requiresManageMessagesfor the bot. Posts a result embed and logs todevelopmentLogs./filter— channel-filter management (create/list/edit/delete/toggle filters of typeslinks/images/attachments/invites/custom).
Developer
All require Owner/Developer/Admin permission level.
/eval <code> [silent] [async](Owner) — usesnew Function(...)with an injected context (client, interaction, guild, channel, user, member). Pre-checks code againstSENSITIVE_PATTERNS(token, secret, password, api_key, auth, credential); also redacts those patterns from the output. Output formatted withDeno.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 tosh/bash,eval/exec,$(...), backticks). Spawnssh -c <cmd>viaDeno.Commandwith a default 10sAbortControllertimeout (max 30s)./database(Developer) —stats,backup(writes to./data/backups/elly_<ts>.sqlite),query(onlySELECT/PRAGMA, blocks DML/DDL by regex, runs againstclient.dbManager.connection),tables(lists tables + row counts),vacuum./sync(Admin) — Manually re-PUTs commands or clears them viaRoutes.applicationGuildCommands/Routes.applicationCommands./reload(Owner) — Same idea, simpler scoping./blacklist(Admin) — add/remove/check/list users onbot|applications|suggestions|commandsblacklists in the legacyblacklistscollection./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
BotErrorbase class:code,userMessage,isOperational,timestamp,context, optionalcause.toJSON()produces a serialisable record (used for logging).- Subclasses:
CommandError,PermissionError,ValidationError,APIError,RateLimitError,ConfigError. ErrorHandlersingleton (getErrorHandler()):handle(error, context?)normalises toBotError, logs (warn for operational, error otherwise), stores in a ring buffer (max 100), incrementserrorCount.handleCommandError(error, interaction)— used by the inlineinteractionCreatehandler. Builds a red embed withuserMessage,codefield, and timestamp, thenfollowUpif replied/deferred orreplyephemerally.getStats(),getRecentErrors(limit=10),clearErrors().
withErrorHandling(fn, context?),assert(condition, …)(throwsValidationError),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 like1d 2h 30m. Unit aliases covers/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). Returnsnumber | null.formatDuration(ms)— verbose (1 day, 2 hours, and 30 minutes).formatDurationShort(ms)— compact (1d 2h 30m).relativeTime(date)— string like2 hours ago,in 3 days.formatDate,formatDateTime,discordTimestamp(date, style='f')(<t:unix:style>).isPast,isFuture,addTime(date, ms),startOfDay,endOfDay.parseDurationre-exported as alias ofparseTime.
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:
PermissionLevelenum.Command,CommandGroup,BotEvent<T>.ButtonHandler,ModalHandler,SelectMenuHandler(interfaces;customIdmay bestring | RegExp). Currently unused — present for the not-wired-upinteractionCreate.tsevent.- 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,AuditActionTypeunion.
Cross-cutting Notes / Inconsistencies
- Two parallel persistence layers.
EllyClientinitializes both, but the SQLiteDatabaseManageronly wires up Family and Reminder repositories. All other domain code (suggestions, applications, QOTD, filters, staff, champions, away, blacklists) still reads/writes through the legacyJsonDatabase. - Broken legacy API contract. Repositories listed under "API mismatch" above call
db.set(which doesn't exist onJsonDatabase) anddb.get(table)with one argument. Either there was a prior version ofJsonDatabaseexposing array semantics, or these features are non-functional at runtime. - Family commands use legacy repo even though
FamilyRepositorySQLiteis initialized —/marryand friends constructnew FamilyRepository(client.database). - Reminder commands use legacy repo with ISO-string
remindAt, whileReminderRepositorySQLiteuses numeric epoch — there is no scheduled job in this codebase that actually fires due reminders (findDueexists but no caller invokes it). - Two separate ready/sync paths.
index.tssyncs commands directly afterclient.login;events/ready.tswould do the same but is never registered.EllyClient.setupEventHandlers()registers its ownonce('ready', onReady)for cache + presence. messageCreateconfig-key bug. Readsconfig.features.channelFilteringbut the TOML key ischannel_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.autocompleteis defined in the type but the liveinteractionCreatelistener inindex.tsonly handlesisChatInputCommand. - 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 onEllyClient); restarts reset all cooldowns. - PikaNetwork API key rotation, rate-limit headers, and 429 backoff are not implemented; the
RateLimitErrorclass exists inutils/errors.tsbut is unreferenced.