Skip to content

Configuration ​

OmniBot provides a typed configuration system for modules. Each module can declare a configuration schema that is automatically exposed through an interactive Discord interface, accessible to administrators via /config <module>.

Declaring a Schema ​

A module declares its schema in defineModule() via the config property:

TIP

Config field name and description must be in English in the TypeScript schema — they serve as the fallback when no translation file matches the guild's locale. No need to duplicate them in en.json. Add translations in i18n/ files for other locales.

typescript
// src/modules/my-module/my-module.module.ts

import { ConfigType } from "#lib/config.js";
import { defineModule } from "#lib/module.js";

export default defineModule({
  id: "my-module",
  name: "My Module",
  description: "Description of my module.",
  version: "1.0.0",

  config: {
    logChannel: {
      name: "Log channel",
      description: "Channel where to post logs",
      type: ConfigType.CHANNEL,
    },
    maxWarnings: {
      name: "Max warnings",
      description: "Number of warnings before action",
      type: ConfigType.NUMBER,
      defaultValue: 3,
    },
    logLevel: {
      name: "Log level",
      description: "Log verbosity",
      type: ConfigType.ENUM,
      options: ["debug", "info", "warn", "error"] as const,
      defaultValue: "info",
    },
  },

  onLoad(_client, registry) {
    // ...
  },
});

Field Types ​

Supported Types ​

TypeInputStorage (JSON)Read (deserialized)
STRINGModal (text input)stringstring
NUMBERModal (text → number)numbernumber
BOOLEANToggle buttonbooleanboolean
USERUser select menustring (id)User
ROLERole select menustring (id)Role
CHANNELChannel select menustring (id)Channel
CATEGORYCategory select menustring (id)CategoryChannel
ENUMSelect menu (fixed choices)string (choice)Literal union
DURATIONModal (30s, 4h, 1h30m)number (seconds)number (seconds)
EMOJIModal (🚩, :smile:, :name:)stringstring
CHANNEL_RECORDSPer-channel editorModule's own tableChannelRecord[]

A DURATION entry can declare a max in seconds. The modal then refuses a longer value, and config.get() never returns more than max, even for a value saved before the limit existed:

typescript
retention: {
  name: "Retention",
  description: "How long a closed report is kept",
  type: ConfigType.DURATION,
  defaultValue: 7 * 24 * 60 * 60,
  max: 30 * 24 * 60 * 60,
},

Lists ​

Any type except DURATION can be declared as a list using type: [ConfigType.X]:

typescript
config: {
  channels: {
    name: "Monitored channels",
    description: "Channels to monitor",
    type: [ConfigType.CHANNEL],    // List of channels
  },
  scores: {
    name: "Scores",
    description: "High scores list",
    type: [ConfigType.NUMBER],     // List of numbers
  },
}

List editing depends on the element type:

  • Entities (USER, ROLE, CHANNEL, CATEGORY): native multi-select menus
  • ENUM: multi-select menu with the declared options
  • Scalars (STRING, NUMBER): dedicated add/remove editor
  • EMOJI: the same editor; a server emoji can be typed as :name:
  • BOOLEAN: add/remove editor with toggle buttons

ENUM Type (Fixed Choices) ​

The ENUM type restricts a field to a fixed set of values:

typescript
config: {
  theme: {
    name: "Theme",
    description: "Display theme",
    type: ConfigType.ENUM,
    options: ["light", "dark", "auto"] as const,
    defaultValue: "auto",
  },
  features: {
    name: "Features",
    description: "Enabled features",
    type: [ConfigType.ENUM],
    options: ["welcome", "logs", "automod"] as const,
  },
}
  • options is required on ENUM entries (enforced by the type system)
  • Using as const gives precise typing: config.get("theme") returns "light" | "dark" | "auto"
  • Without as const, the returned type is string

CHANNEL_RECORDS Type (Per-Channel Settings) ​

Some modules hold one setting per channel rather than one value for the whole server: an emoji for each channel (Auto React), a message for each channel (Sticky Messages). A list cannot express that pairing, so CHANNEL_RECORDS describes a set of records keyed by a channel, each carrying one or more fields:

typescript
import { ChannelType } from "discord.js";

config: {
  reactions: {
    name: "Automatic reactions",
    description: "Channels whose new messages get a reaction.",
    type: ConfigType.CHANNEL_RECORDS,
    channel: {
      name: "Channel",
      channelTypes: [ChannelType.GuildText, ChannelType.GuildAnnouncement],
    },
    fields: {
      emoji: { name: "Emoji", type: ConfigType.EMOJI },
    },
    source: reactionRecords,
  },
}
  • fields accepts STRING (with optional multiline and maxLength) and EMOJI fields
  • The records are not stored in the GuildConfiguration blob: the module keeps them in its own table and exposes them through a source implementing ChannelRecordSource:
