Skip to content

Command System

TomoriBot uses Discord slash commands loaded dynamically from src/commands/.

  • Registration/building: src/utils/discord/commandLoader.ts
  • Runtime dispatch: src/events/interactionCreate/handleCommands.ts

Flow:

  1. loadCommandData() scans command folders and top-level command files.
  2. Command metadata is built into SlashCommandBuilder trees.
  3. handleCommands.ts resolves a root command, or category + group + subcommand.
  4. Category cooldown is checked/set in cooldowns table (COMMAND_CATEGORY).
  5. Target execute() is called with (client, interaction, userData, locale).

commandLoader.ts is an ESM-only loader. It uses async directory reads while building slash-command registration data and dynamically imports command modules so command files can use top-level await. Do not add require, module.exports, or synchronous directory traversal to command discovery.

loadCommandData() is called from two places: the startup registration path (clientReady/01_registercommands.ts) and the lazy first-interaction path (interactionCreate/handleCommands.ts). It is memoized behind a single shared promise (cachedCommandDataPromise) so both callers await one evaluation.

This guards against a startup race: if an interaction arrives while registration is still loading, a second concurrent loadCommandData() would independently await import() the same command modules. Because ES module evaluation interleaves across await points, the second loader could read an export binding (e.g. configureSubcommand) while the module was still in its Temporal Dead Zone, throwing Cannot access 'configureSubcommand' before initialization and silently skipping that command — leaving the bot “dead” for those commands until restart.

The memoized promise is not cached when a load fails catastrophically (empty execution map) or rejects, so a later interaction can retry instead of locking in a broken state. handleCommands.ts likewise only commits its module-level maps when the load produced commands. New callers must use the exported loadCommandData(), never the private loadCommandDataUncached().

Import hygiene (keep the loaded graph shallow)

Section titled “Import hygiene (keep the loaded graph shallow)”

The race above is only possible because a command module’s static import graph can be large and cyclic. Most command files import the repositories barrel (@/utils/db/repositories), so any heavy dependency reachable from that barrel is pulled into every command load.

Rule: data-layer modules (repositories, caches) must not import high-level subsystems (context building, tools, webhooks, providers). Import shared leaf constants directly from their owning leaf module, not from a barrel that also re-exports heavy code. Example: ServerRepository.ts imports DEFAULT_SYSTEM_PROMPT from @/utils/text/context/templates (a leaf), not from @/utils/text/contextBuilder (a barrel that also re-exports buildContext and its tool/webhook/provider graph). That single edge previously routed the entire runtime subsystem into the repositories barrel.

Run bunx madge --circular --extensions ts --ts-config tsconfig.json src to audit cycles. Remaining cycles are expected to be either type-only (import type, erased at runtime), localized repository↔cache↔barrel cycles, or self-contained subsystem-internal cycles (Matrix bridge, chat pipeline) — none should route the repositories barrel into context/tool/webhook code.

Command files import Discord UI helpers from responsibility-owned modules:

  • src/utils/discord/ui/confirmation.ts - confirmation prompts
  • src/utils/discord/ui/modals.ts - raw, legacy, and paginated modal prompts
  • src/utils/discord/ui/embeds.ts - info and summary embed replies
  • src/utils/discord/ui/statusComponents.ts - Components V2 status replies and status-page pagination
  • src/utils/discord/ui/pagination.ts - generic choice pagination
  • src/utils/discord/ui/personaWorkflow.ts - command-facing persona picker lifecycle, acknowledgment phases, and anchor-message controller

The low-level persona renderer remains private to interactionCore.ts and personaWorkflow.ts; it has no command-facing barrel or compatibility export.

src/utils/discord/interactionHelper.ts remains only as the subsystem compatibility barrel. New command code should import from the owned module that matches the helper it uses.

Command, event, tool, and stream code import webhook helpers from responsibility-owned modules:

  • src/utils/discord/webhook/lifecycle.ts - shared/persona webhook creation, lookup, deletion, and avatar updates
  • src/utils/discord/webhook/personaDispatch.ts - persona/webhook send paths
  • src/utils/discord/webhook/identity.ts - persona avatar and webhook identity resolution
  • src/utils/discord/webhook/fallback.ts - managed-webhook restore and transcript fallback behavior
  • src/utils/discord/webhook/cache.ts - webhook cache metrics and invalidation helpers

src/utils/discord/webhookManager.ts remains only as the subsystem compatibility barrel. New code should import from the owned webhook module directly.

Keep webhook cache invalidation in the same success path as the write or delete that changes webhook state.

Subcommand modules export:

  • configureSubcommand(subcommand)
  • execute(client, interaction, userData, locale)

Root command modules export:

  • configureCommand(command)
  • execute(client, interaction, userData, locale)

Grouped commands are represented by folders:

  • src/commands/model/text.ts -> /model text

Model and provider flows that still call promptForSavedProvider() use one shared initial provider-selection embed. Model-selection callers pass the effective slot selection so the embed can show the active model codename and provider; channel and persona text commands resolve their scoped override before falling back to the server text model.

The /model * and /personal provider model-* families no longer use that primitive. They render the provider picker, the >25 range selector, the modal, and the terminal result on one anchor ephemeral message through the shared helpers in src/utils/discord/ui/anchorModelFlow.ts (see the anchor message controller section below). promptForSavedProvider() is forbidden in those files, and the allow-list audit in tests/unit/commands/anchorMigrationLockdown.test.ts enforces it.

Root commands are represented by top-level command files:

  • src/commands/subscribe.ts -> /subscribe

Root command files may also export optional command-level flags:

  • guildOnly = true - restricts the command to guilds
  • managerOnly = true - requires ManageGuild
  • nsfw = true - marks the command as age-restricted
  • isCommandEnabled(context) - returns false to skip command registration and execution-map wiring for this module

Use isCommandEnabled for commands that are present in source but should be absent unless a feature gate is active. The loader evaluates the gate after importing the module but before calling configureCommand() or configureSubcommand(). If every subcommand in a category is disabled, the top-level category command is omitted from registration.

Example:

