generated from exteraGramPlugins/pluginBase
Publish plugin / publish (push) Successful in 5s
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>
102 lines
5.8 KiB
Markdown
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`.
|