Client Commands: telegram-panel slash commands with Discord-style parameters

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>
This commit is contained in:
lyuksovannyyandClaude Opus 5.5 committed 2026-10-09 16:19:20 +02:00
1 parent 84bdc44a76
commit 46a1f6ee1f
29 files changed
+3293 -220

No files matched your search

+7 -3
View File
@@ -16,9 +16,13 @@
```
plugin.json single source of truth for plugin metadata
src/ the plugin, written as an ordinary Python package
main.py entry module — exactly one BasePlugin subclass, absolute imports only
greeting/ sample package (proves relative + cross-module imports survive bundling)
textutil.py sample top-level sibling module
main.py entry module: the plugin class, settings, the send hook
api.py telegram-panel client API (/api/v1/client/commands)
catalog.py each account's commands: the panel's (hourly, cached on disk) + plugins'
library.py the `client_commands` module other plugins register commands with
runner.py runs a command on the panel or on the device, shows or sends the response
commands/ pure Python, tested offline: model, parser (in step with the panel's), HTML
hooks/ the app's UI: suggestion list, message field, local responses
tools/
bundle.py compiles plugin.json + src/ into dist/<id>.plugin
devclient.py speaks the on-device dev-server protocol (127.0.0.1:42690)
+97
View File
@@ -0,0 +1,97 @@
# 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`.
+88
View File
@@ -0,0 +1,88 @@
# The library: commands from other plugins
Client Commands publishes the module `client_commands`. Another plugin registers commands
with it. Those commands show in the `/` list with their parameters and run on the device, and
their answers show like the panel's: only to you, or in the chat when *Send response in chat*
is on.
## Registering
```python
import random
import sys
import types
from base_plugin import BasePlugin
__id__ = "dice"
__name__ = "Dice"
def setup(api):
api.register(
"roll",
roll,
summary="Rolls a die",
params=[
api.Param("sides", "integer", required=False, default=6, description="How many sides"),
api.Param("style", "choice", required=False, default="plain", choices=("plain", "big")),
],
owner=__id__,
)
def roll(ctx, sides=6, style="plain"):
api = sys.modules["client_commands"]
if sides < 2:
raise api.CommandError("A die needs at least 2 sides")
result = random.randint(1, sides)
return f"🎲 <b>{result}</b>" if style == "big" else f"🎲 {result}"
class DicePlugin(BasePlugin):
def on_plugin_load(self):
# Works whichever plugin loads first.
api = sys.modules.setdefault("client_commands", types.ModuleType("client_commands"))
if hasattr(api, "register"):
setup(api)
else:
api.__dict__.setdefault("pending", []).append(setup)
def on_plugin_unload(self):
api = sys.modules.get("client_commands")
if api is not None and hasattr(api, "unregister_owner"):
api.unregister_owner(__id__)
```
## API
| Name | What it does |
|---|---|
| `register(name, handler, *, params=(), summary="", description="", aliases=(), reply="none", owner=None)` | Adds or replaces a command. `reply` is `none`, `optional` or `required`. `params` are `Param`s, or dicts shaped like the panel's. |
| `unregister(name)` | Removes one command. |
| `unregister_owner(owner)` | Removes every command `owner` registered. Call it from `on_plugin_unload`. |
| `commands()` | The registered commands, as dicts. |
| `Param(name, type="string", required=True, description="", default=None, choices=())` | `type` is `string`, `integer`, `number`, `boolean` or `choice`. |
| `CommandError` | Raise it for a user-facing error. The message is shown as the answer. |
| `escape(text)` | Makes text safe to put into an HTML answer. |
| `VERSION`, `available` | The API version, and whether Client Commands is loaded right now. |
`handler(ctx, **values)` runs off the UI thread. Its arguments are the parsed parameter
values, with defaults for those not given. It returns the answer as Telegram HTML, or None
(the answer is then "✓ Done"). These tags work: `b`, `i`, `u`, `s`, `code`, `pre`, `a href`,
`blockquote`, `tg-spoiler`, `tg-emoji` and `br`.
`ctx` has:
- `account`, `dialog_id` and `topic_id`;
- `reply_to`, the replied `MessageObject` or None;
- `text`, the command as typed, and `command`, its name;
- `values`, the parsed parameters;
- `send_to_chat`, whether the answer goes into the chat as the account;
- `client`, the SDK's `AccountClient` of the account.
Parsing follows the same rules as the panel's commands (see
[how-it-works.md](how-it-works.md#parsing)). A panel command with the same name shadows yours.
Registrations survive Client Commands being updated or turned off and on. A `client_commands`
module kept from before still works.
+16
View File
@@ -112,3 +112,19 @@ The official structured plugin format (Elyx/ElyxBuilder) is a real alternative t
This repo's bundler instead compiles a normal multi-file `src/` project down to the one
`.plugin` file the base engine already knows how to load, so the dev-server write/reload loop
works from the first deploy with no extra on-device engine.
## Gotchas found on exteraGram 12.10.6 (client_commands)
- **Class checks.** `hook_utils.find_class` returns a Chaquopy class wrapper without
`isInstance`. Use `jutil.java_class(name)`, which gives the real `java.lang.Class`.
- **Plugin-made modules.** A module a plugin puts into `sys.modules` doesn't reliably survive
that plugin's reload. State that must outlive a reload (the library registry) hangs off
`builtins` instead.
- **Suggestion list.** `MentionsAdapter.notifyDataSetChanged` diffs rows by their item (the
command string). Rows whose label stays the same aren't rebound, so call
`notifyItemRangeChanged` too. The adapter's `:` emoji search is async and replaces the
rows, so replace the search (skip the method) instead of patching its result.
- **Recycled cells.** A cell restyled by an earlier load of the plugin keeps that style.
Remember the original in the view's tag, not in plugin memory.
- **Ephemeral messages.** A tap opens their menu; a long press does nothing. Deleting one goes
through `MessagesController.deleteEphemeralMessage`, which also asks Telegram to delete it.