export const isCommandEnabled = () =>
process.env.RUN_ENV === "production" &&
process.env.TOMORI_SUPPORTER_BILLING_ENABLED === "true";

Command modules must not perform production-only side effects at import time. Keep feature-gated initialization in startup hooks or inside the gated runtime handler.

  • bot
  • capabilities
  • conditioning
  • config
  • contribute
  • donate
  • generate
  • help
  • legal
  • mcp
  • memory
  • model
  • novelai
  • nsfw
  • openrouter
  • optional-key
  • persona
  • personal
  • provider
  • scheduled-task
  • server
  • speech
  • st-preset
  • stats
  • support
  • tool

Defined in commandLoader.ts:

  • Guild-only categories: server, conditioning, stats
  • Manage Guild required by default: config, server

Localization Strategy for Command Metadata

Section titled “Localization Strategy for Command Metadata”

Do not hardcode descriptions/choice names. Use localizer("en-US", key) in command builders.

commandLoader.ts then auto-applies locale localizations for other loaded locales.

Key pattern:

  • Root command description: commands.{command}.description
  • Root command option description: commands.{command}.{option}_description
  • Root command choice name: commands.{command}.{option}_choice_{value}
  • Subcommand description: commands.{category}.{path}.description
  • Option description: commands.{category}.{path}.{option}_description
  • Choice name: commands.{category}.{path}.{option}_choice_{value}

Example path:

  • file: src/commands/memory/personal/remove.ts
  • command path: memory.personal.remove

Root command example:

  • file: src/commands/subscribe.ts
  • command path: subscribe

Discord requires interaction acknowledgement within ~3 seconds.

On slash command invoke, acknowledge within 3 seconds using one of:

  • interaction.reply(...)
  • interaction.deferReply(...)
  • interaction.showModal(...) (or modal helper that sends modal response)

After acknowledgement, you have up to ~15 minutes to complete.

Use when work is synchronous/very fast and has no DB/API/file latency before response.

export async function execute(...) {
if (!interaction.guild) {
await replyInfoEmbed(...);
return;
}
await interaction.reply({ content: "..." });
}

Rules:

  • no DB query before first reply
  • no external API call before first reply
  • no filesystem work before first reply

Use when any meaningful async work happens before first response.

export async function execute(...) {
if (!interaction.guild) {
await replyInfoEmbed(...);
return;
}
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const state = await getCachedTomoriState(interaction.guild.id);
await sql`UPDATE ...`;
await replyInfoEmbed(interaction, locale, { ... });
}

Rules:

  • run fast validation first
  • then defer before DB/API work
  • do not defer at function start if early-return validation can finish immediately
  • /tool estimate cost follows this pattern: it defers first, then fetches recent messages, prepares participants through the shared context API, builds context, and performs provider API token counting

Pattern 3: Modal Command (No Initial Deferral)

Section titled “Pattern 3: Modal Command (No Initial Deferral)”

Use when opening a modal for user input.

const modalResult = await promptWithRawModal(
interaction,
locale,
{ ... },
MessageFlags.Ephemeral, // auto-defer modal submission
);
if (modalResult.outcome !== "submit") return;
await sql`UPDATE ...`;
await replyInfoEmbed(modalResult.interaction, locale, { ... });

Rules:

  • do not call deferReply() before showing modal
  • pre-modal data loading must stay within the initial 3-second window
  • if modal submit processing is async and the command will reply on the modal submission itself, pass MessageFlags.Ephemeral as promptWithRawModal arg 4

Pattern 3A: Bulk Management Modal (Checkbox Groups)

Section titled “Pattern 3A: Bulk Management Modal (Checkbox Groups)”

Use when the user is managing an existing set of configured entries and batch keep/remove is better UX than a one-at-a-time select.

Examples:

  • /server whitelist remove
  • /config remove modeloverride (channels + personas together)
  • /config workarounds (experimental server-scoped workaround toggles)
  • /server stm manage (active server-shared STM entries)
  • /server private-channels
  • /server rp-channels

Rules:

  • use promptWithRawModal(...) with checkbox groups and MessageFlags.Ephemeral auto-defer on submit
  • pre-check every existing entry; unchecked means “remove” or “disable”
  • set minValues: 0 and required: false so users can uncheck everything
  • chunk long lists into groups of 10 options; Discord allows at most 5 groups per modal (50 total options)
  • if you are managing multiple entity types in one modal, keep them in separate checkbox groups by type
  • if the total set exceeds 50 options, show a page-selection step and launch page-scoped checkbox modals
  • after submit, diff original entries against submitted checked values, then invalidate caches only after successful DB writes

Use when one command owns the full enabled-set of a durable setting rather than an add/remove delta flow.

Example:

  • /server crosschannel-blocklist
  • /server whitelist persona (after the persona picker, the command owns that persona’s full enabled channel set)

Rules:

  • checked means “enabled in the stored set”; unchecked means “disabled from the stored set”
  • reopening the command must preload the current saved state
  • submit writes the full selected set back to storage, not just the latest delta intent
  • if the eligible option set exceeds one modal (>50), show a page-selection message first and launch page-scoped checkbox modals from there
  • durable server-scoped settings added through this pattern should also be surfaced in /tool status
  • keep status-command.md in sync when /tool status coverage changes

Pattern 3C: Modal -> Review Prompt -> Modal

Section titled “Pattern 3C: Modal -> Review Prompt -> Modal”

Use when a command needs one modal to collect a bulk selection, then a follow-up confirmation or button choice before optionally opening a second modal.

Example:

  • /personal spotlight set

Rules:

  • do not auto-defer the first modal submit if you still need to reply with buttons from that modal interaction
  • reply to the first modal submit with a review embed + buttons
  • if the user picks the branch that needs more input, open the second modal from the unacknowledged button interaction
  • after the second modal submit, silently acknowledge with acknowledgeModalSubmitForRefresh(...) when you intend to edit the original review reply instead of responding on the second modal itself
  • only persist the final DB write after the last user decision is known, then invalidate caches in that same success path

Pattern 4: Pagination Helpers (No Pre-Defer)

