SayKit
Integrations

Carbon

Use SayKit with Carbon, localise Discord commands, components, and replies

Carbon is a Discord bot framework. @saykit/carbon is its SayKit integration:

  • registers a shared Say with your Carbon client
  • helps command, component, and modal classes expose translated metadata
  • adds locale-aware interaction.say and guild.say properties

The result: commands appear in Discord's localised UI per user, and replies match the user's locale.

Install

pnpm add saykit @saykit/carbon
pnpm add -D @saykit/config @saykit/format-po @saykit/transform-js unplugin-saykit

The Carbon example builds with tsdown, so it uses unplugin-saykit/rolldown. Any Carbon-compatible bundler works, pick the matching entry point from unplugin-saykit.

Configure

saykit.config.ts

saykit.config.ts
import { defineConfig } from '@saykit/config';
import po from '@saykit/format-po';
import js from '@saykit/transform-js';

export default defineConfig({
  locales: ['en', 'fr'],
  buckets: [
    {
      include: ['src/**/*.ts'],
      output: 'src/locales/{locale}.{extension}',
      formatter: po(),
      transformer: js(),
    },
  ],
});

tsdown.config.ts

tsdown.config.ts
import { defineConfig } from 'tsdown';
import saykit from 'unplugin-saykit/rolldown';

export default defineConfig({
  entry: ['src/entry.ts'],
  plugins: [saykit()],
});

App setup

A shared Say

Eagerly load locales so the bot can answer immediately on cold start:

src/i18n.ts
import { Say } from 'saykit';

const say = new Say({
  locales: ['en', 'fr'],
  messages: {
    en: await import('./locales/en.po').then((m) => m.default),
    fr: await import('./locales/fr.po').then((m) => m.default),
  },
});

say.load();
say.activate('en');

export default say;

Register SayPlugin

src/index.ts
import { Client } from '@buape/carbon';
import { SayPlugin } from '@saykit/carbon';
import { PingCommand } from './commands/ping.js';
import say from './i18n.js';

const client = new Client(
  {
    /* options */
  },
  { commands: [new PingCommand(say)] },
  [new SayPlugin(say)],
);

SayPlugin installs the Say instance into the global registry and applies extensions so interaction.say and guild.say work.

Localising commands

withSay() wraps a Carbon class with a constructor that accepts a Say and a mapping function. The mapping function runs once per locale; SayKit uses the results to populate Carbon's per-locale command metadata.

src/commands/ping.ts
import { Command, type CommandInteraction } from '@buape/carbon';
import { withSay } from '@saykit/carbon';
import type { Say } from 'saykit';

export class PingCommand extends withSay(Command) {
  constructor(say: Say) {
    super(say, (say) => ({
      name: say`ping`,
      description: say`Ping the bot!`,
    }));
  }

  async run(interaction: CommandInteraction) {
    await interaction.reply({
      content: interaction.say`Pong!`,
    });
  }
}

When you register PingCommand, Carbon receives every translation of name and description at once, Discord then renders the command in whichever locale each user has.

Subcommands and options

CommandWithSubcommands works the same way:

export class MathsCommand extends withSay(CommandWithSubcommands) {
  constructor(say: Say) {
    super(say, (say) => ({
      name: say`maths`,
      description: say`Maths commands!`,
      subcommands: [new AddCommand(say), new SubtractCommand(say)],
    }));
  }
}

Options work too, everything visible in the Discord UI is localisable:

super(say, (say) => ({
  name: say`add`,
  description: say`Add two numbers!`,
  options: [
    {
      name: say`a`,
      description: say`The first number.`,
      type: ApplicationCommandOptionType.Number,
      required: true,
    },
  ],
}));

Components and modals

For BaseComponent and Modal subclasses, withSay accepts the translated props directly, no per-locale mapping, because Discord doesn't localise component metadata the way it does commands:

import { Button } from '@buape/carbon';

export class RollAgainButton extends withSay(Button) {
  customId = 'roll-again';
  constructor(say: Say) {
    super({ label: say`Roll Again` });
  }
}

interaction.say and guild.say

Once SayPlugin is registered:

  • interaction.say is a Say cloned and activated for the interaction's locale
  • guild.say is a Say cloned and activated for the guild's preferred locale

Use either anywhere you'd normally use say:

await interaction.reply({
  content: interaction.say`The dice rolled ${result}!`,
});
await someOperation(guild.say`Welcome to ${guildName}!`);

The clone is cached on the interaction or guild instance, subsequent accesses re-use it.

Live example

The examples/carbon package is a working Cloudflare Workers bot with localised commands.

Next

On this page