Skip to main content

Internationalization (i18n)

The Discord bot supports multiple languages using rust-i18n. Currently supported locales are English (en) and German (de).

Setup

Cargo.toml

[dependencies]
rust-i18n = "3"

Library Configuration

In src/lib.rs:

#[macro_use]
extern crate rust_i18n;

// Initialize i18n with locales directory, fallback to English
i18n!("locales", fallback = "en");

Locale Files

Locale files are stored in platform/discord_bot/locales/ as YAML files:

locales/
├── en.yml # English (fallback)
└── de.yml # German

File Structure

Both locale files follow the same structure with a _version field for tracking changes:

_version: 1

bot:
name: "Smutje"
description: "Smutje Discord Bot"

commands:
# Command translations...

errors:
# Error message translations...

embeds:
# Embed text translations...

English (en.yml)

_version: 1

bot:
name: "Smutje"
description: "Smutje Discord Bot"

commands:
ping:
name: "ping"
description: "Check if the bot is responsive"
response: "Pong! Latency: %{latency}ms"
pinging: "Pinging..."

help:
name: "help"
description: "Show available commands"
title: "Available Commands"
no_commands: "No commands available"
command_not_found: "Command '%{command}' not found."

info:
name: "info"
description: "Show bot information"
title: "Bot Information"
version: "Version"
uptime: "Uptime"
guilds: "Guilds"

config:
name: "config"
description: "View bot configuration (admin only)"
title: "Bot Configuration"
environment: "Environment"
log_level: "Log Level"
graphql_endpoint: "GraphQL Endpoint"
rest_endpoint: "REST Endpoint"
websocket_endpoint: "WebSocket Endpoint"

settings:
name: "settings"
description: "Manage bot settings (admin only)"
title: "Bot Settings"
sentry: "Sentry"
traces_sample_rate: "Traces Sample Rate"
health_bind_address: "Health Bind Address"
enabled: "Enabled"
disabled: "Disabled"
language:
name: "language"
description: "Change the bot's default language"
label: "🌐 Default Language"
title: "Language Changed"
message: "Default language has been set to **%{language}**."
current: "Current default language: **%{language}**"
invalid: "Invalid language. Supported languages: %{languages}"
supported: "en, de"
select_placeholder: "Select a language..."
select_label: "🌐 Change default language:"

warn:
name: "warn"
description: "Warn a user"
title: "User Warned"
message: "%{user} has been warned.\nReason: %{reason}"
no_reason: "No reason provided"

kick:
name: "kick"
description: "Kick a user from the server"
title: "User Kicked"
message: "%{user} has been kicked.\nReason: %{reason}"
failed_title: "Kick Failed"
failed_message: "Failed to kick user: %{error}"
no_reason: "No reason provided"

ban:
name: "ban"
description: "Ban a user from the server"
title: "User Banned"
message: "%{user} has been banned.\nReason: %{reason}"
failed_title: "Ban Failed"
failed_message: "Failed to ban user: %{error}"
no_reason: "No reason provided"

mute:
name: "mute"
description: "Mute a user"
title: "User Muted"
message: "%{user} has been muted for %{duration}.\nReason: %{reason}"
failed_title: "Mute Failed"
failed_message: "Failed to mute user: %{error}"
failed_find_member: "Failed to find member: %{error}"
no_reason: "No reason provided"

errors:
permission_denied: "You don't have permission to use this command."
rate_limited: "You're being rate limited. Please wait %{seconds} seconds."
unknown_command: "Unknown command. Use /help to see available commands."
internal_error: "An internal error occurred. Please try again later."
not_linked: "Your Discord account is not linked to your account."

embeds:
footer: "Smutje Bot"
powered_by: "Powered by Smutje"

German (de.yml)

_version: 1

bot:
name: "Smutje"
description: "Smutje Discord Bot"

commands:
ping:
name: "ping"
description: "Prüfe ob der Bot reagiert"
response: "Pong! Latenz: %{latency}ms"
pinging: "Pinge..."

help:
name: "help"
description: "Zeige verfügbare Befehle"
title: "Verfügbare Befehle"
no_commands: "Keine Befehle verfügbar"
command_not_found: "Befehl '%{command}' nicht gefunden."

info:
name: "info"
description: "Zeige Bot-Informationen"
title: "Bot-Informationen"
version: "Version"
uptime: "Laufzeit"
guilds: "Server"

config:
name: "config"
description: "Bot-Konfiguration anzeigen (nur Admin)"
title: "Bot-Konfiguration"
environment: "Umgebung"
log_level: "Log-Level"
graphql_endpoint: "GraphQL-Endpunkt"
rest_endpoint: "REST-Endpunkt"
websocket_endpoint: "WebSocket-Endpunkt"