Section titled “Pattern 4: Pagination Helpers (No Pre-Defer)”

Use when calling replyPaginatedChoices(...) or promptWithPaginatedModal(...).

const result = await replyPaginatedChoices(interaction, locale, { ... });
const modalResult = await promptWithPaginatedModal(interaction, locale, { ... });
if (modalResult.outcome !== "submit") return;
// If submission handling is heavy, defer the modal submission immediately.
await modalResult.interaction.deferReply({ flags: MessageFlags.Ephemeral });
await sql`UPDATE ...`;
await replyInfoEmbed(modalResult.interaction, locale, { ... });

Rules:

  • do not defer before replyPaginatedChoices(...) or promptWithPaginatedModal(...) (they acknowledge directly)
  • keep pre-helper work under 3 seconds
  • promptWithPaginatedModal(...) does not expose an auto-defer parameter; defer on submission manually when needed
  • commands that begin with a persona picker use Pattern 4A; the workflow owns picker acknowledgment and retries

>25-option selector style (pre-anchor). This applies to callers still on promptWithPaginatedModal(...). Commands migrated to the anchor message workflow (Pattern 4A/4B) never set selectorStyle: their >25 handling is chosen for them by the engine’s range-selector bridge, which always renders the Components V2 selector.

promptWithPaginatedModal(...) accepts an optional selectorStyle: "legacy" | "componentsV2" (default "legacy"). At <=25 options both styles open a modal directly, so this only affects the paginated path:

  • "legacy" — numbered page-button embed on the interaction’s reply (1 2 3, capped at 9 pages).
  • "componentsV2" — the shared Components V2 range selector (1-25 / 26-50 ranges + Previous/Cancel/Next), byte-identical to the persona workflow’s >25 shell (buildRangeSelectorPayload). Its Cancel button returns outcome: "cancelled" (the legacy selector has no Cancel and never returns it); callers gating on outcome !== "submit" already handle it.

The V2 selector renders IsComponentsV2 onto the interaction’s reply, which Discord then forbids editing with legacy embeds. The selector marks the interaction, and the shared sinks (replyInfoEmbed/replySummaryEmbed/replyPaginatedStatusPages) detect the mark and emit a V2 notice container instead of embeds — so a later error/info reply to the same interaction cannot collide. Before opting a caller into "componentsV2", confirm the interaction reaching the helper is unacknowledged (fresh-reply path) rather than a deferred/replied legacy message, since Discord cannot convert a legacy reply to V2 via editReply.

Pattern 4A: Anchor Message Workflow (persona picker)

Section titled “Pattern 4A: Anchor Message Workflow (persona picker)”

The anchor message workflow is the engine behind Patterns 4A and 4B. Its rule: one command invocation owns exactly one ephemeral message, edited in place through every stage — picker, >25 range selector, modal, progress, and terminal result. Opening a modal is an acknowledgment, not a second message.

This exists because Discord emits no event when a user dismisses a modal. A flow that opens a modal and leaves its picker message behind therefore strands dead-but-clickable buttons (“This interaction failed”) until the modal’s timeout. Rendering everything on one message makes that orphan impossible by construction, and collapse-at-open swaps the live controls for an inert notice the instant the modal opens.

Two specializations share the engine:

  • Pattern 4A (below) — the persona picker, via runPersonaPickerWorkflow.
  • Pattern 4B — one-shot picker → modal config commands, via beginAnchorPrivateWorkflow plus the shared helpers in anchorModelFlow.ts.

Non-persona callers import the engine from src/utils/discord/ui/anchorWorkflow.ts, which also exports neutral Anchor* aliases for the generic types. The implementation itself lives in personaWorkflow.ts, alongside the persona specialization it shares its internals with.

Commands that begin with a persona picker use the single command-facing entry point in src/utils/discord/ui/personaWorkflow.ts:

const result = await runPersonaPickerWorkflow(interaction, locale, {
personas,
onSelected: async (selection) => {
// Perform one acknowledged workflow transaction.
return completePersonaWorkflow();
},
});

The options are personas, optional picker titleKey, descriptionKey, and color, optional requiredPersonaId, optional asynchronous onCancel, and required asynchronous onSelected. The selection phase exposes persona, absoluteIndex, phaseId, deliveryPolicy: "replace-picker", the message controller, and the typed phase methods shown below. A successful result has outcome: "selected" plus the selected persona, absolute index, and the value passed to completePersonaWorkflow(value). Error and fatal results may also include the causal error.

runPersonaPickerWorkflow(...) owns the low-level picker, its retry loop, and one AvatarSessionCache for the complete invocation. Callers return completePersonaWorkflow(value) or retryPersonaWorkflow(updatedPersonas?); they do not write their own outer picker loop. Picker outcomes remain discriminated as selected, cancelled, timeout, empty, error, and fatal. A fatal picker result exits before onSelected runs, so it cannot enter the retry path. The empty outcome (see the eligibility section) is a terminal state distinct from all others and is never retried.

Classify collector expiry with isCollectorTimeoutError(error) from interactionCore — never with a bare error === "time" check. discord.js uses two rejection shapes for the same event: raw collectors reject with the end-reason string ("time" / "idle"), while Message#awaitMessageComponent and awaitModalSubmit reject with an InteractionCollectorError whose message carries the reason ("...ending with reason: time"). Missing the second shape makes an ordinary user timeout present as fatal with the generic unknown-error copy instead of the timeout notice. Other end reasons (limit, messageDelete) and dead-token errors are genuine failures and must stay classified as error/fatal.

Eligibility filtering (item-scoped remove / edit / view)

Section titled “Eligibility filtering (item-scoped remove / edit / view)”

Item-scoped commands should only offer personas they can actually act on. Supplying an optional eligibility object to runPersonaPickerWorkflow(...) makes the picker show only qualifying personas, disclose that it is filtered, and reach a terminal empty outcome instead of ever rendering a zero-persona picker.

