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>
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@anythingworks too, and the name is case-insensitive.- Arguments split like a shell, so quotes group words.
param:valuesets 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/mewhich accounts the token covers, then the commands of each one signed in on the device. It asks again afterrefresh_seconds(an hour), or after 5 minutes when the request failed. Commands are cached infiles/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): hooksMentionsAdapter.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 afterChatActivity.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.sendMessageInternalis behind the send button, the keyboard's send key and the send preview. There a command runs instead of being sent.onSendLongClickopens the hold menu.SendButton.onDrawdraws 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 outgoingTL_messagewith an ephemeral id, from the account, is handed to the open chat throughChatActivity.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 afterChatActivity.updateMessagesVisiblePartwhen their chat (and topic) is opened. "Running…" placeholders aren't kept. The app labels outgoing ephemeral messages "Only visible to @receiver", soChatMessageCell.setMessageObjectInternalis hooked to relabel ours "Only visible to you". A "Running…" one stands in until the answer arrives. Deleting one goes throughdeleteEphemeralMessage, 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. AfterChatActivity.createMenu, a Send in chat row is added above the menu's first row. It posts the answer as the account throughSendMessagesHelper, 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.