Files
clientCommands/docs/how-it-works.md
T
lyuksovannyyandClaude Opus 5.5 0b174fea7e
Publish plugin / publish (push) Successful in 5s
Client commands tokens cover all your panel accounts
One token runs the commands of every account of its owner on the panel: /me
lists them, the plugin asks for the commands of each one signed in on the
device, and every request names its account by Telegram user id.

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

5.8 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. A token isn't bound to an account: it covers every user account of its owner (the accounts they added, or all for a superuser), and each request names the account by its Telegram user id. A token is refused by the routes of the other kind of token.

Route Answer
GET /me {name, accounts: [{account_id, tg_id, name, running}]}: the accounts the token covers
GET /?tg_id=… that account's {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 one of the token owner's accounts, 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 which accounts the token covers, then the commands of each one signed in on the device. 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.