export interface PersonaWorkflowEligibility<TPersona extends TomoriState> {
isEligible: (persona: TPersona) => boolean; // synchronous — never per-persona queries
emptyTitleKey: string; // terminal state when no persona qualifies
emptyDescriptionKey: string;
itemsLabelKey: string; // bare item noun, interpolated into the shared filtered notice
}

Rules:

  • Filtering is a UX layer, never the correctness layer. Every migrated command keeps its existing post-selection emptiness guard as a concurrency backstop; the guard and the filter must call the same predicate so they can never disagree. Shared predicates live in src/utils/discord/ui/personaEligibility.ts.
  • Filter only remove / edit / view verbs. add / set / assign must always list every persona and must not receive an eligibility object.
  • isEligible is synchronous. Class B commands resolve one batched query per invocation into a Set of eligible keys and close over it (personaIdIsEligible(set) / lineageIdIsEligible(set)); they never issue a query per persona.
  • Refresh the set for mid-loop drains. When a retry loop deletes items, refresh the closed-over set in place with refreshEligibilitySet(set, freshSet) after each successful write so a persona whose last item was removed drops out on the next retry and the last such removal reaches the empty terminal state on the anchor message.
  • The caller renders its own pre-picker empty notice on its deferred reply (it already computes the eligible set for its own guard) and returns before calling the workflow. The workflow renders the empty terminal state in place only for the mid-loop case.

Batched availability queries must key on the same column the loader keys on and reproduce every filter the loader applies:

Family Loader Grouping key Extra filters to reproduce
Documents serverMemoryRepository.loadDocuments documents.persona_id server_id; no source_type filter (history docs count too)
History documents serverMemoryRepository.loadHistoryDocuments documents.persona_id server_id and source_type = 'history'
Server memories serverMemoryRepository.loadServerMemoriesScoped server_memories.persona_lineage_id server_id, plus optional user_id (permission-dependent)
Personal memories personalMemoryRepository.loadForUserLineage personal_memories.persona_lineage_id user_id; lineage 0 excluded so a global memory never marks a specific persona eligible
Sprites personaSpriteRepository.listForPersona not persona_id resolves preset pointers first — a pointer persona has zero persona_sprites rows yet still has sprites, so a bare GROUP BY persona_id is wrong; reproduce the numeric sprite_id narrowing

Two traps are worth stating explicitly:

  • Permission-dependent eligibility. /memory server edit, remove, and vectorize scope their loads by hasManagePermission ? undefined : userData.user_id. The batched availability query takes the same optional userId, so a manager and a non-manager can see different eligible sets for the same command in the same guild.
  • Sprite pointer trap. personaSpriteRepository.personaIdsWithSprites(personaIds) resolves pointers in bulk (own rows for materialized personas, shared preset_sprites for live pointer personas); it must not be reduced to a GROUP BY persona_id over persona_sprites.
Class A example (field-backed predicate, no query)
Section titled “Class A example (field-backed predicate, no query)”
import { hasAttributes } from "@/utils/discord/ui/personaEligibility";
const eligible = allPersonas.filter(hasAttributes);
if (eligible.length === 0) {
await replyInfoEmbed(interaction, locale, {
titleKey: "commands.forget.attribute.no_attributes_title",
descriptionKey: "commands.forget.attribute.no_attributes",
color: ColorCode.WARN,
flags: MessageFlags.Ephemeral,
});
return;
}
await runPersonaPickerWorkflow(interaction, locale, {
personas: allPersonas, // full list — the workflow filters for display
eligibility: {
isEligible: hasAttributes,
emptyTitleKey: "commands.forget.attribute.no_attributes_title",
emptyDescriptionKey: "commands.forget.attribute.no_attributes",
itemsLabelKey: "general.persona_workflow.items.attributes",
},
onSelected: async (selection) => {
if (!hasAttributes(selection.persona)) return retryPersonaWorkflow(); // backstop
// ...perform the acknowledged transaction...
return retryPersonaWorkflow(await personaRepository.loadAllForServer(serverDiscId));
},
});
Class B example (batched query + refreshed set)
Section titled “Class B example (batched query + refreshed set)”
import { personaIdIsEligible, refreshEligibilitySet } from "@/utils/discord/ui/personaEligibility";
const eligibleIds = await serverMemoryRepository.personaIdsWithDocuments(serverId);
const isEligible = personaIdIsEligible(eligibleIds);
if (allPersonas.filter(isEligible).length === 0) {
/* render pre-picker empty notice and return */
}
await runPersonaPickerWorkflow(interaction, locale, {
personas: allPersonas,
eligibility: { isEligible, emptyTitleKey, emptyDescriptionKey, itemsLabelKey },
onSelected: async (selection) => {
// ...remove one document (post-selection load stays the backstop)...
await refreshEligibilitySet(eligibleIds, serverMemoryRepository.personaIdsWithDocuments(serverId));
return retryPersonaWorkflow(await personaRepository.loadAllForServer(serverDiscId));
},
});

Every same-visibility workflow owns one anchor ephemeral Components V2 message. Its message ID is exposed as selection.message.anchorMessageId and must remain unchanged through loading, selectors, validation, progress, results, errors, and timeouts. Opening a modal is an interaction acknowledgment, not another message. The only normal visibility change is the typed separate-public phase described below.

Choose the phase operation before doing DB, filesystem, network, image, or embedding work. Each operation below owns the first acknowledgment; do not call a raw interaction method before it.

Phase operation First acknowledgment Use it when
Workflow entry The caller defers the ephemeral slash response before any asynchronous persona/state preload; the internal picker defensively defers if entry is still unacknowledged Starting any persona workflow
selection.openModal(options) with at most 25 select options showModal() on the selected persona button Modal options are already available synchronously
selection.openModal(options) with more than 25 select options update() replaces the picker with range buttons Preloaded modal options exceed Discord’s select limit; the chosen range button later calls showModal()
selection.openModal(async () => options) deferUpdate() on the selected button, then an in-place loading state Modal options require DB or other asynchronous work; a new launcher/range button later opens the modal
selection.beginInPlaceWork() deferUpdate() on the selected persona button Any non-modal asynchronous work
modal.phase.replace(payload) update() on the message-backed modal submission A fast terminal replacement whose payload is already built
modal.phase.beginInPlaceWork() deferUpdate() on the modal submission Processing submitted values with asynchronous work
selection.useButton(button).replace(payload) update() on that nested button A fast private-view transition whose payload is ready
selection.useButton(button).beginInPlaceWork() deferUpdate() on that nested button A nested view action needs asynchronous work
selection.useButton(button).openModal(...) The same direct/factory modal rules above A private view button opens a modal
selection.useButton(button).delete() deferUpdate(), then anchor-message deletion Closing a private view
selection.beginSeparatePublicReply(compactPayload) update() compacts the private picker; publicPhase.reply() then sends one public follow-up The result is intentionally public

