Shared building blocks for discord.js-based Discord bots: a nestable console logger, zod-based env validation, a generic HTTP API client, a slash-command interaction dispatcher, command registration helpers, and thin client/shard bootstrap wrappers — plus optional Postgres (Prisma) and i18next helpers for bots that want them.
Extracted from HammerTimeBot,
Fantastick, and
PennyCurve, which had each
independently reimplemented the same architecture. See CLAUDE.md for the
design rationale and module-to-source mapping.
pnpm add @went.tf/discord-bot-framework zod discord.js @discordjs/rest discord-api-typeszod and ajv are real dependencies of this package but must also be
listed by consumers directly (peer resolution quirk of subpath-only usage)
if you use defineEnv or compile your own commands schema at your own top
level. prisma/@prisma/client/@prisma/adapter-pg and
i18next/i18next-fs-backend are optional peers — only install them if
you import @went.tf/discord-bot-framework/db or /i18n.
Everything is available from the package root except ./db, ./i18n, and
./dev. ./db/./i18n are kept as separate subpaths so bots that don't use
Postgres/Prisma or i18next never need to install those peer dependencies.
./dev is excluded for a different reason — it has no extra peer
dependencies, but it's dev-only tooling that shouldn't leak into every
consumer's root import surface.
Backed by pino. Plain new Logger(prefix) /
Logger.fromShardInfo(...) stay simple, console-only, worker-thread-free
constructors:
import { Logger, NestableLogger, DevNullLogger } from '@went.tf/discord-bot-framework/logger';
const logger = new Logger('Bot');
const interactionLogger = logger.nest(`Interaction#${interaction.id}`);
const shardLogger = Logger.fromShardInfo(process.env.SHARDS);To additionally fan logs out to a Discord webhook (in batches, respecting
Discord's per-webhook rate limits), use createLogger instead — it builds one
pino instance with the requested transport targets (console + optional
webhook), and nest() on the result shares that same instance rather than
spawning a new worker thread per call:
import { createLogger } from '@went.tf/discord-bot-framework/logger';
const logger = createLogger({
prefix: 'Bot',
discordWebhook: {
url: env.LOG_WEBHOOK_URL,
level: 'warn', // only warn/error/fatal are sent to Discord; default 'warn'
},
});import { defineEnv, boolFromString } from '@went.tf/discord-bot-framework/env';
import { z } from 'zod';
export const env = defineEnv({
DISCORD_BOT_TOKEN: z.string().min(1),
API_URL: z.string().url(),
LOCAL: boolFromString().default(false),
SUPPORT_SERVER_ID: z.string().optional().default(''),
});Throws one formatted Error listing every failing key. Pass { dotenv: false }
to skip loading a .env file, or { source } to validate a fixture object
(useful in tests).
import { ApiClient, ApiAuthType } from '@went.tf/discord-bot-framework/api-client';
const apiClient = new ApiClient(logger, {
baseUrl: `${env.API_URL}/api`,
authentication: { type: ApiAuthType.AUTHORIZATION_HEADER, getValue: () => env.API_TOKEN },
userAgent: env.UA_STRING,
retry: { maxAttempts: 3, initialDelayMs: 500 }, // optional; off by default, retries 5xx/429
timeoutMs: 5000, // optional; off by default - fetch has no timeout of its own otherwise
});
const { response } = await apiClient.request({
path: '/things',
validator: typia.createValidate<Thing[]>(), // optional; omit for `response: unknown`
timeoutMs: 1000, // optional; overrides the client-level default for this call only
});Commands/components/modals are self-describing — put the name/id directly on
the object (as name or id) and pass an array to a createXRegistry()
helper instead of hand-writing a Record<Enum, Handler> map. The registry
derives the literal name/id union from the array itself (TS 5 const type
params), so there's no separate enum to keep in sync, and registry.byName
is a drop-in commands/components/modals value for
createInteractionRouter/the dispatch* functions below.
A command's wire definition (name, description, options, permissions) no
longer lives on this object — it lives in your commands.json file, see
./commands below. This object is purely the handler side:
import { createChatInputCommandRegistry, createComponentRegistry, createInteractionRouter, handleInteractionError } from '@went.tf/discord-bot-framework/interactions';
const pingCommand = { name: 'ping', handle: (interaction) => interaction.reply('pong') };
const chatInputCommandRegistry = createChatInputCommandRegistry([pingCommand /* , ... */]);
const componentRegistry = createComponentRegistry([/* ... */]);
const router = createInteractionRouter({
commands: chatInputCommandRegistry.byName,
components: componentRegistry.byName,
buildContext: async (interaction, baseContext) => ({ ...baseContext, t: await buildT(interaction) }),
onError: (interaction, context, error) =>
handleInteractionError(interaction, context, { buildMessage: () => context.t('errors.unexpected') }),
});
client.on(Events.InteractionCreate, (interaction) => router(interaction, baseContext));Bots that need to run logic between a command handler and error handling
(e.g. telemetry) can call dispatchChatInputCommand/dispatchAutocomplete/
dispatchComponent/dispatchModal/dispatchContextMenu directly instead of
the combined router — both take the same registry.byName maps.
There's also createContextMenuCommandRegistry/createModalRegistry for the
other two interaction kinds, and flattenCommandModals(chatInputRegistry)
for bots that nest a .modal map directly on the owning chat-input command
(rather than registering modals as a standalone top-level registry) — it
synthesizes a flat Registry<string, BotModal<Ctx>> view so dispatchModal
can consume it unchanged.
If other code (e.g. a locale-file type, or a helper building a command
mention) needs the literal name/id union as a type, derive it from the
registry you already built instead of hand-writing a parallel
const enum CommandName { ... } — that enum is exactly the duplication the
registry's const type inference exists to avoid. This only works if each
command's own name stays a literal type up to the point it's passed into
createChatInputCommandRegistry — a real gotcha, not a hypothetical one:
a plain, unannotated const pingCommand = { name: 'ping', handle: ... };
widens name to string right there (standard TS object-literal-property
widening, the same reason const x = { n: 1 }; x.n is number not 1),
before it ever reaches the registry call, so RegistryName would silently
resolve to string too. satisfies BotChatInputCommand does not fix
this either — it only checks compatibility, it doesn't request a literal.
Pin each command's own name as an explicit generic argument instead (the
same pattern this package's own registry tests use):
import { NamedChatInputCommand, RegistryName } from '@went.tf/discord-bot-framework/interactions';
const pingCommand: NamedChatInputCommand<Ctx, 'ping'> = { name: 'ping', handle: (i) => i.reply('pong') };
const searchCommand: NamedChatInputCommand<Ctx, 'search'> = { name: 'search', handle: (i) => i.reply('...') };
const chatInputCommandRegistry = createChatInputCommandRegistry([pingCommand, searchCommand]);
type ChatInputCommandName = RegistryName<typeof chatInputCommandRegistry>; // 'ping' | 'search'(Object literals passed directly inline into the array — not through an
intermediate const — infer literally on their own, since TS 5's const
type parameter modifier on createChatInputCommandRegistry requests literal
inference for its argument; the explicit-generic form above is what you need
once each command lives in its own file, which is the normal case.)
A command's name should only ever be written down in two places: its
commands.json entry and its own registry object's name field — nothing
else should define it again, only reference the same string (or the derived
RegistryName type) that those two already agree on.
Every command's wire definition (name, description, options, permissions)
lives in one commands.json file per bot — a flat array mirroring
Discord's bulk-overwrite PUT body exactly, so it's directly postable to
Discord's API as-is (translations aside, see below). This replaces the old
per-command getDefinition() function: handler objects ({ name, handle, autocomplete?, modal? }) no longer describe their own wire shape at all.
-
Author
commands.json, validated against your own JSON Schema composed over this package's generic fragments (see "JSON Schema fragments" below):{ "$schema": "./commands.schema.json", "commands": [ { "type": "CHAT_INPUT", "name": "ping", "description": "Replies with pong" }, { "type": "CHAT_INPUT", "name": "search", "description": "Search for something", "options": [ { "type": "STRING", "name": "query", "description": "Query string", "required": true } ] } ] }The
{ "$schema", "commands" }wrapper is what makescommands.jsoneditable with real autocomplete/validation in VS Code, JetBrains, or any other JSON-Schema-aware editor:$schemais only ever valid on a JSON object, and a bare array can never carry one. A bare array (just thecommandsvalue on its own, no wrapper) is still fully supported — nothing about the wrapper is required — but you lose the inline$schemaeditor hookup if you use it.buildApplicationCommandsBodyaccepts either shape directly.type(andcontexts/integration_types/channel_types) accept either Discord's raw numeric value or the UPPER_SNAKE_CASE string alias shown above — nobody should have to remember that3meansSTRING. Both forms, and any mix of the two, are always valid;buildApplicationCommandsBodyresolves whichever form was used to its real numeric value right before writing each command/option into the final REST body, using the mapping in./commands/schema'senum-maps.ts(sourced fromdiscord-api-types' own enums, not hand-duplicated numbers, so it can't drift). The numeric form still works and always will — this is additive, not a replacement. -
Parse and validate it before doing anything else with it:
import { Ajv } from 'ajv'; import { parseCommandsFile, registerFrameworkSchemas, resolveCommandsSchemaRefs } from '@went.tf/discord-bot-framework/commands/schema'; import myCommandsSchemaRaw from './commands.schema.json' with { type: 'json' }; import commandsData from './commands.json' with { type: 'json' }; // Rewrites your schema's relative-path $refs (into node_modules) to each // fragment's real ajv-resolvable identity - see "JSON Schema fragments" below. const myCommandsSchema = resolveCommandsSchemaRefs(myCommandsSchemaRaw); const ajv = new Ajv({ allErrors: true, allowUnionTypes: true }); registerFrameworkSchemas(ajv); const validate = ajv.compile(myCommandsSchema); const commandsFile = parseCommandsFile(commandsData, { validate });
-
Build the Discord-ready body and register it — unchanged from before, just fed by
commandsFile+ your handler registries instead ofgetDefinition():import { buildApplicationCommandsBody, createCommandRegistrar, fixedReplyCommandFactory } from '@went.tf/discord-bot-framework/commands'; const registrar = createCommandRegistrar({ rest, applicationId: env.DISCORD_CLIENT_ID, logger }); const commandBodies = buildApplicationCommandsBody( commandsFile, { chatInput: chatInputCommandRegistry, contextMenu: contextMenuCommandRegistry }, { sharedMetadata: { integration_types: [...], contexts: [...] } }, ); await registrar.updateGlobalCommands(commandBodies); const pingCommand = { name: 'ping', ...fixedReplyCommandFactory('pong') };
buildApplicationCommandsBody walks commandsFile (its order drives the
output order, not registry insertion order), matches each entry to a handler
by name, applies registerCondition filtering, merges sharedMetadata
(the commands.json entry's own fields win on conflict), and stably sorts
every options array (including nested subcommand/subcommand-group options)
so required options precede optional ones, matching Discord's API
requirement automatically. It also enforces two invariants before ever
calling Discord's API, each collecting every offender into one thrown
error rather than failing on the first: every commands.json entry must
have a matching handler, every handler must have a matching commands.json
entry, and every command/option must end up with a non-empty description
(from the file directly, or via resolveDescription — see "Localizing
command names/descriptions" below).
fixedReplyCommandFactory(content, ephemeral?) now only returns { handle }
— pair it with a registry entry that supplies name, and a commands.json
entry that supplies name/description.
This package ships only the generic, reusable JSON Schema building
blocks mirroring discord-api-types' command/option shapes — it does not
dictate one rigid schema for your whole commands.json file. Compose your
own schema on top via $ref/allOf, e.g. to narrow name to an enum of
your bot's actual command names. Since a bot's own commands.schema.json is
authored as plain JSON (so external tools can consume it too, not just this
package's TS composition helpers), supporting both the bare-array and
the { $schema, commands } root shapes over the same narrowed entry uses a
local $defs entry referenced from both branches:
{
"$defs": {
"entry": {
"oneOf": [
{
"allOf": [
{ "$ref": "../node_modules/@went.tf/discord-bot-framework/build/commands/schema/chat-input-command.json" },
{ "properties": { "name": { "enum": ["ping", "search"] } } }
]
},
{ "$ref": "../node_modules/@went.tf/discord-bot-framework/build/commands/schema/context-menu-command.json" }
]
}
},
"oneOf": [
{ "type": "array", "items": { "$ref": "#/$defs/entry" } },
{
"type": "object",
"properties": {
"$schema": { "type": "string" },
"commands": { "type": "array", "items": { "$ref": "#/$defs/entry" } }
},
"required": ["commands"],
"additionalProperties": false
}
]
}$ref uses a real relative filesystem path into node_modules (the
exact ../ count doesn't have to be exactly right — see below) rather than
an opaque URL — this is what makes an editor (VS Code, JetBrains, ...)
actually able to follow it and offer real autocomplete/validation while you
hand-edit commands.schema.json, since it points at a real file: this
package ships its raw fragment .json mirrors precisely so a path like this
resolves to something real on disk, no network access involved.
ajv can't follow that same relative path directly — it resolves relative
$refs via real RFC3986 URI resolution against the referencing schema's own
$id, and node_modules is virtualized under a symlinked store by pnpm (and
similar tools), so there is no $id value that makes ajv's own resolution
land on the right file for every install. resolveCommandsSchemaRefs()
sidesteps this: it rewrites any $ref matching a shipped fragment's
filename to that fragment's canonical $id (whatever relative-path prefix
you used), which registerFrameworkSchemas(ajv) has already registered.
Your own local refs (e.g. #/$defs/...) are left untouched:
import myCommandsSchemaRaw from './commands.schema.json' with { type: 'json' };
const myCommandsSchema = resolveCommandsSchemaRefs(myCommandsSchemaRaw);
registerFrameworkSchemas(ajv); // before ajv.compile(myCommandsSchema)If you don't need the name-narrowing (or any other bot-specific constraint),
skip authoring your own schema entirely and validate directly against this
package's own commandsFileSchema — it already accepts both root shapes.
The fragments this package ships (all under
@went.tf/discord-bot-framework/commands/schema, and as real standalone
.json files under build/commands/schema/ for non-TS tooling):
commands-file, command-file-entry, chat-input-command,
context-menu-command, application-command-option (and its
application-command-leaf-option/application-command-subcommand/
application-command-subcommand-group building blocks),
application-command-option-choice, default-member-permissions,
option-name, context-menu-name, application-command-type,
interaction-context-type, application-integration-type, channel-type.
./commands/schema also exports enum-maps.ts's APPLICATION_COMMAND_TYPE_MAP,
APPLICATION_COMMAND_OPTION_TYPE_MAP, INTERACTION_CONTEXT_TYPE_MAP,
APPLICATION_INTEGRATION_TYPE_MAP, CHANNEL_TYPE_MAP, and resolveEnumValue —
the same lookup tables buildApplicationCommandsBody uses internally to
resolve commands.json's UPPER_SNAKE_CASE string aliases, exported in case
other tooling built on top of this package needs the same mapping (e.g. a
linter or a codemod converting old numeric commands.json files to the
string form).
Base fragments use additionalProperties: false for strictness — if your
bot needs a genuinely new top-level field per command entry, you'll need
unevaluatedProperties-based composition instead of allOf, since
additionalProperties: false only evaluates a schema's own declared
properties, not fields declared on sibling allOf members.
Command/option names are required in commands.json, but
descriptions are optional — a description can be authored directly in
the file, or left out and filled in at submission time (see below). Nothing
in commands.json is ever localized by hand: no name_localizations/
description_localizations fields exist in this schema at all.
createCommandLocalizer (from @went.tf/discord-bot-framework/i18n)
generically resolves descriptions and builds name_localizations/
description_localizations dictionaries from an i18next TFunction, keyed
by the same path convention buildApplicationCommandsBody uses internally
(commands.<name>.description, commands.<name>.options.<option>.description,
and one level deeper for subcommand options):
import { createCommandLocalizer } from '@went.tf/discord-bot-framework/i18n';
const localizer = createCommandLocalizer({ locales: SUPPORTED_LANGUAGES, baseLocale: DEFAULT_LANGUAGE, t: i18nextInstance.t });
const commandBodies = buildApplicationCommandsBody(commandsFile, registries, {
resolveDescription: localizer.resolveDescription,
localizeNames: localizer.localizeName,
localizeDescriptions: localizer.localizeDescription,
});If a command/option has no description in commands.json and no
resolveDescription hook is wired in (or the hook can't find a translation
either), buildApplicationCommandsBody throws before anything is sent to
Discord — it never silently registers a command with a missing description.
To derive TS types from your own composed schema, install
json-schema-to-ts
yourself (it's a devDependency of this package, type-only, not re-exported)
and use its FromSchema the same way this package's own
commands/schema/index.ts does — pass every $ref-ed fragment (yours and
this package's) in the references option.
Sharding is entirely opt-in. Most bots — anything single-guild or otherwise
small enough not to need multiple discord.js shards — should just use
createBotClient and never touch createShardManager or anything
shard-related at all:
import { createBotClient } from '@went.tf/discord-bot-framework/client';
const client = await createBotClient({ intents: [GatewayIntentBits.Guilds], token, onInteraction });Only reach for createShardManager if your bot actually runs across
multiple discord.js shards (large multi-guild bots). It's a separate,
independent function — pulling it in doesn't require any sharding-specific
config elsewhere in the framework:
import { createShardManager } from '@went.tf/discord-bot-framework/client';
const manager = await createShardManager({
token, botScriptPath, logger,
beforeSpawn: () => startupCommandsUpdate(logger),
});Graceful deploys with gracefulRespawnSignal: a plain process restart
kills every shard at once, then respawns them sequentially — Discord's
identify rate limit forces roughly one shard every 5s, so a bot with 20+
shards can be fully down for minutes on every deploy. Passing
gracefulRespawnSignal: 'SIGUSR2' registers a handler that calls the
manager's respawnAll() instead, which kills/respawns shards one at a
time on the same long-lived manager process — at most one shard is briefly
offline (a few seconds) at any point, not the whole fleet:
const manager = await createShardManager({
token, botScriptPath, logger,
beforeSpawn: () => startupCommandsUpdate(logger),
gracefulRespawnSignal: 'SIGUSR2',
});Your deploy script then sends that signal to the running manager process
(e.g. kill -s USR2 "$(pm2 pid my-bot)" — note dash's kill builtin
rejects bash's -SIGUSR2 form, use -s USR2) instead of restarting it,
whenever only shard-side code changed. Since a forked shard process re-reads
its script fresh off disk, this picks up ordinary code changes fine — but
NOT changes to the top-level process that calls createShardManager itself
(this file, its beforeSpawn, or anything it holds in memory), since that
process is never restarted. Fall back to a real restart for those, and for
anything that needs beforeSpawn's one-time setup (e.g. slash command
registration) to re-run.
Validated against a real bot. HammerTimeBot ran this against a real registered Discord Interactions Endpoint URL (portal PING validation, then a live
/unixslash command end-to-end) on itsmigrate-discord-bot-frameworkbranch — signature verification, PING/PONG, the discord.js-Interactionbridge, and the REST-callback-based ack path all confirmed working with zero modifications needed to its 16 command files + component handler. See CLAUDE.md's./webhookdesign-decision entry for the one open item that validation surfaced (a Discord portal-side double-PING quirk, not blocking).
An alternate, independent transport alongside ./client's gateway-based
createBotClient/createShardManager, for bots that want to receive
interactions over an HTTP Interactions Endpoint instead of holding a
WebSocket open. As with createBotClient/createShardManager, this doesn't
unify with the gateway path behind one entry point — pick one transport per
bot and use its functions directly.
verifyInteractionRequest checks a request's X-Signature-Ed25519/
X-Signature-Timestamp headers against your application's public key, using
Node's native crypto module (no tweetnacl/discord-interactions
dependency needed). handleWebhookInteractionRequest wraps that plus
Discord's PING→PONG endpoint-validation handshake around your own handler,
taking/returning plain data so it can be wired into any HTTP framework (or
none):
import { handleWebhookInteractionRequest } from '@went.tf/discord-bot-framework/webhook';
import { createServer } from 'node:http';
createServer(async (req, res) => {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk);
const rawBody = Buffer.concat(chunks);
const { status, body } = await handleWebhookInteractionRequest(
{
signature: req.headers['x-signature-ed25519'] as string,
timestamp: req.headers['x-signature-timestamp'] as string,
rawBody,
headers: req.headers as Record<string, string | undefined>, // optional - enriches the signature-rejection debug log
},
{
publicKey: env.DISCORD_PUBLIC_KEY,
logger,
onInteraction: (interaction) => handleInteraction(interaction), // bot-side: build an APIInteractionResponse
},
);
res.writeHead(status, { 'Content-Type': 'application/json' }).end(JSON.stringify(body));
}).listen(3000);On a signature-verification failure, handleWebhookInteractionRequest logs a
debug-level diagnostic alongside the existing warn - source IP (checking
cf-connecting-ip/x-real-ip/x-forwarded-for in turn, for bots sitting
behind a reverse proxy), user-agent, whether the signature/timestamp headers
were present at all vs. present-but-wrong, and signature/body length. A
public webhook endpoint draws routine internet-scanner noise as well as
genuine misconfiguration; this is what tells the two apart after the fact,
without every consuming bot re-implementing the same wrapper. Pass headers
to populate it - omit it and the IP/user-agent fields just come back
undefined.
If that metadata narrows it down to "looks like genuine Discord traffic, but
still fails verification" (rather than scanner noise), pass
verboseSignatureDiagnostics: true to also log the exact signature/
timestamp header values, a SHA-256 hash of the raw body, and - only if the
body happens to parse as JSON - just its type/id fields:
await handleWebhookInteractionRequest(
{ signature, timestamp, rawBody, headers },
{ publicKey: env.DISCORD_PUBLIC_KEY, logger, onInteraction, verboseSignatureDiagnostics: env.WEBHOOK_VERBOSE_DIAGNOSTICS },
);Off by default, and deliberately stops at those fields - it never logs the parsed interaction body itself (command names, option values, user data), so it's safe to leave on for an investigation without a separate opt-out plan.
One recurring rejection is expected and muted automatically: Discord itself
periodically sends a Ping deliberately signed with an invalid signature
(seemingly to verify endpoints actually reject bad signatures, rather than
trusting anything that looks like a Discord request) -
isDiscordSignatureConformanceCheck recognizes it by shape (its type and
Discord's own official system user, not a fixed size) and
handleWebhookInteractionRequest mutes just its warn/debug logs for that
one request; the rejection itself is unchanged either way. On by default -
pass muteKnownConformanceCheckLogs: false to always log every rejection, or
applicationId: env.DISCORD_CLIENT_ID to narrow the match further:
await handleWebhookInteractionRequest(
{ signature, timestamp, rawBody, headers },
{ publicKey: env.DISCORD_PUBLIC_KEY, logger, onInteraction, applicationId: env.DISCORD_CLIENT_ID },
);createWebhookInteractionResponder gives you the REST calls needed after
that initial response — editing a deferred reply, sending a follow-up, or
deleting the reply — built on @discordjs/rest (already a peer dependency):
import { createWebhookInteractionResponder } from '@went.tf/discord-bot-framework/webhook';
const responder = createWebhookInteractionResponder({ rest, applicationId, interactionToken: interaction.token });
await responder.editReply({ content: 'Done!' });Running existing gateway-shaped command handlers unmodified:
createWebhookOnlyClient builds a real discord.js Client that's fully
authenticated for REST but never opens a gateway connection (Client#login()
always does, with no way to opt out, so this sets client.rest's token
directly instead - both fully public API). interactionFromWebhookPayload
then reconstructs a genuine discord.js Interaction instance from the raw
payload, the same way discord.js's own gateway path does internally - so
.reply()/.deferReply()/.editReply()/.options.getString() etc. all work
exactly as they do today, and your existing BotChatInputCommand/
dispatchChatInputCommand/createInteractionRouter code needs zero changes:
import { createWebhookOnlyClient, handleWebhookInteractionRequest, interactionFromWebhookPayload } from '@went.tf/discord-bot-framework/webhook';
import { dispatchChatInputCommand } from '@went.tf/discord-bot-framework/interactions';
const client = createWebhookOnlyClient({ token: env.DISCORD_BOT_TOKEN, applicationId: env.DISCORD_CLIENT_ID });
// inside your HTTP handler, in place of the plain onInteraction above:
onInteraction: async (data) => {
const interaction = interactionFromWebhookPayload(client, data);
if (interaction.isChatInputCommand()) {
await dispatchChatInputCommand(interaction, context, { commands: registry.byName, onError });
return; // .reply()/.deferReply() already sent the real response via REST
}
// ...similarly for dispatchComponent/dispatchModal/dispatchAutocomplete/dispatchContextMenu
},Two real gaps versus a gateway-delivered interaction, both from there being no
populated gateway cache: .guild is always null, and .channel is null
unless you pre-cache the interaction's inline partial channel data yourself.
.member degrades gracefully instead (falls back to the raw
APIInteractionGuildMember object), so plain property reads keep working.
client.user would be null too (no gateway READY), which discord.js
dereferences while building any message carrying one of the bot's own
reactions - e.g. the target of a message context menu command - so pass
applicationId and it's set to a minimal ClientUser (just its id; tag,
username, ... stay unpopulated).
See interactionFromWebhookPayload's doc comment for the full detail -
runtime-verified in interaction-from-webhook-payload.test.ts (including the
two discord.js interaction classes with private constructors,
ButtonInteraction/ModalSubmitInteraction, which still construct correctly
through this bridge).
The REST-callback-based ack path is confirmed working against live Discord
traffic, not just source-reading: when a handler's .reply()/.deferReply()
call already sent the real response via REST mid-handler,
handleWebhookInteractionRequest sends a bare {} with a 200 as the literal
HTTP response to Discord's original webhook POST if onInteraction returns
nothing - HammerTimeBot confirmed this is accepted end-to-end via a real
/unix slash command run through a registered Interactions Endpoint URL. See
CLAUDE.md's ./webhook design-decision entry for that validation's one open
item (a portal-side double-PING quirk, not blocking).
Live-reloads compiled command/interaction handler implementations during
local development, without restarting the process or re-registering commands
with Discord for every code change. createHandlerWatcher is a small,
dependency-free primitive built on native fs.watch — it only watches paths,
debounces/coalesces filesystem events per file, and invokes your onChange
callback (catching and logging anything it throws so a bad reload never
crashes the bot). It deliberately does not know how to re-import a module or
merge it into a registry, since that depends on each bot's own file layout:
import { createHandlerWatcher } from '@went.tf/discord-bot-framework/dev';
import { pathToFileURL } from 'node:url';
import { basename, extname } from 'node:path';
if (env.DEV_WATCH) {
const watcher = createHandlerWatcher({
paths: ['./build/commands'],
logger,
onChange: async (filePath) => {
const commandName = basename(filePath, extname(filePath));
if (!chatInputCommandRegistry.isKnown(commandName)) return;
// The `?t=` query busts Node's ESM module cache, which keys on the
// resolved URL — deriving the registry key and writing it back into
// `byName` is bot-side glue, not something this package standardizes.
const fresh = await import(`${pathToFileURL(filePath).href}?t=${Date.now()}`);
chatInputCommandRegistry.byName[commandName] = fresh.default;
logger.log(`Reloaded command handler: ${commandName}`);
},
});
process.on('SIGINT', () => {
watcher.close();
process.exit(0);
});
}This works because dispatch*/createInteractionRouter always read
registry.byName[key] live on every interaction — mutating an entry in place
is picked up on the very next interaction with no other wiring.
Limitations: this only reloads handler implementations already sitting in
a registry's byName. It does not re-run command registration — changing
a command's commands.json entry (name, description, options schema) still
requires re-running buildApplicationCommandsBody + createCommandRegistrar
and a full process restart, and a brand-new command file that wasn't in the
registry at startup isn't picked up without one either. It also assumes a parallel tsc --watch (or equivalent) process
is running, since this package has no bundler and watches compiled build/
output, not src/. Gate it behind your own dev-only flag (e.g. a DEV_WATCH
env var via boolFromString()) — this package intentionally has no built-in
concept of a dev/prod mode.
If your onChange callback re-imports only the one file that changed (as
in the example above), it correctly picks up edits to a command file itself,
but not edits to a shared module that command statically imports — a
modal handler, a util, anything under a second file. Node's ESM cache keys on
resolved URL: giving the changed file a fresh cache-busted URL doesn't affect
how its own import './some-util.js' statement resolves, so that nested
import still returns the stale cached instance. Reimporting one small
aggregator module that pulls in your whole command/component tree (its
registry.byName values in particular) instead of one file at a time avoids
this — see createSourceReloader below.
Re-imports a module — and everything it transitively imports from under a
given root directory — as brand-new instances on every call, without
restarting the process. Unlike the single-file ?t= trick above, this
correctly picks up changes to any file in the reloaded subtree, not just
the one directly re-imported, by tagging every module resolved under
rootDir with a shared epoch via a module.register() hook, and bumping
that epoch before each reimport():
import { createHandlerWatcher, createSourceReloader } from '@went.tf/discord-bot-framework/dev';
import { join } from 'node:path';
if (env.DEV_WATCH) {
const reloader = createSourceReloader({ rootDir: currentFolder, logger });
const interactionsPath = join(currentFolder, 'utils', 'interactions.ts');
const watcher = createHandlerWatcher({
paths: [join(currentFolder, 'commands'), join(currentFolder, 'components'), join(currentFolder, 'utils')],
filter: filePath => filePath.endsWith('.ts'),
logger,
onChange: async () => {
const fresh = await reloader.reimport(interactionsPath);
// `registry.byName` is what dispatch reads live — merge into the existing
// registry object in place; the binding in the module that declared it
// (and everything that imported it) can't be swapped from out here.
Object.assign(chatInputCommandRegistry.byName, fresh.chatInputCommandRegistry.byName);
Object.assign(componentRegistry.byName, fresh.componentRegistry.byName);
},
});
process.on('SIGINT', () => watcher.close());
}Anything resolved outside rootDir — node_modules, this framework,
compiled output elsewhere — is left completely alone, on Node's normal
module cache. That's the property that makes this safe to use for a Discord
bot: as long as your gateway client and DB pool are created (and imported
from) outside rootDir — true for the shard-script shape shown under
createShardManager below, where the client is built directly in bot.ts
and never re-imported by the reloaded interactions.ts subtree — a reload
never reconnects the client or reopens the pool. Reloading a module with
top-level side effects (one that opens a connection, starts a timer) will
duplicate those side effects on every call; keep whatever you reload
side-effect-free (a thin aggregator of plain object exports, like
interactions.ts above).
reimport()'s epoch tag lives on a SharedArrayBuffer, so it needs
--allow-worker under Node's permission model, same as module.register()
itself.
Combining with createShardManager: the example above assumes the
process calling createHandlerWatcher is also the one holding the
registries — true for createBotClient bots, and true for a
createShardManager bot's shard process (the file at botScriptPath),
not the top-level process that calls createShardManager itself. Put the
if (env.DEV_WATCH) { ... } block in the shard script, after the client is
created, not in the file that spawns the ShardingManager.
If you skip tsc --watch and instead run the shard script directly from
source (tsx/ts-node/similar) to avoid a separate compile step, two things
that are easy to get wrong:
-
botScriptPathmust point at the actual file being executed (e.g.bot.ts), not abuild/-compiled path that was never written. -
discord.js's
ShardingManagerdoes not inherit the parent process's CLI flags for spawned shards — both'process'(child_process.fork) and'worker'(worker_threads.Worker) modes are given an explicitexecArgv: []unless you pass your ownexecArgvtocreateShardManager. If the parent process is only able to run TypeScript because of loader flags injected by a tool liketsx(visible inprocess.execArgv), those flags are silently dropped for every shard unless you forward them yourself — the shard process/thread then fails to load a.tsentry file at all. Forward them explicitly:const isTsDevMode = process.env.npm_lifecycle_script?.includes('.ts') ?? false; await createShardManager({ token, logger, botScriptPath: `${currentFolder}/bot.${isTsDevMode ? 'ts' : 'js'}`, mode: isTsDevMode ? 'worker' : 'process', execArgv: isTsDevMode ? process.execArgv : undefined, beforeSpawn: () => startupCommandsUpdate(logger), });
This has no effect on watched paths:
createHandlerWatcher'sfilteroption still needs updating to match.tsinstead of the default.js/.mjs/.cjs, since there's nobuild/output to watch in this mode.
runAttempts, getGitData, queueLazyPromises, condenseStringArray,
sendMessageSlices, loadAllMessages, getUserIdentifier,
stringifyChannelName, stringifyOptionsData, and generic guild/member/role/
channel lookups (getServer, findServerTextChannelByName,
findServerRoleByName, findServerMember, getServerMemberRole,
serverMemberHasRole, isSameObject).
Requires @prisma/client and @prisma/adapter-pg (Postgres only).
import { createPostgresPrismaDb } from '@went.tf/discord-bot-framework/db';
import { PrismaClient } from './generated/prisma/client.js';
export const db = createPostgresPrismaDb(PrismaClient, { connectionString: env.DATABASE_URL });Bots that only talk to an externally-managed database (or no database at all) never need to import this subpath or install its peer dependencies.
Requires i18next and i18next-fs-backend.
import { createI18nInitializer } from '@went.tf/discord-bot-framework/i18n';
const initI18next = createI18nInitializer({
localesDir: './src/locales',
supportedLngs: SUPPORTED_LANGUAGES,
fallbackLng: DEFAULT_LANGUAGE,
debug: env.DEBUG_I18N,
});
const i18nextInstance = await initI18next(logger);Locale file content, translation-credit generation, and any custom eslint i18n-key-validation rules stay entirely bot-side.
createCommandLocalizer also lives here — see "Localizing command
names/descriptions" under ./commands above.
pnpm install
pnpm test
pnpm run lint
pnpm run build