ExGram provides a command macro to declare bot commands with descriptions, visibility scopes, and language translations. When setup_commands: true is set on the bot, these declarations are automatically registered with the Telegram API on startup, making your commands appear in the autocomplete menu for users.

Basic usage

The simplest way to declare a command is with just a name and a description:

defmodule MyBot do
  use ExGram.Bot, name: :my_bot, setup_commands: true

  command(:start, description: "Start the bot")

  def handle({:command, :start, _msg}, context) do
    answer(context, "Welcome!")
  end
end

Commands without a description are still valid - they will be handled by your bot but won't be registered in the Telegram command menu. This is useful for "hidden" commands or not useful commands in day-to-day:

command(:start)  # no description - works but won't show in menu
command(:debug)  # no description - works but won't show in menu

def handle({:command, :start, _msg}, context) do
  answer(context, "Hey!")
end

def handle({:command, :debug, _msg}, context) do
  answer(context, "Debug mode")
end

Options reference

  • :description (string) - text shown in Telegram's command menu. Required if any other of this options are set.
  • :scopes (list) - scopes where the command is visible. See Scopes.
  • :lang (keyword list) - language-specific overrides, IETF language code atom. See Language translations.
  • :name (atom) - atom used for dispatch pattern matching. Defaults to the command name as an atom. Useful when you want a different name in the handler.
# Override the handler atom - dispatches as {:command, :begin, msg}
command(:start, name: :begin, description: "Start the bot")

def handle({:command, :begin, _msg}, context) do
  answer(context, "Welcome!")
end

Scopes

Telegram's BotCommandScope controls where commands appear in the autocomplete menu. By declaring scopes you can show different command sets to different audiences - for example, showing admin commands only to group administrators.

Simple scopes

These are plain atoms:

ScopeWho sees it
:defaultAll users when no more specific scope applies
:all_private_chatsUsers in any private (1-on-1) chat
:all_group_chatsUsers in any group or supergroup chat
:all_chat_administratorsAdministrators in any group chat
command(:help,
  description: "Get help",
  scopes: [:all_private_chats, :all_group_chats]
)

command(:ban,
  description: "Ban a user",
  scopes: [:all_chat_administrators]
)

Parametric scopes

These are tuples that target specific chats or users:

  • {:chat, chat_ids: [100, 200]} - visible in the listed chats. Expands to one API registration per chat.
  • {:chat_administrators, chat_ids: [100]} - visible to administrators of the listed chats.
  • {:chat_member, chat_id: 1, user_ids: [10, 20]} - visible to specific users in a specific chat.
command(:notify,
  description: "Send a notification",
  scopes: [{:chat, chat_ids: [123_456, 789_012]}]
)

command(:secret,
  description: "Secret command",
  scopes: [{:chat_member, chat_id: 123_456, user_ids: [111, 222]}]
)

Scope inheritance

The scopes option controls not just where a command appears, but how it interacts with the rest of your command list.

Commands without scopes (or with scopes: nil). All the other scopes will inherit this command, meaning they will appear everywhere other commands appear.

Commands with scopes: [] (empty list) fall back to :default. It will only appear to users with the default scope.

If no command defines any scope, everything falls back to :default.

Commands with explicit scopes appear only in those scopes.

# "help" has no scopes
command(:help, description: "Get help")

# "stats" is only in :all_private_chats
command(:stats,
  description: "Your stats",
  scopes: [:all_private_chats]
)

In this example, both :stats and :help appear in :all_private_chats. And on the :default scope (group chats for example) only the :help command would appear.

Language translations

The :lang option lets you provide per-language overrides for the command name and description. Each key is an IETF language code atom (:es, :pt, :it, etc.) and the value is a keyword list with :command and/or :description.

Translating descriptions

command(:start,
  description: "Start the bot",
  scopes: [:default],
  lang: [
    es: [description: "Iniciar el bot"],
    pt: [description: "Iniciar o bot"]
  ]
)

Spanish users see "Iniciar el bot", Portuguese users see "Iniciar o bot", everyone else sees "Start the bot".

Translating command names

You can also change the command name itself for a language:

command(:help,
  description: "Get help",
  scopes: [:default],
  lang: [es: [command: "ayuda", description: "Obtener ayuda"]]
)

Spanish users see /ayuda in their menu. ExGram automatically registers /ayuda as a dispatch alias, so it routes to the same :help handler - no extra handle clause needed:

def handle({:command, :help, _msg}, context) do
  # Here the user will have `language_code` if you want to send translated messages
  answer(context, "Here is some help!")
end

Inheriting values

A lang entry does not need to override both fields:

  • Omitting :description inherits the base description.
  • Omitting :command keeps the base command name.
command(:help,
  description: "Get help",
  lang: [es: [command: "ayuda"]]  # description inherited from base
)

Merge behavior

Untranslated commands are automatically merged into every language group. This ensures users of any language always see the full command list - translated commands appear in their translated form, untranslated commands fall back to their base form.

command(:start,
  description: "Start the bot",
  lang: [es: [description: "Iniciar el bot"]]
)
command(:help, description: "Get help")

Spanish users see both start ("Iniciar el bot") and help ("Get help"). The untranslated :help is merged in automatically.

If a command renames itself in a translation (e.g. help -> ayuda), the original name (help) is excluded from that language group to avoid showing duplicate entries.

Scopes and languages combined

Translations apply independently to each scope. If a command appears in multiple scopes, each (scope + language) combination gets its own Telegram API registration.

command(:greet,
  description: "Greet users",
  scopes: [:all_private_chats, :all_group_chats],
  lang: [es: [description: "Saludar usuarios"]]
)

This produces four registrations:

  • :all_private_chats (no lang) - "Greet users"
  • :all_group_chats (no lang) - "Greet users"
  • :all_private_chats + "es" - "Saludar usuarios"
  • :all_group_chats + "es" - "Saludar usuarios"

Full example

defmodule MyBot do
  use ExGram.Bot, name: :my_bot, setup_commands: true

  middleware(ExGram.Middleware.IgnoreUsername)

  # Visible to all users in all contexts
  command(:start,
    description: "Start the bot",
    lang: [
      es: [description: "Iniciar el bot"],
      pt: [description: "Iniciar o bot"]
    ]
  )

  # Only visible in private chats, with a translated name for Spanish
  command(:help,
    description: "Get help",
    scopes: [:all_private_chats],
    lang: [es: [command: "ayuda", description: "Obtener ayuda"]]
  )

  # Only visible to group administrators
  command(:ban,
    description: "Ban a user",
    scopes: [:all_chat_administrators],
    lang: [es: [description: "Prohibir usuario"]]
  )

  # Hidden command - no description, won't appear in the menu
  command(:debug)

  def handle({:command, :start, _msg}, context) do
    answer(context, "Welcome! Use /help for a list of commands.")
  end

  # Handles both /help and /ayuda (Spanish alias)
  def handle({:command, :help, _msg}, context) do
    answer(context, "Here is some help!")
  end

  def handle({:command, :ban, %{text: target}}, context) do
    answer(context, "Banned #{target}")
  end

  def handle({:command, :debug, _msg}, context) do
    answer(context, "Debug info: ...")
  end

  def handle(_, _context), do: :ok
end