Calling two first-ack operations for the same interaction throws a PersonaWorkflowUpdateError with code already-acknowledged. Raw REST modal state is tracked separately from discord.js replied/deferred, so a raw modal acknowledgment is not repeated accidentally.

Persona arrays are loaded before the workflow entry today, so acknowledge the slash command with deferReply({ flags: MessageFlags.Ephemeral }) before that asynchronous load. If the command also has a root-modal scope, resolve the scope synchronously and defer only the persona branch; a deferred root interaction cannot open its own modal. The workflow reuses the deferred anchor response and still owns every component acknowledgment after the picker renders.

The bare message controller edits the root reply; it does not acknowledge the currently pending button or modal submission. Do not call selection.message.replace(...) before choosing beginInPlaceWork(), openModal(...), or beginSeparatePublicReply(...), and do not mutate modal.phase.message before choosing modal.phase.replace(...) or modal.phase.beginInPlaceWork(). Nested buttons use the operations returned by selection.useButton(button) for the same reason.

openModal(...) returns submitted, cancelled, timeout, error, or fatal. A submitted phase exposes single values in values, string-select/checkbox values in multiValues, uploaded files in attachments, the range’s optionOffset, the message controller, direct replace(payload), beginInPlaceWork(), and the narrowly scoped unsafeInteraction() escape hatch. Check the outcome before accessing the phase.

selection.message, in-place phases, and modal phases expose the same typed controller:

  • replace(payload) edits the anchor message and clears old attachments unless the payload explicitly supplies attachments;
  • edit(payload) edits while retaining existing attachments when attachments is omitted;
  • fetchMessage() returns the anchor Message and verifies its ID;
  • disableControls() keeps the current view readable and disables every interactive component;
  • delete() deletes the anchor reply and makes later operations fail with deleted.

Both replace and edit accept only PersonaWorkflowComponentsV2Payload: components and MessageFlags.IsComponentsV2 are required, while content and embeds are forbidden at both the TypeScript and runtime boundaries. Message mismatch, missing message backing, expired tokens, and Discord edit failures are typed errors. The controller never converts an in-place failure into another ephemeral reply, followUp, or webhook.send.

The examples below show command-body code. Substitute the repository method and locale keys for the command being migrated; keep the workflow calls and acknowledgment ordering intact.

import {
buildPersonaWorkflowNotice,
completePersonaWorkflow,
retryPersonaWorkflow,
runPersonaPickerWorkflow,
} from "@/utils/discord/ui/personaWorkflow";
await runPersonaPickerWorkflow(interaction, locale, {
personas,
onSelected: async (selection) => {
const work = await selection.beginInPlaceWork();
await work.message.replace(
buildPersonaWorkflowNotice({
locale,
color: ColorCode.INFO,
titleKey: "general.persona_workflow.loading_title",
descriptionKey: "general.persona_workflow.loading_description",
}),
);
const removed = await personaRepository.removePrompt(selection.persona.persona_id);
if (!removed) {
await work.message.replace(
buildPersonaWorkflowNotice({
locale,
color: ColorCode.ERROR,
titleKey: "general.errors.update_failed_title",
descriptionKey: "general.errors.update_failed_description",
}),
);
return completePersonaWorkflow();
}
invalidateTomoriStateCache(interaction.guildId ?? interaction.user.id);
await work.message.replace(
buildPersonaWorkflowNotice({
locale,
color: ColorCode.SUCCESS,
titleKey: "commands.forget.personaprompt.success_title",
descriptionKey: "commands.forget.personaprompt.success_description",
descriptionVars: { persona_name: selection.persona.persona_nickname },
}),
);
return completePersonaWorkflow();
},
});
await runPersonaPickerWorkflow(interaction, locale, {
personas,
onSelected: async (selection) => {
const modal = await selection.openModal(async () => {
// The selected button is already update-deferred before this DB read.
const memories = await personalMemoryRepository.loadForUserLineage(
userData.user_id,
selection.persona.persona_lineage_id ?? 0,
false,
);
return {
modalCustomId: "memory_edit_select",
modalTitleKey: "commands.memory.personal.edit.select_modal_title",
components: [
{
customId: "memory_select",
labelKey: "commands.memory.personal.edit.select_label",
required: true,
options: memories.map((memory) => ({
label: memory.content,
value: String(memory.personal_memory_id),
})),
},
],
};
});
if (modal.outcome === "fatal") throw modal.error;
if (modal.outcome !== "submitted") return retryPersonaWorkflow();
const work = await modal.phase.beginInPlaceWork();
await work.message.replace(
buildPersonaWorkflowNotice({
locale,
color: ColorCode.INFO,
titleKey: "general.persona_workflow.loading_title",
descriptionKey: "general.persona_workflow.loading_description",
}),
);
const memoryId = Number.parseInt(modal.phase.values.memory_select ?? "", 10);
const updated = await personalMemoryRepository.edit(memoryId, "replacement text", []);
await work.message.replace(
buildPersonaWorkflowNotice({
locale,
color: updated ? ColorCode.SUCCESS : ColorCode.ERROR,
titleKey: updated
? "commands.memory.personal.edit.success_title"
: "general.errors.update_failed_title",
descriptionKey: updated
? "commands.memory.personal.edit.success_description"
: "general.errors.update_failed_description",
}),
);
return retryPersonaWorkflow();
},
});

