Files
clientCommands/docs/how-it-works.md
T
lyuksovannyyandClaude Opus 5.5 46a1f6ee1f Client Commands: telegram-panel slash commands with Discord-style parameters
Lists the panel account's client commands as Telegram slash commands (hourly
refresh, cached on the device), shows their parameters while typing, runs
complete commands on the panel instead of sending them and shows the answer
as an "Only visible to you" message (latest 20 kept in memory). Holding send
offers Run / Send as message / Send response in chat (red ! badge); an
answer's menu starts with Send in chat. Other plugins register their own
commands through the client_commands library module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:19:20 +02:00

5.6 KiB

How Client Commands works

The panel side

A telegram-panel module marks a method of a user account's module as a client command:

from app.telegram.client_commands import ClientCommandContext, CommandError, client_command

@client_command(
    "getid",
    summary="Ids of this message, its users, a chat and the forum topic",
    params={"target": "Chat id, @username or part of a chat name; this chat when empty"},
    reply="optional",
)
async def getid(self, ctx: ClientCommandContext, target: str | None = None) -> str:
    ...  # returns the answer as Telegram HTML; raise CommandError("...") for user errors

Parameters come from the signature. str, int, float, bool and Literal[...] become the types string, integer, number, boolean and choice. A default makes a parameter optional. reply is none, optional or required.

A command reaches the handler in one of two ways:

  • Sent to a chat (no plugin): the account's outgoing update runs it, and the command message is edited into the answer, or into ❌ reason.
  • From the plugin, through the client API. Nothing is sent: the answer is returned, or posted into the chat as the account when the plugin asks.

The client API

/api/v1/client/commands is authenticated by a commands API token. Each token is bound to one user account of yours. A token is refused by the routes of the other kind of token.

Route Answer
GET /me the bound account: account_id, tg_id (the Telegram user id the plugin matches), account_name, running
GET / {commands: [{name, aliases, summary, description, reply, params: [{name, type, required, description, default, choices}]}], refresh_seconds}
POST /execute body {text, sender_id, chat_id, reply_to_message_id, topic_id, send}; answer {ok, error, html, text, entities, sent_message_id}

sender_id is the Telegram user id of the account that typed the command. The panel refuses the command when it isn't the token's account, and handlers get it as ctx.sender_id. chat_id is the marked (Telethon) id, where channels are -100…. Entities use UTF-16 offsets. Command errors come back as ok: false with a reason. HTTP errors mean the token was refused or the panel failed.

Parsing

The plugin (src/commands/parse.py) and the panel (app/telegram/client_commands/parser.py) parse the same way. Keep the two in step: tools/verify_bundle.py and the panel's tests/test_client_commands.py check the same cases.

  • /name args. /name@anything works too, and the name is case-insensitive.
  • Arguments split like a shell, so quotes group words.
  • param:value sets a parameter by name. Other words fill the remaining parameters in order. The last remaining parameter, when it is a string, takes all the words left over.
  • An empty param: counts as not given.

The plugin side

  • Catalog (catalog.py): asks /me for each token, then that account's commands. It asks again after refresh_seconds (an hour), or after 5 minutes when the request failed. Commands are cached in files/client_commands/catalog.json. A plugin's commands are added for every account, unless a panel command has the same name.
  • Suggestions (hooks/suggest.py): hooks MentionsAdapter.searchUsernameOrHashtag. For a bare /prefix, after the app's own search, it fills the adapter's bot command lists with ours first. For /command args, it runs instead of the app's search, whose : emoji search would otherwise replace the rows, and lists the parameters. The chat's item listeners are wrapped after ChatActivity.createView (and again whenever our rows are shown, since a reload of the plugin leaves an older wrapper in place). Taps on our rows only fill the field. A tap on one of our commands in a stale list is caught by its text, so it never reaches the app's handler, which would send it.
  • Field (hooks/composer.py):
    • ChatActivityEnterView.sendMessageInternal is behind the send button, the keyboard's send key and the send preview. There a command runs instead of being sent.
    • onSendLongClick opens the hold menu.
    • SendButton.onDraw draws the ! badge.
    • The SDK's send-message hook catches commands sent some other way. A complete one runs; an incomplete one goes back into the field instead of being sent.
  • Answers (hooks/responses.py): Telegram 12.10's ephemeral messages. An outgoing TL_message with an ephemeral id, from the account, is handed to the open chat through ChatActivity.processNewMessages. It is never stored: the latest 20 are kept in memory (with the shared library state, so they survive a reload of the plugin, not of the app) and inserted again after ChatActivity.updateMessagesVisiblePart when their chat (and topic) is opened. "Running…" placeholders aren't kept. The app labels outgoing ephemeral messages "Only visible to @receiver", so ChatMessageCell.setMessageObjectInternal is hooked to relabel ours "Only visible to you". A "Running…" one stands in until the answer arrives. Deleting one goes through deleteEphemeralMessage, which is hooked so it only removes the message locally. Replying to one is blocked.
  • Send in chat (hooks/menu.py): tapping an answer opens the app's menu for ephemeral messages. After ChatActivity.createMenu, a Send in chat row is added above the menu's first row. It posts the answer as the account through SendMessagesHelper, without the command line and replying where the command replied, then removes the local copy.

Diagnostics go to …/files/exteraGram/exteraGram Files/client_commands/diag.log, readable over adb shell cat.