Custom commands
Text commands and button commands are built from the same modal, in the dashboard’s Commands tab. Whether a saved command becomes one or the other is decided entirely by whether it has any buttons attached when it’s saved; editing can flip an existing command the other way.
One modal, two kinds of command
The Commands tab lists every text and button command for the selected bot in one table — columns Trigger, Response, Active, Actions — and adds new ones from one Add Command button.
The add modal is titled Add Filter Command; editing an existing command opens the same modal titled Edit Command. The trigger field is a bare, /-prefixed field sanitised to letters, numbers and underscore; the response field is a rich-text field.
What gets saved is decided by the button board under the response field, not by a separate switch. Submit with zero buttons attached and the dashboard calls the text-command create action; attach at least one button first and it calls the button-command create action instead. The same check runs when saving an edit to an existing command: clear every button off a button command and saving converts it to a text command; add a first button to a text command and saving converts it to a button command.
The button board
The button board appears under the response field once the first button is added. Each button opens its own edit popover, titled Button, with a label field, a URL field, a colour picker, a layout switch, and an optional premium-emoji icon.
Button board limits
| Setting | Value |
|---|---|
| Buttons per command | 12 max — the Add Button control disables at the cap |
| Label length | 64 characters (field placeholder: Label (e.g. Buy Now)) |
| URL scheme | https:// only. A bare host is normalised to https:// automatically; an explicit http:// URL is not blocked while typing but is rejected on save with Button URLs must use HTTPS |
| Colours | 4 — Gray (default, no style set), Blue (primary), Green (success), Red (danger) |
| Layout | Horizontal or Stacked — Stacked is the default for a new command and for the first button added to one |
A button can also carry one premium emoji next to its label — a Telegram custom_emoji_id, picked from the owner’s own emoji packs or pasted directly. The bot keeps it only if it’s 5 to 32 digits; anything else is silently dropped when the live config is built (config.py’s button-command loader).
The same caps are enforced again on the server when a command is saved (services/managedBotFeatures.ts validateButtonCommandData): at least 1 button and at most 12, every button needs a label and an https:// URL, and the layout value must be inline or stacked.
The response editor
Selecting text inside the response field pops up a floating formatting toolbar.
Selection toolbar
| Button | Does |
|---|---|
| Bold, Italic, Underline, Strikethrough | Standard text formatting |
| Monospace | Wraps the selection in a code span |
| Spoiler | Wraps the selection in Telegram’s spoiler span |
| Hyperlink | Turns the selection into a link — the typed URL must start with https:// literally, or nothing is inserted (no auto-prefixing here, unlike the button board’s URL field) |
| Remove link | Strips a link back to plain text |
Two more controls sit in the modal’s own toolbar rather than the floating one: Undo / Redo, an emoji inserter (“Drop a premium emoji, or a whole banner, into the response”), and a divider inserter (“Drop a divider line into the response”).
The divider inserter draws from an 11-cell catalog and also offers an invisible spacer glyph — “adds space where Telegram normally allows none” — insertable on its own alongside the divider lines.
The trigger namespace
A trigger must be unique across every text command, every button command, and every character’s trigger command on the same bot. Creating or renaming one is checked against all three.
Trigger validation errors
| Situation | Message |
|---|---|
| Bad format | Trigger must be alphanumeric (letters, numbers, underscore only) |
| Matches a character’s trigger | Trigger cannot be the same as a character command (/x) |
| Matches another text command | Command with trigger "x" already exists |
| Matches another button command | Button command with trigger "x" already exists |
| A text and a button command would share one trigger | A button command with trigger "x" already exists. Triggers must be unique across text and button commands. |
A trigger matches with the leading slash optional — the matcher is ^/?{trigger}(@botname)?(\s|$), case-insensitive (scripts/telegram/utils/prims.py:265-279).
Slash optional
| Input | Output |
|---|---|
| Saved trigger | ca |
| All of these match | /ca /ca@yourbotusername ca |
The Commands tab’s own subtitle says as much: “users type /ca or just ca to get the response.”
Custom commands are matched only after every built-in slash command has had a chance to: the fixed command router runs first, and any match there consumes the message before a custom command is even checked. Custom commands are not gated by the group authorization allowlist themselves — neither the matcher (match_text_or_button_command) nor the dispatchers (dispatch_text_command, dispatch_button_command) make an is_authorized call, and nothing upstream of them in the message pipeline does either.
7 trigger words are reserved as built-ins:
- start
- commands
- getid
- whoisactive
- holders
- ban
- mute
Where the reserved list is actually enforced
This list (
RESERVED_BUILTIN_TRIGGERS,services/managedBotFeatures.ts) blocks reassigning a character’s trigger command to one of these 7 words. Text-command and button-command triggers are validated by a different check (validateTextCommandTrigger) that does not consult this list — saving a text or button command using one of these 7 words as its trigger is not blocked by name alone.
Limits
Per-command limits
| Setting | Value |
|---|---|
| Visible reply length | 4096 characters — Telegram’s own cap, measured after HTML tags are stripped and entities collapsed to 1 character each |
| Stored markup size | 64 KiB (65,536 characters) of raw response markup |
| Batch create | 50 commands per API call (an items array on the create action) |
| Management API rate limit | 60 requests / 60 seconds per IP — shared across every /api/bots/management resource, not specific to commands |
Over either size limit, saving fails with Response markup is too large (over 64 KiB) or Response exceeds Telegram's 4096 character limit (N visible characters) (over 4096 visible characters, markup itself still under 64 KiB).
Hyperlink navigation entries
Beta
Hyperlink navigation is currently in beta.
A finished hyperlink nav is stored as an ordinary textCommands[] entry carrying one extra field, hyperlinkNav — the nav’s preset, colour, link list, and the site data it was discovered from. The bot itself never reads that object: config.py’s text-command loader (config.py:451-457) reads only trigger and response into the running config (and isActive, to decide whether to include the command at all) — hyperlinkNav is dropped entirely.
The Commands tab can edit one. Opening a command for editing checks for hyperlinkNav first and, if present, opens the hyperlink-nav builder instead of the ordinary text editor (CommandsCreator.tsx:339-348). There’s no button in the Commands tab to start a new one — creation only happens from the beta tab’s own nav builder.
A nav entry can’t be cloned — the Clone action doesn’t render for it in the table — and can’t be converted to a button command: attempting that conversion fails on the server with Hyperlink navigations cannot be converted to regular commands (app/api/bots/management/route.ts:455).