When a modal select contains more than 25 options, openModal replaces the anchor message with localized range buttons and opens the modal from the chosen range button. modal.phase.optionOffset is the absolute offset of that slice for callers whose option values are page-local indexes.

import { PERSONA_WORKFLOW_COMPONENT_TIMEOUT_MS } from "@/utils/discord/ui/personaWorkflow";
await runPersonaPickerWorkflow(interaction, locale, {
personas,
onSelected: async (selection) => {
let page = 0;
const work = await selection.beginInPlaceWork();
await work.message.replace(renderPrivatePage(locale, page));
const message = await work.message.fetchMessage();
try {
while (true) {
const button = await message.awaitMessageComponent({
componentType: ComponentType.Button,
filter: (candidate) => candidate.user.id === interaction.user.id,
time: PERSONA_WORKFLOW_COMPONENT_TIMEOUT_MS,
});
const action = selection.useButton(button);
if (button.customId === "view_close") {
await action.delete();
return completePersonaWorkflow();
}
page += button.customId === "view_next" ? 1 : -1;
await action.replace(renderPrivatePage(locale, page));
}
} catch {
await work.message.disableControls();
return completePersonaWorkflow();
}
},
});

Declare renderPrivatePage to return PersonaWorkflowComponentsV2Payload; this makes a legacy content or embeds field a compile-time error. For navigation that must load data, call action.beginInPlaceWork() before the load and then replace through its controller. The example reuses the documented workflow timeout instead of hardcoding a separate collector lifetime.

await runPersonaPickerWorkflow(interaction, locale, {
personas,
onSelected: async (selection) => {
const publicPhase = await selection.beginSeparatePublicReply(
buildPersonaWorkflowNotice({
locale,
color: ColorCode.SUCCESS,
titleKey: "commands.stats.persona.chosen_title",
titleVars: { name: selection.persona.persona_nickname },
}),
);
await publicPhase.reply({
content: localizer(locale, "commands.stats.persona.picker_description"),
allowedMentions: { parse: [] },
});
return completePersonaWorkflow();
},
});

publicPhase.reply() rejects ephemeral flags and a second call. The compact private picker and the one public response are the two messages only because visibility changed explicitly.

Command and feature code must not import or invoke replyPaginatedPersonaChoicesV2, set preserveSelectedInteraction: true, provide an empty picker onSelect, or introduce a competing persona-selection helper such as selectConditioningPersona. The tests/unit/checks/personaWorkflowBoundary.test.ts audit enforces this across src/.

If Discord exposes an operation that the typed phases cannot represent, use selection.unsafeInteractions() only for that operation and keep all message mutations on selection.message. If direct low-level picker access is genuinely unavoidable, the change must include all of the following:

  1. A narrow, exact-path entry with rationale in scripts/checks/lib/personaWorkflowBoundary.ts.
  2. A focused test proving acknowledgment timing, anchor-message identity, V2-only payloads, and no private fallback reply.
  3. An update to this section documenting why the workflow API could not express the case.

An exception must never weaken the repository-wide scanner or add a directory-wide bypass.

Pattern 4B: Anchor One-Shot Picker -> Modal

Section titled “Pattern 4B: Anchor One-Shot Picker -> Modal”

Use for a config command shaped pick a provider -> choose a value in a modal -> show the result. The whole /model * family and its /personal provider model-* siblings are built this way, plus /model fallback and /personal model fallback.

The command expresses only business intent — which model table to read, which column to write, which terminal copy to show. All lifecycle branching lives in the shared helpers in src/utils/discord/ui/anchorModelFlow.ts:

const initialPayload =
savedProviders.length === 0
? buildNoProvidersPayload(locale, "personal")
: savedProviders.length === 1
? buildOpenSelectorPayload(locale, `${ID_ROOT}_open`)
: buildProviderPickerPayload(locale, ID_ROOT, providers, currentSelections);
const phase = await beginAnchorPrivateWorkflow(interaction, locale, initialPayload);
anchorMessage = phase.message; // for the outer catch
if (savedProviders.length === 0) return;
const opener = await acquireModelModalOpener(phase, userId, locale, savedProviders, ID_ROOT);
if (!opener) return; // cancel/timeout already rendered
const modalPhase = await openAnchorModal(phase, opener.button, locale, modalOptions);
if (!modalPhase) return; // dismiss/cancel already rendered
const work = await modalPhase.beginInPlaceWork(); // acks the submit within 3s
await work.message.replace(terminalPayload); // terminal lands on the same message

Rules:

  • Never call promptForSavedProvider, promptWithPaginatedModal, promptWithRawModal, or replaceProviderPickerWithInfo from a file that uses this pattern. List the file in MIGRATED_ANCHOR_CALLERS; the audit in tests/unit/commands/anchorMigrationLockdown.test.ts then fails the build if one of those primitives reappears in it.
  • Every terminal — success, validation failure, write failure, and the outer catch — renders through work.message.replace(...) or the tracked anchorMessage, never replyInfoEmbed. Absence of the banned primitives is what transitively guarantees this.
  • >25 options need no caller handling: openAnchorModal routes through the engine’s range-selector bridge automatically.
  • Single-provider flows still show an explicit “open selector” button. A modal must open from an interaction the controller owns, so the slash command cannot open it directly.

When the bridge does not fit. The bridge slices exactly one select component and assumes every entry is a selectable option. /model fallback violates both — five selects over one shared option list, with one entry per page reserved for an explicit “None” choice. Such a command picks its range on the anchor message first via acquireModalOptionRange(...) (passing a pageSize below 25 to reserve entries), then hands openAnchorModal an already-sliced <=25 list, which opens directly.

Use when you must delay deferral until after quick checks.

if (!hasPermission) {
await replyInfoEmbed(...);
return;
}
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const data = await exportServerData(...);
await interaction.editReply({ files: [data] });