typescript
export const reactionRecords: ChannelRecordSource = {
  async list(guildId) {
    return (await store.list(guildId)).map(({ channelId, emoji }) => ({
      channelId,
      values: { emoji },
    }));
  },
  save(guild, { channelId, values }, { t, userId }) {
    // validate, persist, side effects… then report
    return { ok: true, message: t("set.done") };
  },
  remove(guild, channelId, { t, userId }) {
    // …
  },
};
  • save and remove return { ok, message }: the message (translated with the module's t) is shown at the top of the editor. Put the module's own checks there (bot permissions in the channel, usable emoji, …)
  • When the editor opens, records whose channel no longer exists are removed through remove, and the editor refuses to work for a disabled module
  • The core only checks that each field is filled in and that EMOJI fields hold an emoji before calling save
  • config.get("reactions") returns the records ({ channelId, values }[]) read from the source; it is undefined if the source failed
  • Records are not affected by the panel's Reset button: they are not a stored value with a default
  • Localize the labels with config.<key>.name, config.<key>.description, config.<key>.channel and config.<key>.fields.<field>

Default Values ​

  • A field with defaultValue is always present — the type is T (never undefined)
  • A field without defaultValue may be T | undefined (never set by the admin)
  • Entity fields without defaults return undefined (not a fake value)

This is an important distinction — always check for undefined when accessing fields without defaults.

Accessing Configuration ​

The config parameter is passed automatically to commands, listeners, and interactions:

typescript
async function handler(interaction, config) {
  const channel = config.get("logChannel"); // Channel | undefined
  const max = config.get("maxWarnings"); // number (always present)
  const level = config.get("logLevel"); // "debug" | "info" | "warn" | "error"

  // Use with default
  const safe = config.get("logChannel") ?? someDefaultChannel;
}

Admin Interface ​

/config <module> Command ​

  • Admin only (Administrator permission required)
  • Public response — configuration is visible to everyone (transparency)
  • Autocomplete on module name (core + enabled modules)

The panel displays:

  • All configurable fields with their type, description, and current value
  • Edit buttons adapted to each type (button toggle, modal, select menu)
  • Pagination when there are more than 10 fields (Discord's 40-component limit)

Editing Flow ​

Field TypeEditor TypePersistenceUI Update
STRINGModal (text input)On submitPublic message updated in place
NUMBERModal (number input)On submitPublic message updated in place
BOOLEANToggle buttonOn clickPublic message updated in place
USER, ROLE, CHANNEL, CATEGORYEntity select menu (ephemeral)On selectPublic message refreshed
ENUMString select menu (ephemeral)On selectPublic message refreshed
DURATIONModal (duration input)On submitPublic message updated in place
EMOJIModal (single emoji)On submitPublic message updated in place
List of scalarsAdd/remove editor (ephemeral)On actionPublic message refreshed
CHANNEL_RECORDSPer-channel editor (ephemeral)On submitPublic message refreshed

Ephemeral editors (select menus, list editors) refresh the public config message after each change using refreshSourceConfigMessage(). The source message ID is threaded through the customId of the ephemeral components.

Persistence ​

Configuration is stored as a single JSON blob per guild in the GuildConfiguration table:

prisma
model GuildConfiguration {
  guildId String @id
  data    Json   // Record<moduleId, { key: value }>
}
  • Entity IDs (user, role, channel) are stored as strings and deserialized to Discord objects at read time
  • Entities deleted since (removed channel/role/…) are silently dropped from lists at read time, so config.get() never contains null
  • An in-memory cache (configCache) avoids database reads on every interaction
  • The cache is invalidated on every write

Localization ​

Config field names and descriptions can be translated per guild locale via i18n files. The system resolves localized values automatically based on the guild's configured locale (/config core > locale).

Module-level translations ​

Create an i18n/ directory in your module with one JSON file per locale:

json
// i18n/en.json
{
  "config.myField.name": "My Field",
  "config.myField.description": "Description of my field."
}

// i18n/fr.json
{
  "config.myField.name": "Mon champ",
  "config.myField.description": "Description de mon champ."
}

The field's name and description in the TypeScript schema serve as English defaults — there's no need to duplicate them in en.json.

Using config.t() for messages ​

The ConfigProvider injected into commands, listeners, and interactions exposes a t() method for localized strings anywhere in your module:

typescript
async execute(interaction, config) {
  const message = config.t("welcomeMessage", { user: interaction.user.username });
  await interaction.reply(message);
}

Namespace fallback ​

Keys are resolved in this order:

  1. Your module's namespace (e.g. thread-creator:welcomeMessage)
  2. The core namespace (core:welcomeMessage)

This means common UI strings like config.previous, config.next, modules.enable, and type names (type.text, type.number, etc.) are provided by the core namespace — you only need to translate module-specific strings.

Best Practices ​

  • Use camelCase for keys, human-readable name and clear description
  • Provide defaultValue for fields that should always have a value
  • Keep schemas focused — too many fields makes the admin panel unwieldy
  • Use as const on ENUM options for type-safe literal unions
  • Check for undefined when accessing fields without defaults

Next Steps ​