Skip to content
Select themeSelect language

Wire a new Telegram command

The Telegram bot is wired in four layers, all in server/src/interfaces/telegram/ and server/src/app/i18n/. Follow these steps in order.

  1. Declare the command variant.

    Open commands.rs and add a variant to the Command enum (derived with teloxide::utils::command::BotCommands):

    #[command(description = "One-line help text shown by /help")]
    MyCommand(String), // String = the raw argument string; use () for no args

    The rename_rule = "lowercase" attribute on the enum means MyCommand registers as /mycommand.

  2. Add a route variant to LinkedCommandRoute.

    Immediately below the Command enum there is a private LinkedCommandRoute enum. Add a matching arm:

    MyCommand { args: &'a str },

    Then wire it in linked_command_route:

    Command::MyCommand(args) => Some(LinkedCommandRoute::MyCommand { args }),
  3. Dispatch to a handler.

    In the match linked_command_route(...) block inside handle_command, add an arm that calls your handler:

    LinkedCommandRoute::MyCommand { args } => {
    super::commands_workspace_handlers::handle_my_command(
    &bot, chat_id, &state, user_id, locale, args,
    )
    .await?
    }

    Place the handler itself in the appropriate dispatch module:

    • Simple read/action commands go in commands_dispatch.rs.
    • Workspace-scoped or feature-listing commands go in commands_workspace_handlers.rs.

    A typical handler delegates straight to a service function and uses the shared send_service_result helper:

    pub(super) async fn handle_my_command(
    bot: &Bot,
    chat_id: ChatId,
    state: &AppState,
    user_id: Uuid,
    locale: Locale,
    args: &str,
    ) -> ResponseResult<()> {
    send_service_result(
    bot,
    chat_id,
    locale,
    crate::chat::command::my_command(state, user_id, locale, args).await,
    )
    .await
    }

    send_service_result sends the ResponsePlan text (plain or HTML) on success and falls back to the TelegramCommandFailed message on error, so the handler never needs to format errors itself.

  4. Add Fluent message keys.

    Every user-visible string must go through the typed Fluent catalog — no hardcoded strings in handlers.

    a. Register the key in server/src/app/i18n/keys.rs:

    pub enum MessageKey {
    // ...existing keys...
    TelegramMyCommandResult,
    TelegramMyCommandUsage,
    }

    Add the same variants to the ALL slice and to the id match arm using kebab-case:

    Self::TelegramMyCommandResult => "telegram-my-command-result",
    Self::TelegramMyCommandUsage => "telegram-my-command-usage",

    The unit test message_key_ids_are_unique_kebab_case_entries in keys.rs will fail if an id is duplicated or uses a non-kebab character.

    b. Write the message in both locale files:

    server/locales/en/telegram.ftl:

    telegram-my-command-result = Result: { $value }
    telegram-my-command-usage = Usage: /mycommand <argument>

    server/locales/de/telegram.ftl:

    telegram-my-command-result = Ergebnis: { $value }
    telegram-my-command-usage = Nutzung: /mycommand <argument>

    c. Use the key in your service function:

    // No dynamic args — plain text:
    i18n::text(locale, MessageKey::TelegramMyCommandResult)
    // With substitution variables:
    i18n::with_args(locale, MessageKey::TelegramMyCommandResult,
    i18n::arg("value", some_string))

    If the same formatted string is reused in several places, add a typed helper to server/src/app/i18n/format.rs (see the existing helpers such as telegram_intervention_sent as a model).

  5. Verify.

    Terminal window
    cd server
    cargo check
    cargo test arch
    cargo test -- i18n

    cargo test -- i18n runs the catalog validation test (fluent_catalogs_contain_all_typed_keys) which checks that every MessageKey variant has a corresponding entry in both locale files.