Rules:

  • keep pre-defer path fast
  • once async heavy work starts, interaction must already be acknowledged
  • defer before promptWithRawModal(...) (causes already-acknowledged errors)
  • no defer before DB/API updates in async command paths
  • pre-defer before pagination helpers
  • forgetting to defer modal submissions that do heavy async processing
  • replyInfoEmbed(...) / replySummaryEmbed(...):
    • handle reply vs editReply based on interaction state
  • promptWithRawModal(...):
    • shows modal (acknowledges original interaction)
    • optional arg 4 (autoDeferReply) can defer modal submission automatically
  • promptWithUnacknowledgedConfirmation(...):
    • shows confirm/cancel buttons without pre-acknowledging the confirm button
    • use this for button -> modal flows where showModal() must happen after confirmation
  • replyPaginatedChoices(...) / promptWithPaginatedModal(...):
    • send pagination UI immediately (acknowledges interaction)
    • should be called without pre-deferring
Command Type Defer Before Work? Primary API
Simple/Fast No interaction.reply(...)
DB/API before response Yes interaction.deferReply(...) then helper reply
Modal No (before modal) promptWithRawModal(...)
Pagination No (before helper) replyPaginatedChoices(...) / promptWithPaginatedModal(...)
Persona workflow No runPersonaPickerWorkflow(...); select a typed phase operation before work
Manual timing Depends defer after quick checks, before heavy work
  • bot: respond, generate(image/scene), kill, impersonate
  • config: setup, model(text/image/embedding/video/vision/speech/transcription), api-key(rotation), provider(add/remove), custom-endpoint(add/edit/remove), image-tags(default-positive/default-negative), system-prompt(set/remove/preset), context-note(set), params(*), timezone, message-fetch-limit, self-debug, model-randomizer, workarounds, bot-permissions -> tool-use(toggle/manage), notice-embeds(visibility)
  • speech: elevenlabs, voice-add, voice-remove, voice-assign, transcripts, chatterbox(parameters)
  • nsfw: jailbreaks
  • optional-key: brave/set/remove
  • server: trigger(add/delete), whitelist(channel/persona/role/remove), stm(manage), cooldown(triggers), auto-trigger(channels/threshold), matrix(link/unlink), quota(image-generation/text-generation/video-generation/reset), rp-channels, crosschannel-blocklist, welcome-channel(set/remove), private-channels, user-blacklist(add/remove), member-permissions, always-reply, thought-logs-channel, channel-prompt
  • novelai: attg, image(params/generate), character-reference
  • server: trigger(add/delete), whitelist(channel/persona/role/remove), stm(manage), cooldown(triggers), auto-trigger(*), matrix(link/unlink), quota(image-generation/text-generation/video-generation/reset), rp-channels, crosschannel-blocklist, welcome-channel(set/remove), private-channels, user-blacklist(add/remove)
  • persona: create, generate, import, export, default, swap, remove, image-tags, sprites(add/edit/remove/export/import), attribute(add/edit/remove), sample-dialogue(add/edit/remove), prompt(set/remove), history(import/remove)
  • memory: document(add/remove), personal(add/edit/remove/import/export), server(add/edit/remove/import/export)
  • personal: privacy, language, nickname, image-tags, cache, config(import/export/remove), provider(add/remove/model-text/model-embedding/model-image/model-video/model-vision/toggle-models), model(fallback), parameters, impersonate(prompt), spotlight(set/manage)
  • scheduled-task: edit, remove
  • conditioning: manage, reward(headpat/hug/kiss/tickle), punish(spank/pinch/bite/squeeze)
  • tool: ping, status, refresh, compact, comment
  • stats: personal(scope toggle), persona(picker), server — each takes an optional timeframe (default All-Time)

/stats is a guild-only category that reads the stat_counters telemetry table (see database-schema). Each subcommand (personal, persona, server) takes an optional timeframe choice (Today / Last 7 Days / Last 30 Days / Last Year / All-Time), defaulting to All-Time when omitted; personal adds a required scope choice (This Server / All Servers) — declared before timeframe because Discord rejects a required option after an optional one. The result is a public, invoker-controlled tabbed dashboard (src/utils/stats/statsDashboard.ts) built on Components V2: each tab is a single container (H3 title, separator-divided stat sections, and the tab buttons living inside the card). A row of named tab buttons swaps which container is shown (a tabbed view, not item pagination). Only the invoker can operate the tabs; the buttons are stripped on collector timeout (STATS_DASHBOARD_TIMEOUT_MS, default 5 min). The renderer uses a single persistent createMessageComponentCollector (not a one-shot awaitMessageComponent loop) so rapid tab switching can’t land in a no-collector gap, and wraps each button.update in try/catch so a stale/expired interaction (DiscordAPIError 10062) can never tear down the dashboard. Dashboard and infographic entry points drain the in-memory stat buffer before querying, so their snapshots include all successfully buffered work from the current process. Timeframe gating: rewards/punishments and memories are all-time-only; daily telemetry, including generation totals, works for every timeframe. Span metrics (streaks, most-active hour/day) are hidden under the single-day Today view. /stats persona uses runPersonaPickerWorkflow(...) and its explicit separate-public phase: the selected button compacts the private picker, then exactly one public follow-up becomes the dashboard. Token and cost figures prefer provider-reported usage and fall back to character estimates when unavailable; they remain estimates because pricing can be incomplete or provider-dependent. Timeframe windows use the daily-bucket floor, so Today is the current UTC day, not a rolling 24h.

/server auto-trigger is channel-scoped and uses one shared cycle across its configured channels. Threshold 0 enables always-reply in those channels. Positive values use either a fixed trigger (min = max) or a shared inclusive random range (min-max), rerolling after each successful auto-trigger. The cycle only advances on qualifying real user-like messages; TomoriBot and alter webhook self-messages do not advance or consume the auto-trigger counter. Removing a channel disables auto-trigger behavior for that channel. /server auto-trigger channels can also target a single channel and assign one persona to that room’s auto-trigger fallback instead of always using the main persona.

