A bot in two languages
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:
// 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:
// 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}',
},
},// 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:
// 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:
@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:
// 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, witht.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
announcein 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
meocordgroup are those ofMeoCordMessages, so a misspelt key fails to compile. The Indonesian catalog is a plain variable, whose messages TypeScript types asstring, so a{param}MeoCord doesn't pass is caught byexpectCompleteCatalogin the test below; written inline or withas 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:
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
- Localisation: catalogs, plurals, and choosing the language.
- Presenters: a loading view and error titles in the user's language.
- Gateway events: the welcome's
@On('guildMemberAdd').