generated from exteraGramPlugins/pluginBase
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:
1 parent
84bdc44a76
commit
46a1f6ee1f
29 files changed
+3293
-220
No files matched your search
+7
-3
@@ -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)
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user