settings:
name: "settings"
description: "Bot-Einstellungen verwalten (nur Admin)"
title: "Bot-Einstellungen"
sentry: "Sentry"
traces_sample_rate: "Traces Sample Rate"
health_bind_address: "Health Bind-Adresse"
enabled: "Aktiviert"
disabled: "Deaktiviert"
language:
name: "language"
description: "Standardsprache des Bots ändern"
label: "🌐 Standardsprache"
title: "Sprache geändert"
message: "Standardsprache wurde auf **%{language}** gesetzt."
current: "Aktuelle Standardsprache: **%{language}**"
invalid: "Ungültige Sprache. Unterstützte Sprachen: %{languages}"
supported: "en, de"
select_placeholder: "Sprache auswählen..."
select_label: "🌐 Standardsprache ändern:"

warn:
name: "warn"
description: "Einen Benutzer verwarnen"
title: "Benutzer verwarnt"
message: "%{user} wurde verwarnt.\nGrund: %{reason}"
no_reason: "Kein Grund angegeben"

kick:
name: "kick"
description: "Einen Benutzer vom Server kicken"
title: "Benutzer gekickt"
message: "%{user} wurde gekickt.\nGrund: %{reason}"
failed_title: "Kick fehlgeschlagen"
failed_message: "Benutzer konnte nicht gekickt werden: %{error}"
no_reason: "Kein Grund angegeben"

ban:
name: "ban"
description: "Einen Benutzer vom Server bannen"
title: "Benutzer gebannt"
message: "%{user} wurde gebannt.\nGrund: %{reason}"
failed_title: "Bann fehlgeschlagen"
failed_message: "Benutzer konnte nicht gebannt werden: %{error}"
no_reason: "Kein Grund angegeben"

mute:
name: "mute"
description: "Einen Benutzer stummschalten"
title: "Benutzer stummgeschaltet"
message: "%{user} wurde für %{duration} stummgeschaltet.\nGrund: %{reason}"
failed_title: "Stummschaltung fehlgeschlagen"
failed_message: "Benutzer konnte nicht stummgeschaltet werden: %{error}"
failed_find_member: "Mitglied konnte nicht gefunden werden: %{error}"
no_reason: "Kein Grund angegeben"

errors:
permission_denied: "Du hast keine Berechtigung für diesen Befehl."
rate_limited: "Du wirst rate-limitiert. Bitte warte %{seconds} Sekunden."
unknown_command: "Unbekannter Befehl. Nutze /help um verfügbare Befehle zu sehen."
internal_error: "Ein interner Fehler ist aufgetreten. Bitte versuche es später erneut."
not_linked: "Dein Discord-Konto ist nicht mit deinem Konto verknüpft."

embeds:
footer: "Smutje Bot"
powered_by: "Betrieben von Smutje"

Usage with Poise

Async Locale Pattern (Critical)

Thread-Local Issue

In rust-i18n v3, set_locale() uses thread-local storage. In async Rust, when you hit an .await, the task can resume on a different thread, losing the locale setting.

Solution: Always pass the locale explicitly to the t!() macro in async code using get_command_locale():

use crate::utils::get_command_locale;
use crate::bot::data::{Context, Error};
use rust_i18n::t;
use poise::CreateReply;

/// Command using Discord user's locale (CORRECT async pattern)
#[poise::command(slash_command, prefix_command)]
pub async fn ping(ctx: Context<'_>) -> Result<(), Error> {
// Get locale ONCE at the start
let locale = get_command_locale(ctx);

let reply = ctx.say(t!("commands.ping.pinging", locale = locale)).await?;
let latency = start.elapsed().as_millis();

// Pass locale explicitly to ALL t!() calls - required in async contexts!
reply.edit(ctx, CreateReply::default()
.content(t!("commands.ping.response", latency = latency, locale = locale))
).await?;

Ok(())
}

Helper Functions

FunctionUse Case
get_command_locale(ctx)Primary function for async code - returns locale string for explicit t!() use
set_locale_from_context(ctx)Deprecated for async - uses thread-local which can be lost across .await
set_locale_from_string("de")For sync code or before any .await points
map_locale("de-DE")Map Discord locale code to supported locale

Basic Translation Examples

use rust_i18n::t;

// Simple key
let msg = t!("commands.ping.pinging", locale = "en");
// → "Pinging..."

