Skip to content
GitHub

A bot in two languages

MeoCord 4.1 · since 4.1.0

An /announce command in English and Indonesian, with MeoCord's own answers in the member's language too.

/announce in English and Indonesian. Members see the command in their own language, the announcement is posted in the server's language for everyone, and the author's confirmation is in theirs. New members are welcomed in the server's language, and what MeoCord answers itself, such as a cooldown notice, comes in the member's. It uses Localisation end to end.

The code

The default catalog is typed, and the Indonesian one only has to cover what it translates:

recipes/i18n/bot.ts
// The default catalog is typed; every other one only has to cover what it translates
const enUS = defineCatalog({
  announce: {
    name: 'announce',
    description: 'Post an announcement',
    message: 'The announcement',
    heading: '📣 Announcement',
    posted: 'Posted. Everyone sees it in the server’s language.',
  },
  welcome: 'Welcome to {server}, {user}!',
})

const id = {
  announce: {
    name: 'umumkan',
    description: 'Kirim pengumuman',
    message: 'Isi pengumuman',
    heading: '📣 Pengumuman',
    posted: 'Terkirim. Semua orang melihatnya dalam bahasa server.',
  },
  welcome: 'Selamat datang di {server}, {user}!',
// …
}

Beside the bot's own messages, the Indonesian catalog translates MeoCord's texts, in a meocord group:

recipes/i18n/bot.ts
// MeoCord's own texts, all of them; one left out would stay in MeoCord's English
meocord: {
  usage: {
    heading: 'Cara pakai: {usage}',
    headingMany: 'Cara pakai:\n{usages}',
    missing: '{param} belum diisi',
    notValid: '{label}: "{word}" bukan {type} yang sah',
    notOneOf: '{label}: "{word}" bukan salah satu dari {choices}',
    notMember: '{label}: <@{id}> bukan anggota server ini',
    noUser: '{label}: tidak ada pengguna dengan ID {id}',
    notRole: '{label}: <@&{id}> bukan peran di server ini',
    notChannel: '{label}: "{word}" bukan saluran',
    unknownFlag: '--{flag} bukan opsi perintah ini',
    notYesNo: '{label}: "{value}" bukan ya atau tidak',
    flagNeedsValue: '{label} perlu nilai, seperti {label}=<{flag}>',
    tooManyWords: 'Perintah ini menerima lebih sedikit kata',
    serverOnly: 'Perintah ini hanya bisa dipakai di server.',
    dmOnly: 'Perintah ini hanya bisa dipakai di pesan langsung.',
  },
  types: {
    string: 'teks',
    int: 'bilangan bulat',
    number: 'angka',
    bool: 'jawaban ya atau tidak',
    duration: 'lama waktu, seperti 10m',
    member: 'anggota',
    user: 'pengguna',
    role: 'peran',
    channel: 'saluran',
  },
  cooldown: {
    // {when} is a Discord timestamp, which Discord words in the reader's language: "dalam 45 detik"
    until: 'Pelan-pelan: coba lagi {when}.',
    storeDown: 'Cooldown tidak bisa diperiksa sekarang: coba lagi sebentar lagi.',
  },
  fallback: {
    notFound: 'Perintah tidak ditemukan!',
    error: 'Terjadi kesalahan saat menjalankan perintah.',
  },
  dm: {
    error: '{command} di {channel} pada {server}: {reason}',
    cooldown: '{command} di {channel} pada {server}: {wait}',
  },
  presenter: {
    loading: 'Sedang diproses…',
    errorTitle: 'Ups!',
  },
  help: {
    commandsHeading: 'Perintah:',
    commandsHint: 'Ketik {invocation} <perintah> untuk cara pakai satu perintah.',
    describedCommand: '{usage} — {description}',
    param: '{name}: {label}',
    optionalParam: '{name} (opsional): {label}',
    params: '{params}',
    aliases: 'Juga: {aliases}',
    serverOnly: 'Hanya bisa dipakai di server.',
    dmOnly: 'Hanya bisa dipakai di pesan langsung.',
    unknown: 'Tidak ada perintah bernama "{query}". Ketik {invocation} untuk melihat daftarnya.',
    emptyHere: 'Tidak ada perintah yang bisa kamu pakai di sini.',
    emptyServerOnly: 'Perintah-perintah ini hanya bisa dipakai di server.',
    listOf: '{label}, satu atau lebih',
    flagOn: 'aktif bila diberikan',
    oneOf: 'salah satu dari {choices}',
  },
},
recipes/i18n/bot.ts
// At module scope: the builder runs when its class is decorated
export const t = createTranslator({ default: 'en-US', locales: { 'en-US': enUS, id } })

t.localizations gives Discord the command's name and descriptions in every language that has them, and Discord shows each member theirs:

recipes/i18n/bot.ts
// Discord shows the command's name and description in each member's language
@CommandBuilder(CommandType.SLASH)
export class AnnounceCommandBuilder {
  build(commandName: string) {
    return new SlashCommandBuilder()
      .setName(commandName)
      .setNameLocalizations(t.localizations('announce.name'))
      .setDescription(t.default('announce.description'))
      .setDescriptionLocalizations(t.localizations('announce.description'))
      .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
      .setContexts(InteractionContextType.Guild)
      .addStringOption(option =>
        option
          .setName('message')
          .setDescription(t.default('announce.message'))
          .setDescriptionLocalizations(t.localizations('announce.message'))
          .setRequired(true),
      )
  }
}

The handler picks whose language each message is in:

