# How Client Commands works ## The panel side A telegram-panel module marks a method of a user account's module as a client command: ```python 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`.