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

102 lines
5.8 KiB
Markdown

# 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. 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`.