recipes/i18n/bot.ts
@Controller()
export class AnnounceController {
  // Interactions report the default name, so the route is `announce` in every language
  @Command('announce', AnnounceCommandBuilder)
  // One announcement a minute in each server; MeoCord answers a second one in the author's language
  @Cooldown({ uses: 1, seconds: 60, per: 'guild' })
  async announce(interaction: ChatInputCommandInteraction, { message }: { message: string }) {
    // What everyone sees, in the server's language
    const everyone = t.for(interaction, { public: true })
    await respond(interaction).send({ content: `**${everyone('announce.heading')}**\n${message}` })
    // What only the author sees, in their own
    await respond(interaction).followUp({
      content: t.for(interaction)('announce.posted'),
      flags: MessageFlags.Ephemeral,
    })
  }

  // An event has no user locale: greet in the server's
  @On('guildMemberAdd')
  async welcome(member: GuildMember) {
    const text = t.forGuild(member.guild)('welcome', { server: member.guild.name, user: member.toString() })
    await member.guild.systemChannel?.send({ content: text })
  }
}

The app gives MeoCord the translator, which is what makes its own texts follow the catalogs:

recipes/i18n/bot.ts
// The translator answers MeoCord's own texts too: cooldowns, usage, errors and "Command not found!"
@MeoCord({
  controllers: [AnnounceController],
  i18n: t,
  clientOptions: { intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers] },
})
export default class AnnounceApp {}

How it works

  • Whose language. A message everyone sees follows the server, t.for(interaction, { public: true }). A private one follows the user, t.for(interaction). An event has no user, so the welcome follows the server, with t.forGuild(guild). See Choosing the language.
  • One route. Discord reports a command by its default name whatever language the member sees, so the route is announce in every language.
  • MeoCord's own texts. With @MeoCord({ i18n: t }), what MeoCord answers itself goes through the same translator: cooldown refusals, "Command not found!", the generic error, and the presenter's "Working on it…" and "Oops!". Answers to an interaction are in the user's language. A second announcement within the minute is refused in the author's language, such as "Pelan-pelan: coba lagi {when}." under the title "Ups!", where {when} is a Discord timestamp each member's app shows in their own language and counts down.
  • Checked when it compiles. The keys of the meocord group are those of MeoCordMessages, so a misspelt key fails to compile. The Indonesian catalog is a plain variable, whose messages TypeScript types as string, so a {param} MeoCord doesn't pass is caught by expectCompleteCatalog in the test below; written inline or with as const, it fails to compile too.
  • What a language leaves out. Each text is looked up on its own, so a line the Indonesian catalog left out would stay in MeoCord's English. This catalog translates all of them.

Testing it

A mock's locale and guildLocale stand for the member's and the server's languages. The cooldown test runs the command twice through the app, as the bot does, and reads the refusal the author sees. expectCompleteCatalog(t, { meocord: true }) fails when a language lacks a message, MeoCord's own included, or uses a {param} the default message doesn't take:

recipes/i18n/bot.spec.ts
describe('AnnounceApp', () => {
  afterEach(() => vi.useRealTimers())

  it('announces in the server’s language, and confirms in the author’s', async () => {
    const module = MeoCordTestingModule.create({ controllers: [AnnounceController] }).compile()
    const interaction = announce('Maintenance at 20:00.')

    await module.invoke(AnnounceController, 'announce', interaction)

    const [announcement, confirmation] = getResponse(interaction).calls
    expect(announcement.payload).toMatchObject({ content: '**📣 Announcement**\nMaintenance at 20:00.' })
    expect(confirmation.payload).toMatchObject({ content: 'Terkirim. Semua orang melihatnya dalam bahasa server.' })
  })

  it('tells the author how long to wait, in their language, through MeoCord’s own text', async () => {
    vi.useFakeTimers({ toFake: ['Date'] })
    const module = MeoCordTestingModule.fromApp(AnnounceApp).compile()

    await module.dispatch(announce('Maintenance at 20:00.'))
    vi.advanceTimersByTime(15_000)
    const again = announce('And again.')
    await module.dispatch(again)

    // The presenter shows it as an embed, its title translated too
    expect(getResponse(again).calls[0].payload).toMatchObject({
      embeds: [
        { title: 'Ups!', description: `Pelan-pelan: coba lagi <t:${Math.ceil((Date.now() + 45_000) / 1000)}:R>.` },
      ],
    })
    expect(again.ephemeral).toBe(true)
  })

  it('welcomes a new member in the server’s language', async () => {
    const module = MeoCordTestingModule.create({ controllers: [AnnounceController] }).compile()
    const systemChannel = createMockInteraction(TextChannel)
    const guild = createMockInteraction(Guild, {
      name: 'Kafe Kucing',
      preferredLocale: Locale.Indonesian,
      systemChannel,
    })
    // discord.js's own toString() mentions the member through its user
    const member = createMockInteraction(GuildMember, { guild, user: createMockInteraction(User, { id: '111' }) })

    await module.emit('guildMemberAdd', member)

    expect(systemChannel.send).toHaveBeenCalledWith({ content: 'Selamat datang di Kafe Kucing, <@111>!' })
  })

  it('has every message in every language, MeoCord’s own included', () => {
    expectCompleteCatalog(t, { meocord: true })
  })
})

Variations

A server's own choice

Keep a language for each server in a service or a database, set by a command for admins, and translate with t.locale(chosen).

Some of MeoCord's texts

A catalog can translate only some of MeoCord's texts, such as the cooldown notices: the rest stay in MeoCord's English, line by line. Check it with expectCompleteCatalog(t), without { meocord: true }: it still reports a {param} MeoCord's English doesn't take.

MeoCord's words in your own answer

An exception filter that answers a cooldown its own way can keep MeoCord's translated words with translateError. See MeoCord's own texts.

Next steps