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>
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@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/mefor each token, then that account's commands. 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.