Skip to content

Repository files navigation

@went.tf/discord-bot-framework

npm version

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.

Install

pnpm add @went.tf/discord-bot-framework zod discord.js @discordjs/rest discord-api-types

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

Subpaths

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.

@went.tf/discord-bot-framework/logger

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'
  },
});

@went.tf/discord-bot-framework/env

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

@went.tf/discord-bot-framework/api-client

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
});

@went.tf/discord-bot-framework/interactions

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.

@went.tf/discord-bot-framework/commands

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.

  1. 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 makes commands.json editable with real autocomplete/validation in VS Code, JetBrains, or any other JSON-Schema-aware editor: $schema is only ever valid on a JSON object, and a bare array can never carry one. A bare array (just the commands value on its own, no wrapper) is still fully supported — nothing about the wrapper is required — but you lose the inline $schema editor hookup if you use it. buildApplicationCommandsBody accepts either shape directly.

    type (and contexts/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 that 3 means STRING. Both forms, and any mix of the two, are always valid; buildApplicationCommandsBody resolves 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's enum-maps.ts (sourced from discord-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.

  2. 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 });
  3. Build the Discord-ready body and register it — unchanged from before, just fed by commandsFile + your handler registries instead of getDefinition():

    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.

JSON Schema fragments

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.

Localizing command names/descriptions

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.

@went.tf/discord-bot-framework/client

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.

@went.tf/discord-bot-framework/webhook

Validated against a real bot. HammerTimeBot ran this against a real registered Discord Interactions Endpoint URL (portal PING validation, then a live /unix slash command end-to-end) on its migrate-discord-bot-framework branch — signature verification, PING/PONG, the discord.js-Interaction bridge, 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 ./webhook design-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).

@went.tf/discord-bot-framework/dev

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.

createSourceReloader

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:

  • botScriptPath must point at the actual file being executed (e.g. bot.ts), not a build/-compiled path that was never written.

  • discord.js's ShardingManager does 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 explicit execArgv: [] unless you pass your own execArgv to createShardManager. If the parent process is only able to run TypeScript because of loader flags injected by a tool like tsx (visible in process.execArgv), those flags are silently dropped for every shard unless you forward them yourself — the shard process/thread then fails to load a .ts entry 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's filter option still needs updating to match .ts instead of the default .js/.mjs/.cjs, since there's no build/ output to watch in this mode.

@went.tf/discord-bot-framework/utils

runAttempts, getGitData, queueLazyPromises, condenseStringArray, sendMessageSlices, loadAllMessages, getUserIdentifier, stringifyChannelName, stringifyOptionsData, and generic guild/member/role/ channel lookups (getServer, findServerTextChannelByName, findServerRoleByName, findServerMember, getServerMemberRole, serverMemberHasRole, isSameObject).

@went.tf/discord-bot-framework/db (optional)

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.

@went.tf/discord-bot-framework/i18n (optional)

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.

Development

pnpm install
pnpm test
pnpm run lint
pnpm run build

About

Shared building blocks for discord.js-based Discord bots

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Contributors

Languages