generated from exteraGramPlugins/pluginBase
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>
98 lines
5.6 KiB
Markdown
98 lines
5.6 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. 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`.
|