// With variables
let msg = t!("commands.ping.response", latency = 42, locale = "en");
// → "Pong! Latency: 42ms"

// Moderation message with multiple variables
let msg = t!("commands.kick.message", user = "Alice", reason = "Spam", locale = "de");
// → "Alice wurde gekickt.\nGrund: Spam"

// Rate limit message
let msg = t!("errors.rate_limited", seconds = 30, locale = "en");
// → "You're being rate limited. Please wait 30 seconds."

Command Implementation Example

use rust_i18n::t;
use crate::bot::data::{Context, Error};
use crate::utils::get_command_locale;

/// Check if the bot is responsive
#[poise::command(slash_command, prefix_command)]
pub async fn ping(ctx: Context<'_>) -> Result<(), Error> {
let locale = get_command_locale(ctx);

let start = std::time::Instant::now();
let reply = ctx.say(t!("commands.ping.pinging", locale = locale)).await?;
let latency = start.elapsed().as_millis();

reply.edit(ctx, poise::CreateReply::default()
.content(t!("commands.ping.response", latency = latency, locale = locale))
).await?;

Ok(())
}

Error Messages in Error Handler

use rust_i18n::t;

async fn on_error(error: poise::FrameworkError<'_, Data, Error>) {
match error {
poise::FrameworkError::Command { error, ctx, .. } => {
tracing::error!(
command = ctx.command().name,
error = %error,
"Command error"
);
let locale = get_command_locale(ctx);
let _ = ctx.say(t!("errors.internal_error", locale = locale)).await;
}
poise::FrameworkError::CommandCheckFailed { ctx, .. } => {
let locale = get_command_locale(ctx);
let _ = ctx.say(t!("errors.permission_denied", locale = locale)).await;
}
poise::FrameworkError::CooldownHit { remaining_cooldown, ctx, .. } => {
let locale = get_command_locale(ctx);
let _ = ctx.say(t!(
"errors.rate_limited",
seconds = remaining_cooldown.as_secs(),
locale = locale
)).await;
}
_ => {}
}
}

Runtime Default Locale

The bot supports changing its default language at runtime via the /settings language command. This is stored in Data.runtime_locale:

// In bot/data.rs
use tokio::sync::RwLock;
use std::sync::Arc;

pub struct Data {
// ... other fields ...
pub runtime_locale: Arc<RwLock<String>>,
}

impl Data {
/// Get the current runtime default locale
pub async fn get_default_locale(&self) -> String {
self.runtime_locale.read().await.clone()
}

/// Set the runtime default locale (persists until bot restart)
pub async fn set_default_locale(&self, locale: &str) {
let mut lock = self.runtime_locale.write().await;
*lock = locale.to_string();
}
}

Locale Resolution Priority

  1. User's Discord language (slash commands only, via ctx.locale())
  2. Runtime default locale (set via /settings language command)
  3. Config default locale (from settings.bot.default_locale)
  4. Fallback: English

Per-User Locale from Discord

Discord sends the user's selected language with slash command interactions:

Discord Locale Codes:

CodeLanguage
en-USEnglish (US)
en-GBEnglish (UK)
deGerman
frFrench
es-ESSpanish
pt-BRPortuguese (Brazil)
jaJapanese
koKorean
zh-CNChinese (Simplified)
zh-TWChinese (Traditional)
ruRussian
note

ctx.locale() returns None for prefix commands since Discord only sends locale data with slash command interactions.

System Notifications

For system-initiated messages like live notifications, there's no user locale available. Use set_locale_from_string with the locale from guild settings:

use crate::utils::set_locale_from_string;
use rust_i18n::t;

/// Send a system notification to a channel
async fn send_live_notification(
ctx: &serenity::Context,
channel_id: ChannelId,
guild_locale: &str, // From guild settings in Heimdall API
) -> Result<(), Error> {
// Set locale from guild settings (no user context available)
set_locale_from_string(guild_locale);

let embed = heimdall_embed()
.title(t!("notifications.live.title"))
.description(t!("notifications.live.description"));

channel_id.send_message(ctx, CreateMessage::new().embed(embed)).await?;
Ok(())
}

Fetching Guild Locale from API

For prefix commands or system notifications, fetch the guild's language preference:

use crate::utils::set_locale_from_string;

async fn get_guild_locale(
graphql: &GraphqlClient,
guild_id: GuildId,
) -> String {
let query = r#"
query GetGuildSettings($guildId: String!) {
discordGuildSettings(guildId: $guildId) {
locale
}
}
"#;

match graphql.query::<GuildSettings>(query, Some(serde_json::json!({
"guildId": guild_id.to_string()
}))).await {
Ok(settings) => settings.locale,
Err(_) => "en".to_string(),
}
}