/server channel-prompt is a flat, modal-driven command that scopes a system prompt to one channel. It takes a required channel option, then opens a prefilled 4-part modal (up to 16000 chars, part 1 optional) plus a Radio Group for Prompt Mode (Append / Replace). Append injects the channel prompt as a distinct SYSTEM_CHANNEL_PROMPT block after the server system prompt; Replace substitutes the channel prompt for the server system prompt’s slot — persona prompt and persona attributes are never affected. Submitting with all prompt parts empty removes the channel’s override. State lives in the standalone channel_prompt_overrides table (per-channel, never exported) and is resolved per request via getCachedChannelPrompt. The override surfaces in /tool prompt snapshot under the Channel Prompt header.

/persona sprites add is a one-modal Manage Server flow that selects a persona, validates a sprite label, uploads an image, converts it to PNG, and upserts a persona_sprites row. Reusing a normalized label replaces the existing sprite. /persona sprites edit uses the persona workflow, sprite picker, and confirmation bridge before opening a prefilled modal for name, optional replacement image, usage instructions, and identity status; replacement images consume the shared avatar quota, while metadata-only edits do not. /persona sprites remove starts from runPersonaPickerWorkflow(...), then uses its in-place modal bridge for checkbox groups where checked sprites are kept and unchecked sprites are deleted. When a persona has more than 25 modal options, the workflow shows localized range buttons on the anchor message before opening the selected checkbox slice. /persona sprites export selects a persona and bundles its sprites into a shareable .zip through the explicit public-result phase. /persona sprites import opens a single modal with a persona select plus a .zip file-upload field; it validates and converts every image up front, reserves one import-quota slot for the whole batch, overwrites on name conflicts, and rejects the entire import if it would exceed PERSONA_SPRITE_MAX_PER_PERSONA. The archive format (manifest + sprites/ images) and its ZIP-bomb guards live in src/utils/persona/spriteArchive.ts. See multi-persona for the format details.

/bot generate image is a modal-driven, fire-and-forget scene snapshot command. It plans against the current channel context with the active text provider, preparing its simplified-history participants through the same API as live chat, then renders with either the current provider’s native image path or NovelAI’s tag-based image tool when a NovelAI backend is available. Personal provider overlays apply before the hidden turn is built so personal text/image routing is respected.

/bot generate scene is a modal-driven scripted text-scene command. V1 requires two different personas, optionally accepts a third, blocks duplicate selections, and only opens when the available persona set fits Discord’s 25-option select limit. The Rounds field repeats the selected speaking order and is bounded by BOT_GENERATE_SCENE_MAX_CYCLES (default 10; TomoriBot is BYOK so each generated turn bills the invoking user’s own provider). Each generated turn receives a concise tail directive: additional instructions when provided, then “Begin your next reply as {persona}. Write only this character’s next message.” Scene turns keep tools enabled, suppress /bot respond continuation prompting, and use unique text-quota trigger keys so each generated turn is charged separately. Because every scene turn shares one trigger message, both reply-to-trigger mechanisms are suppressed for scene turns: the visual Discord reply (replyToMessage in toolLoop.ts) and the textual buildQueuedReplyDirective context directive (contextPipeline.ts) — otherwise every queued persona would render and be told to reply to the same unrelated message. The command-execution status embed (commands.bot.generate.scene.success_title) is sent non-ephemerally so it is classified as a scene_directive system embed and re-read into context as [System: ...]. For scene turns after the first, triggererName (what {{user}} resolves to in turnPlanner.ts) is overridden to the previous speaker in sceneTurn.sequence, so each persona treats the prior persona as the entity it is responding to rather than the command invoker; turn 0 has no prior speaker and keeps the invoker.

/generate video is a modal-driven async generation command. It validates videogen_enabled, provider capability, API key, configured video_model_id, and server quota before polling the selected provider until the MP4 result is ready.

/config model-randomizer is a server-level toggle (mirrors /config self-debug) for the per-turn text model randomizer. When enabled, each generation turn randomly promotes one model from the pool (primary model + configured fallbacks) to lead the attempt chain, breaking the bot out of any single model’s repetitive phrasing while keeping the rest as failover. It enforces a block-until-fallbacks precondition: enabling is refused with a localized warning embed unless the server has ≥1 fallback configured via /model fallback, guaranteeing the pool always has ≥2 members so the toggle is never a silent no-op. The flag lives in server_chat_configs.model_randomizer_enabled and is consumed by buildGenerationAttempts — see the generation-turn pipeline.

/config workarounds is a checkbox-group modal for experimental compatibility patches. V1 exposes Verbatim Tool-Calling, a default-off server flag stored in server_capabilities_configs.verbatim_tool_calling_enabled. The command uses promptWithRawModal(..., MessageFlags.Ephemeral) as the first acknowledgement, writes only changed columns through ConfigRepository.updateCapabilitiesConfig, and invalidates TomoriState cache after a successful DB write.

Personal-provider (BYOK) routing in commands

Section titled “Personal-provider (BYOK) routing in commands”

Any command that performs AI work the invoking user triggers must overlay that user’s personal (BYOK) provider onto the loaded server state via applyPersonalProviderSelectionsToTomoriState(tomoriState, userData.user_id) before reading config.api_key, deriving the provider/model name, or validating capabilities. The overlay returns the server state unchanged when the user has no enabled personal provider, so it is always safe to call. Commands that currently apply it: /persona generate, /novelai image generate, /generate image, /generate video, /bot generate image, /memory document add, /memory history import, /server initialize expressions, and /tool estimate cost (so its live estimate stays in parity with what would actually run for the user).

The one deliberate exception is /model embedding, which re-embeds server-wide documents under server credentials (resolveCapabilityCredentials(serverId, "embedding") with no userId). This is bulk maintenance of a pre-existing server resource rather than a fresh user action, so it intentionally stays on server credentials.

Forward-looking command rewrite guidance (naming conventions, checklist-style settings pattern, migration map) is now part of docs/en/contributing/adding-slash-command.md. The runtime loader and current implementation still use the existing src/commands/ structure.

  1. Add a .ts file under the correct command category/group path.
  2. Export configureSubcommand and execute. Root commands export configureCommand and execute.
  3. Add locale keys in both locale trees (src/locales/en-US/ and src/locales/ja/). Command keys live in commands/{category}.ts within each locale directory.
  4. Run:
    • bun run check-locales
    • bun run check
    • bun run lint