Best Practices

Use Keys Consistently

Organize translations hierarchically:

# Top-level categories
bot: # Bot metadata
commands: # Command-specific translations
errors: # Error messages
embeds: # Embed strings

Always Use Fallback

The fallback locale ensures missing translations don't cause errors:

i18n!("locales", fallback = "en");

If a key is missing in the current locale, it falls back to English.

Keep Translations Short

Discord has message limits. Keep translations concise:

# Good
errors:
permission_denied: "No permission."

# Too verbose
errors:
permission_denied: "Unfortunately, you do not have the required permission level..."

Handle Plurals

For plural forms, use separate keys:

users:
count_one: "%{count} user"
count_other: "%{count} users"
let count = 5;
let key = if count == 1 { "users.count_one" } else { "users.count_other" };
let msg = t!(key, count = count, locale = locale);

Adding a New Language

  1. Create a new locale file (e.g., locales/fr.yml)
  2. Copy the structure from en.yml
  3. Translate all strings
  4. Update _version field
  5. The new locale is automatically available
# locales/fr.yml
_version: 1

bot:
name: "Smutje"
description: "Bot Discord Smutje"

commands:
ping:
name: "ping"
description: "Vérifier si le bot répond"
response: "Pong ! Latence : %{latency}ms"
pinging: "Ping en cours..."
# ... rest of translations
let msg = t!("commands.ping.response", latency = 42, locale = "fr");
// → "Pong ! Latence : 42ms"

Translation Keys Reference

Bot Metadata

KeyDescriptionExample
bot.nameBot display name"Smutje"
bot.descriptionBot description"Smutje Discord Bot"

Commands

Key PatternDescription
commands.*.nameCommand name
commands.*.descriptionWhat the command does
commands.*.titleEmbed/response title
commands.*.messageSuccess message (with variables)
commands.*.failed_titleError title
commands.*.failed_messageError message (with %{error})
commands.*.no_reasonDefault reason text

Settings Language Subcommand

KeyDescription
commands.settings.language.labelDropdown label
commands.settings.language.titleSuccess embed title
commands.settings.language.messageSuccess message (with %{language})
commands.settings.language.currentCurrent language display
commands.settings.language.invalidInvalid language error
commands.settings.language.supportedList of supported languages
commands.settings.language.select_placeholderDropdown placeholder
commands.settings.language.select_labelSelect menu label

Errors

KeyDescriptionVariables
errors.permission_deniedNo permission message-
errors.rate_limitedRate limit message%{seconds}
errors.unknown_commandUnknown command-
errors.internal_errorGeneric error-
errors.not_linkedAccount not linked-

Embeds

KeyDescription
embeds.footerEmbed footer text
embeds.powered_byAttribution text

Testing Translations

The bot includes comprehensive i18n integration tests at tests/i18n_test.rs:

# Run i18n tests
cargo test --test i18n_test

Example test cases:

use rust_i18n::t;

// Initialize i18n in test context
rust_i18n::i18n!("locales", fallback = "en");

#[test]
fn test_settings_language_label_en() {
let label = t!("commands.settings.language.label", locale = "en");
assert_eq!(label, "🌐 Default Language");
}

#[test]
fn test_settings_language_label_de() {
let label = t!("commands.settings.language.label", locale = "de");
assert_eq!(label, "🌐 Standardsprache");
}

#[test]
fn test_ping_with_variable() {
let response = t!("commands.ping.response", latency = 42, locale = "en");
assert_eq!(response, "Pong! Latency: 42ms");
}

#[test]
fn test_moderation_message() {
let msg = t!("commands.kick.message", user = "TestUser", reason = "Testing", locale = "en");
assert!(msg.contains("TestUser"));
assert!(msg.contains("Testing"));
}

#[test]
fn test_nested_keys() {
let en_label = t!("commands.settings.language.label", locale = "en");
let de_label = t!("commands.settings.language.label", locale = "de");

// Verify translations return actual values, not raw keys
assert!(!en_label.contains("commands.settings"));
assert!(!de_label.contains("commands.settings"));
}

#[test]
fn test_fallback_locale() {
// Unknown locale falls back to English
let msg = t!("commands.ping.pinging", locale = "xx");
assert_eq!(msg, "Pinging...");
}

These tests verify:

  • Translations are embedded at compile time
  • All keys resolve correctly for both locales
  • Variable substitution works properly
  • Nested keys work correctly
  • Fallback locale works for unknown languages