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
+3294
-221
No files matched your search
@@ -1,38 +1,69 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="images/icon.svg" width="128" height="128" alt="exteraGram Plugin Base icon">
|
<img src="images/icon.svg" width="128" height="128" alt="Client Commands icon">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1 align="center">exteraGram Plugin Base</h1>
|
<h1 align="center">Client Commands</h1>
|
||||||
|
|
||||||
**TL;DR:** a template repo for writing [exteraGram](https://exteragram.app) plugins as normal
|
**TL;DR:** an [exteraGram](https://exteragram.app) plugin that turns your
|
||||||
multi-file Python projects. Write code in `src/`, run one command, get a single `.plugin`
|
[telegram-panel](../../telegram-panel) account's commands into Telegram slash commands with
|
||||||
file deployed to your phone or published to Releases.
|
Discord-style parameters. They run on the panel instead of being sent, and only you see the
|
||||||
|
answer. Other plugins can add their own commands through it.
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
exteraGram loads every plugin from exactly one Python file. This template lets you split a
|
The panel runs your Telegram account and gives it commands (such as `/getid`). Sent as an
|
||||||
plugin into ordinary modules and packages anyway: a bundler compiles `src/` and `plugin.json`
|
ordinary message, the panel picks a command up from the account's own update and edits the
|
||||||
into that single file, an offline harness verifies the result without a device, ADB scripts
|
message into its answer. That still works without this plugin.
|
||||||
push it to a connected phone and reload it, and CI publishes every version to the repo's
|
|
||||||
Releases tab.
|
With the plugin, typing `/` lists the account's commands the way Telegram lists a bot's
|
||||||
|
commands. Sending one asks the panel to run it, and the answer appears in the chat only for
|
||||||
|
you, as one of Telegram's "Only visible to you" messages. Nothing goes into the chat unless
|
||||||
|
you choose so.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
None for the end user. The bundled sample plugin (`Hello Bundle`) only proves that the
|
- **Telegram's command list.** Typing `/` shows the account's commands above the input, with
|
||||||
toolchain works. This repo is meant to be used as a **template** for creating other plugins:
|
the account's avatar, each command's parameters and its summary, before any bot commands of
|
||||||
click "Use this template" (or clone it), then replace `plugin.json` and `src/` with your own.
|
the chat. Tapping or holding a command puts it into the field; only the send button runs
|
||||||
|
it.
|
||||||
|
- **Discord-style parameters.** After `/command `, the list shows the command's parameters:
|
||||||
|
their type, whether they are required, and whether the command needs a reply. The parameter
|
||||||
|
at the cursor is blue, given ones are green with their value, and missing required ones are
|
||||||
|
red. Values go in order or by name (`days:7`), and quotes group words. Tap a parameter to
|
||||||
|
add `name:`.
|
||||||
|
- **Runs instead of sending.** A complete command goes to the panel when you press send. An
|
||||||
|
incomplete one stays in the field and a bulletin says what is missing. A command is only
|
||||||
|
ever sent as text through *Send as message*.
|
||||||
|
- **Private answers.** The answer shows as your own message, marked "Only visible to you",
|
||||||
|
with the command above it. The latest 20 answers stay in memory and come back when you
|
||||||
|
reopen their chat, until the app restarts; an answer to a command run while its chat was
|
||||||
|
closed shows there too. Deleting one removes it for good. Tap it and choose **Send in chat**, the first item of
|
||||||
|
its menu, to post the answer into the chat as you.
|
||||||
|
- **Hold send for options.** Holding send on a command offers *Run*, *Send as message* (the
|
||||||
|
panel then handles it from the update, as without the plugin) and a *Send response in chat*
|
||||||
|
check. While that check is on and the command can run, the send button shows a red **!**:
|
||||||
|
the panel will post the answer into the chat as you.
|
||||||
|
- **Kept fresh.** The panel's command list of every signed-in account is fetched hourly and
|
||||||
|
cached on the device, so commands show at once, even offline.
|
||||||
|
- **A library for other plugins.** Plugins can register their own commands, with parameters,
|
||||||
|
that run on the device. See [docs/library.md](docs/library.md).
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. In the panel, open Settings → API Tokens and create a **Client commands** token for each
|
||||||
|
of your accounts that uses exteraGram. Message logger tokens don't work here, and these
|
||||||
|
tokens don't work for the message logger.
|
||||||
|
2. In the plugin settings, enter the panel URL and the tokens, separated by spaces.
|
||||||
|
3. Tap *Test connection*. *Available commands* lists what the current account has.
|
||||||
|
|
||||||
|
Your panel user needs the `client:commands:use` permission.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
- [How Client Commands works](docs/how-it-works.md): the panel API, parsing, the hooks
|
||||||
|
- [The library](docs/library.md): adding commands from another plugin
|
||||||
- [Development](docs/development.md): getting started, repo layout, building and verifying, icon export
|
- [Development](docs/development.md): getting started, repo layout, building and verifying, icon export
|
||||||
- [How exteraGram plugins work](docs/how-plugins-work.md): the plugin format and lifecycle
|
- [How exteraGram plugins work](docs/how-plugins-work.md): the plugin format and lifecycle
|
||||||
- [Device setup and ADB automation](docs/device-and-adb.md): deploy, watch, logs, debugger
|
- [Device setup and ADB automation](docs/device-and-adb.md): deploy, watch, logs, debugger
|
||||||
- [Versioning and releases](docs/versioning-and-releases.md): git hooks, auto-bump, CI publishing
|
- [Versioning and releases](docs/versioning-and-releases.md): git hooks, auto-bump, CI publishing
|
||||||
- [SDK notes](docs/sdk-notes.md): condensed exteraGram SDK API surface
|
- [SDK notes](docs/sdk-notes.md): condensed exteraGram SDK API surface
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 tools/bundle.py --check # once after cloning: enables the git hooks
|
|
||||||
bash scripts/deploy.sh --enable # build + push to a connected device + enable
|
|
||||||
```
|
|
||||||
+7
-3
@@ -16,9 +16,13 @@
|
|||||||
```
|
```
|
||||||
plugin.json single source of truth for plugin metadata
|
plugin.json single source of truth for plugin metadata
|
||||||
src/ the plugin, written as an ordinary Python package
|
src/ the plugin, written as an ordinary Python package
|
||||||
main.py entry module — exactly one BasePlugin subclass, absolute imports only
|
main.py entry module: the plugin class, settings, the send hook
|
||||||
greeting/ sample package (proves relative + cross-module imports survive bundling)
|
api.py telegram-panel client API (/api/v1/client/commands)
|
||||||
textutil.py sample top-level sibling module
|
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/
|
tools/
|
||||||
bundle.py compiles plugin.json + src/ into dist/<id>.plugin
|
bundle.py compiles plugin.json + src/ into dist/<id>.plugin
|
||||||
devclient.py speaks the on-device dev-server protocol (127.0.0.1:42690)
|
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
|
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
|
`.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.
|
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.
|
||||||
+7
-5
@@ -1,11 +1,13 @@
|
|||||||
{
|
{
|
||||||
"id": "hello_bundle",
|
"id": "client_commands",
|
||||||
"name": "Hello Bundle",
|
"name": "Client Commands",
|
||||||
"description": "Multi-file exteraGram plugin built by tools/bundle.py",
|
"description": "Your telegram-panel account's commands as slash commands, with Discord-style parameters; other plugins can add their own.",
|
||||||
"author": "@lyuksovannyy",
|
"author": "@lyuksovannyy",
|
||||||
"version": "1.0.0",
|
"version": "0.1.0",
|
||||||
"icon": "exteraGramPluginIcons/6",
|
"icon": "exteraGramPluginIcons/6",
|
||||||
"app_version": ">=12.5.1",
|
"app_version": ">=12.5.1",
|
||||||
"sdk_version": ">=1.4.4.3",
|
"sdk_version": ">=1.4.4.3",
|
||||||
"requirements": []
|
"requirements": [
|
||||||
|
"requests"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
"""telegram-panel client API (`/api/v1/client/commands`).
|
||||||
|
|
||||||
|
Each token runs the client commands of one panel account, the Telegram
|
||||||
|
account it is bound to (panel: Settings -> API Tokens -> Client commands);
|
||||||
|
the plugin holds one per account and picks it by the Telegram user id
|
||||||
|
`/me` reports. Message logger tokens are refused here, and these there.
|
||||||
|
|
||||||
|
Calls are blocking: run them off the UI thread (`jutil.in_background`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from config import API_TOKENS, EXECUTE_TIMEOUT_SECONDS, HTTP_TIMEOUT_SECONDS, SERVER_URL
|
||||||
|
|
||||||
|
|
||||||
|
class ApiError(Exception):
|
||||||
|
def __init__(self, message, status=None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
@property
|
||||||
|
def refused(self):
|
||||||
|
"""The token itself is no good (unknown, expired, wrong kind...)."""
|
||||||
|
return self.status in (401, 403)
|
||||||
|
|
||||||
|
|
||||||
|
class Api:
|
||||||
|
def __init__(self, plugin):
|
||||||
|
self.plugin = plugin
|
||||||
|
|
||||||
|
@property
|
||||||
|
def configured(self):
|
||||||
|
return bool(self._base() and self.tokens())
|
||||||
|
|
||||||
|
def _base(self):
|
||||||
|
url = str(self.plugin.get_setting(SERVER_URL, "") or "").strip().rstrip("/")
|
||||||
|
return url + "/api/v1/client/commands" if url else ""
|
||||||
|
|
||||||
|
def tokens(self):
|
||||||
|
raw = str(self.plugin.get_setting(API_TOKENS, "") or "")
|
||||||
|
seen = []
|
||||||
|
for token in re.split(r"[\s,;]+", raw):
|
||||||
|
if token and token not in seen:
|
||||||
|
seen.append(token)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
def _call(self, token, method, path, timeout=HTTP_TIMEOUT_SECONDS, **kwargs):
|
||||||
|
import requests
|
||||||
|
|
||||||
|
if not self._base():
|
||||||
|
raise ApiError("The panel URL is not set")
|
||||||
|
try:
|
||||||
|
response = requests.request(
|
||||||
|
method,
|
||||||
|
self._base() + path,
|
||||||
|
headers={"Authorization": "Bearer " + token},
|
||||||
|
timeout=timeout,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise ApiError(f"Panel unreachable: {exc}") from exc
|
||||||
|
if response.status_code >= 400:
|
||||||
|
try:
|
||||||
|
detail = response.json().get("detail")
|
||||||
|
except Exception:
|
||||||
|
detail = response.text[:200]
|
||||||
|
if response.status_code == 404 and detail == "Not Found":
|
||||||
|
detail = "the panel doesn't support client commands yet (update telegram-panel)"
|
||||||
|
raise ApiError(f"{response.status_code}: {detail}", response.status_code)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def me(self, token):
|
||||||
|
"""{account_id, tg_id, account_name, running, ...} of the token."""
|
||||||
|
return self._call(token, "GET", "/me")
|
||||||
|
|
||||||
|
def commands(self, token):
|
||||||
|
"""{commands: [...], refresh_seconds}."""
|
||||||
|
return self._call(token, "GET", "/")
|
||||||
|
|
||||||
|
def execute(self, token, text, chat_id, reply_to_id=None, topic_id=None, send=False, sender_id=None):
|
||||||
|
"""{ok, error, text, entities, html, sent_message_id}. `sender_id`:
|
||||||
|
the Telegram user id of the account that typed the command."""
|
||||||
|
body = {
|
||||||
|
"text": text,
|
||||||
|
"sender_id": sender_id,
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"reply_to_message_id": reply_to_id,
|
||||||
|
"topic_id": topic_id,
|
||||||
|
"send": bool(send),
|
||||||
|
}
|
||||||
|
return self._call(token, "POST", "/execute", timeout=EXECUTE_TIMEOUT_SECONDS, json=body)
|
||||||
+244
@@ -0,0 +1,244 @@
|
|||||||
|
"""Which commands each signed-in account has.
|
||||||
|
|
||||||
|
- The panel's: every token is asked for its account (`/me`) and that
|
||||||
|
account's commands, again once an hour (or when the panel says so), and
|
||||||
|
cached on disk, so a restart shows them at once, even offline.
|
||||||
|
- The ones plugins registered through the library (`library.py`): for every
|
||||||
|
account.
|
||||||
|
|
||||||
|
A panel command shadows a plugin's command of the same name.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import diag
|
||||||
|
from api import ApiError
|
||||||
|
from commands.model import Command
|
||||||
|
from config import REFRESH_SECONDS, RETRY_SECONDS, SERVER_COMMANDS
|
||||||
|
|
||||||
|
|
||||||
|
def own_user_id(slot):
|
||||||
|
from org.telegram.messenger import UserConfig
|
||||||
|
|
||||||
|
return int(UserConfig.getInstance(int(slot)).getClientUserId())
|
||||||
|
|
||||||
|
|
||||||
|
class Catalog:
|
||||||
|
def __init__(self, plugin, api, library):
|
||||||
|
self.plugin = plugin
|
||||||
|
self.api = api
|
||||||
|
self.library = library
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._server = {} # tg id -> {"commands": [Command], "account": name, "fetched": epoch}
|
||||||
|
self._token_of = {} # tg id -> token
|
||||||
|
self._due = {} # token -> epoch of the next refresh
|
||||||
|
self._errors = {} # token -> last error text
|
||||||
|
self._wake = threading.Event()
|
||||||
|
self._running = False
|
||||||
|
self._listeners = []
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
# commands
|
||||||
|
|
||||||
|
def commands_for(self, slot):
|
||||||
|
"""[Command] of the account in `slot`: the panel's, then plugins'."""
|
||||||
|
out, names = [], set()
|
||||||
|
if self.plugin.get_setting(SERVER_COMMANDS, True):
|
||||||
|
with self._lock:
|
||||||
|
entry = self._server.get(own_user_id(slot))
|
||||||
|
for command in entry["commands"] if entry else ():
|
||||||
|
out.append(command)
|
||||||
|
names.update(command.names)
|
||||||
|
for command in self.library.local_commands():
|
||||||
|
if not names.intersection(command.names):
|
||||||
|
out.append(command)
|
||||||
|
names.update(command.names)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def find(self, slot, name):
|
||||||
|
name = (name or "").lower()
|
||||||
|
for command in self.commands_for(slot):
|
||||||
|
if name in command.names:
|
||||||
|
return command
|
||||||
|
return None
|
||||||
|
|
||||||
|
def token_for(self, slot):
|
||||||
|
"""The token of the account in `slot`, learning it if needed
|
||||||
|
(blocking: not on the UI thread)."""
|
||||||
|
tg_id = own_user_id(slot)
|
||||||
|
with self._lock:
|
||||||
|
token = self._token_of.get(tg_id)
|
||||||
|
if token is None:
|
||||||
|
for candidate in self.api.tokens():
|
||||||
|
if candidate not in self._token_of.values():
|
||||||
|
try:
|
||||||
|
self._learn(candidate)
|
||||||
|
except ApiError:
|
||||||
|
continue
|
||||||
|
with self._lock:
|
||||||
|
token = self._token_of.get(tg_id)
|
||||||
|
return token
|
||||||
|
|
||||||
|
def account_name(self, slot):
|
||||||
|
with self._lock:
|
||||||
|
entry = self._server.get(own_user_id(slot))
|
||||||
|
return entry.get("account") if entry else None
|
||||||
|
|
||||||
|
def on_change(self, listener):
|
||||||
|
self._listeners.append(listener)
|
||||||
|
|
||||||
|
# refreshing
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
self._running = True
|
||||||
|
thread = threading.Thread(target=self._loop, name="client-commands-catalog", daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._running = False
|
||||||
|
self._wake.set()
|
||||||
|
|
||||||
|
def refresh(self, reset=False):
|
||||||
|
"""Asks the panel again now; `reset` forgets which token is whose
|
||||||
|
(the tokens changed)."""
|
||||||
|
with self._lock:
|
||||||
|
self._due.clear()
|
||||||
|
if reset:
|
||||||
|
self._token_of.clear()
|
||||||
|
self._errors.clear()
|
||||||
|
self._wake.set()
|
||||||
|
|
||||||
|
def errors(self):
|
||||||
|
with self._lock:
|
||||||
|
return dict(self._errors)
|
||||||
|
|
||||||
|
def _loop(self):
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
delay = self._refresh_due()
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("catalog refresh", exc)
|
||||||
|
delay = RETRY_SECONDS
|
||||||
|
self._wake.wait(max(5, delay))
|
||||||
|
self._wake.clear()
|
||||||
|
|
||||||
|
def _refresh_due(self):
|
||||||
|
"""Refreshes the tokens due; seconds until the next one is."""
|
||||||
|
tokens = self.api.tokens() if self.api.configured else []
|
||||||
|
now = time.time()
|
||||||
|
for token in tokens:
|
||||||
|
if self._due.get(token, 0) <= now:
|
||||||
|
self._refresh(token)
|
||||||
|
with self._lock:
|
||||||
|
for token in list(self._due):
|
||||||
|
if token not in tokens:
|
||||||
|
self._due.pop(token)
|
||||||
|
self._errors.pop(token, None)
|
||||||
|
live = {tg for tg, token in self._token_of.items() if token in tokens}
|
||||||
|
dropped = [tg for tg in self._server if tg not in live and not self._errors]
|
||||||
|
for tg in dropped:
|
||||||
|
self._server.pop(tg)
|
||||||
|
self._token_of = {tg: t for tg, t in self._token_of.items() if t in tokens}
|
||||||
|
if dropped:
|
||||||
|
self._save()
|
||||||
|
self._changed()
|
||||||
|
upcoming = [self._due[t] for t in tokens if t in self._due]
|
||||||
|
return (min(upcoming) - time.time()) if upcoming else REFRESH_SECONDS
|
||||||
|
|
||||||
|
def _learn(self, token):
|
||||||
|
me = self.api.me(token)
|
||||||
|
tg_id = int(me.get("tg_id") or 0)
|
||||||
|
if not tg_id:
|
||||||
|
raise ApiError("the panel doesn't know this account's Telegram id yet")
|
||||||
|
with self._lock:
|
||||||
|
self._token_of[tg_id] = token
|
||||||
|
return tg_id, me
|
||||||
|
|
||||||
|
def _refresh(self, token):
|
||||||
|
try:
|
||||||
|
tg_id, me = self._learn(token)
|
||||||
|
listing = self.api.commands(token)
|
||||||
|
commands = []
|
||||||
|
for data in listing.get("commands") or ():
|
||||||
|
try:
|
||||||
|
commands.append(Command.from_json(data))
|
||||||
|
except (KeyError, ValueError) as exc:
|
||||||
|
diag.log(f"skipped command {data.get('name')!r}: {exc}")
|
||||||
|
except ApiError as exc:
|
||||||
|
diag.log(f"refresh {token[:10]}…: {exc}")
|
||||||
|
with self._lock:
|
||||||
|
self._errors[token] = str(exc)
|
||||||
|
self._due[token] = time.time() + (REFRESH_SECONDS if exc.refused else RETRY_SECONDS)
|
||||||
|
return
|
||||||
|
refresh = int(listing.get("refresh_seconds") or REFRESH_SECONDS)
|
||||||
|
with self._lock:
|
||||||
|
self._errors.pop(token, None)
|
||||||
|
self._due[token] = time.time() + max(60, refresh)
|
||||||
|
self._server[tg_id] = {
|
||||||
|
"commands": commands,
|
||||||
|
"account": me.get("account_name"),
|
||||||
|
"fetched": time.time(),
|
||||||
|
}
|
||||||
|
diag.log(f"{len(commands)} commands for {tg_id}")
|
||||||
|
self._save()
|
||||||
|
self._changed()
|
||||||
|
|
||||||
|
def _changed(self):
|
||||||
|
for listener in list(self._listeners):
|
||||||
|
try:
|
||||||
|
listener()
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("catalog listener", exc)
|
||||||
|
|
||||||
|
# disk cache
|
||||||
|
|
||||||
|
def _path(self):
|
||||||
|
try:
|
||||||
|
from file_utils import get_files_dir
|
||||||
|
|
||||||
|
directory = os.path.join(get_files_dir(), "client_commands")
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
return os.path.join(directory, "catalog.json")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _load(self):
|
||||||
|
path = self._path()
|
||||||
|
if not path or not os.path.exists(path):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as handle:
|
||||||
|
data = json.load(handle)
|
||||||
|
for tg_id, entry in data.items():
|
||||||
|
self._server[int(tg_id)] = {
|
||||||
|
"commands": [Command.from_json(c) for c in entry.get("commands") or ()],
|
||||||
|
"account": entry.get("account"),
|
||||||
|
"fetched": float(entry.get("fetched") or 0),
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("catalog load", exc)
|
||||||
|
|
||||||
|
def _save(self):
|
||||||
|
path = self._path()
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
data = {
|
||||||
|
str(tg_id): {
|
||||||
|
"commands": [c.to_json() for c in entry["commands"]],
|
||||||
|
"account": entry.get("account"),
|
||||||
|
"fetched": entry.get("fetched"),
|
||||||
|
}
|
||||||
|
for tg_id, entry in self._server.items()
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with open(path + ".tmp", "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(data, handle)
|
||||||
|
os.replace(path + ".tmp", path)
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("catalog save", exc)
|
||||||
Whitespace-only changes.
@@ -0,0 +1,106 @@
|
|||||||
|
"""Telegram-style HTML -> (text, entities), for responses of commands that
|
||||||
|
run on the device (server responses arrive parsed). Offsets are UTF-16 code
|
||||||
|
units, as Telegram counts them. Pure Python: tested offline.
|
||||||
|
|
||||||
|
Tags: b strong i em u ins s strike del code pre (`<code class="language-x">`
|
||||||
|
inside), a href (tg://user?id=N mentions), blockquote [expandable],
|
||||||
|
tg-spoiler / span class="tg-spoiler", tg-emoji emoji-id, br.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
|
_SIMPLE = {
|
||||||
|
"b": "bold",
|
||||||
|
"strong": "bold",
|
||||||
|
"i": "italic",
|
||||||
|
"em": "italic",
|
||||||
|
"u": "underline",
|
||||||
|
"ins": "underline",
|
||||||
|
"s": "strike",
|
||||||
|
"strike": "strike",
|
||||||
|
"del": "strike",
|
||||||
|
"code": "code",
|
||||||
|
"pre": "pre",
|
||||||
|
"blockquote": "blockquote",
|
||||||
|
"tg-spoiler": "spoiler",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def utf16_len(text):
|
||||||
|
return len(text.encode("utf-16-le")) // 2
|
||||||
|
|
||||||
|
|
||||||
|
class _Parser(HTMLParser):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(convert_charrefs=True)
|
||||||
|
self.parts = []
|
||||||
|
self.length = 0 # UTF-16 units so far
|
||||||
|
self.open = [] # [(tag, entity or None)]
|
||||||
|
self.entities = []
|
||||||
|
|
||||||
|
def _entity(self, tag, attrs):
|
||||||
|
attrs = dict(attrs)
|
||||||
|
if tag == "code" and any(t == "pre" for t, _ in self.open):
|
||||||
|
pre = next(e for t, e in reversed(self.open) if t == "pre")
|
||||||
|
language = (attrs.get("class") or "").removeprefix("language-")
|
||||||
|
if pre is not None and language:
|
||||||
|
pre["language"] = language
|
||||||
|
return None
|
||||||
|
if tag == "span" and attrs.get("class") == "tg-spoiler":
|
||||||
|
return {"type": "spoiler"}
|
||||||
|
if tag in _SIMPLE:
|
||||||
|
entity = {"type": _SIMPLE[tag]}
|
||||||
|
if tag == "blockquote" and "expandable" in attrs:
|
||||||
|
entity["collapsed"] = True
|
||||||
|
return entity
|
||||||
|
if tag == "a" and attrs.get("href"):
|
||||||
|
href = attrs["href"]
|
||||||
|
if href.startswith("tg://user?id="):
|
||||||
|
try:
|
||||||
|
return {"type": "mention_name", "user_id": int(href.split("=", 1)[1])}
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return {"type": "text_link", "url": href}
|
||||||
|
if tag == "tg-emoji":
|
||||||
|
try:
|
||||||
|
return {"type": "custom_emoji", "document_id": int(attrs.get("emoji-id"))}
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
if tag == "br":
|
||||||
|
self.handle_data("\n")
|
||||||
|
return
|
||||||
|
entity = self._entity(tag, attrs)
|
||||||
|
if entity is not None:
|
||||||
|
entity["offset"] = self.length
|
||||||
|
self.open.append((tag, entity))
|
||||||
|
|
||||||
|
def handle_endtag(self, tag):
|
||||||
|
for index in range(len(self.open) - 1, -1, -1):
|
||||||
|
if self.open[index][0] == tag:
|
||||||
|
_, entity = self.open.pop(index)
|
||||||
|
if entity is not None:
|
||||||
|
entity["length"] = self.length - entity["offset"]
|
||||||
|
if entity["length"] > 0:
|
||||||
|
self.entities.append(entity)
|
||||||
|
return
|
||||||
|
|
||||||
|
def handle_data(self, data):
|
||||||
|
self.parts.append(data)
|
||||||
|
self.length += utf16_len(data)
|
||||||
|
|
||||||
|
|
||||||
|
def parse(html):
|
||||||
|
"""(plain text, entities) of `html`; entities ordered by offset."""
|
||||||
|
parser = _Parser()
|
||||||
|
parser.feed(html or "")
|
||||||
|
parser.close()
|
||||||
|
entities = sorted(parser.entities, key=lambda e: (e["offset"], -e["length"]))
|
||||||
|
return "".join(parser.parts), entities
|
||||||
|
|
||||||
|
|
||||||
|
def escape(text):
|
||||||
|
"""`text` made safe to put into a response."""
|
||||||
|
return str(text).replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""Commands and their parameters, as the panel lists them (and as plugins
|
||||||
|
register them through the library). Pure Python: tested offline."""
|
||||||
|
|
||||||
|
TYPES = ("string", "integer", "number", "boolean", "choice")
|
||||||
|
REPLY_MODES = ("none", "optional", "required")
|
||||||
|
|
||||||
|
# What the composer shows for a parameter's type.
|
||||||
|
TYPE_LABELS = {
|
||||||
|
"string": "text",
|
||||||
|
"integer": "whole number",
|
||||||
|
"number": "number",
|
||||||
|
"boolean": "yes / no",
|
||||||
|
"choice": "choice",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Param:
|
||||||
|
__slots__ = ("name", "type", "required", "description", "default", "choices")
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name,
|
||||||
|
type="string",
|
||||||
|
required=True,
|
||||||
|
description="",
|
||||||
|
default=None,
|
||||||
|
choices=(),
|
||||||
|
):
|
||||||
|
if type not in TYPES:
|
||||||
|
raise ValueError(f"unknown parameter type {type!r}")
|
||||||
|
self.name = str(name)
|
||||||
|
self.type = type
|
||||||
|
self.required = bool(required)
|
||||||
|
self.description = str(description or "")
|
||||||
|
self.default = default
|
||||||
|
self.choices = tuple(str(c) for c in choices or ())
|
||||||
|
if self.type == "choice" and not self.choices:
|
||||||
|
raise ValueError(f"choice parameter {name!r} needs choices")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_json(cls, data):
|
||||||
|
kind = data.get("type") or "string"
|
||||||
|
return cls(
|
||||||
|
data["name"],
|
||||||
|
kind if kind in TYPES else "string",
|
||||||
|
data.get("required", True),
|
||||||
|
data.get("description") or "",
|
||||||
|
data.get("default"),
|
||||||
|
data.get("choices") or (),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_json(self):
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"type": self.type,
|
||||||
|
"required": self.required,
|
||||||
|
"description": self.description,
|
||||||
|
"default": self.default,
|
||||||
|
"choices": list(self.choices),
|
||||||
|
}
|
||||||
|
|
||||||
|
def placeholder(self):
|
||||||
|
"""`<name>` when required, `[name]` when optional."""
|
||||||
|
return f"<{self.name}>" if self.required else f"[{self.name}]"
|
||||||
|
|
||||||
|
def type_label(self):
|
||||||
|
if self.type == "choice":
|
||||||
|
return " | ".join(self.choices)
|
||||||
|
return TYPE_LABELS.get(self.type, self.type)
|
||||||
|
|
||||||
|
|
||||||
|
class Command:
|
||||||
|
"""A command: `source` is "server" (the panel runs it) or the id of the
|
||||||
|
plugin that registered it (then `handler` runs it on the device)."""
|
||||||
|
|
||||||
|
__slots__ = (
|
||||||
|
"name",
|
||||||
|
"aliases",
|
||||||
|
"summary",
|
||||||
|
"description",
|
||||||
|
"params",
|
||||||
|
"reply",
|
||||||
|
"source",
|
||||||
|
"handler",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name,
|
||||||
|
params=(),
|
||||||
|
summary="",
|
||||||
|
description="",
|
||||||
|
aliases=(),
|
||||||
|
reply="none",
|
||||||
|
source="server",
|
||||||
|
handler=None,
|
||||||
|
):
|
||||||
|
name = str(name).lstrip("/").lower()
|
||||||
|
if not name or not all(c.isalnum() and c.isascii() or c == "_" for c in name):
|
||||||
|
raise ValueError(f"bad command name {name!r}: use a-z, 0-9 and _")
|
||||||
|
if reply not in REPLY_MODES:
|
||||||
|
raise ValueError(f"reply must be one of {REPLY_MODES}")
|
||||||
|
self.name = name
|
||||||
|
self.aliases = tuple(str(a).lstrip("/").lower() for a in aliases or ())
|
||||||
|
self.summary = str(summary or "")
|
||||||
|
self.description = str(description or "")
|
||||||
|
self.params = tuple(params or ())
|
||||||
|
self.reply = reply
|
||||||
|
self.source = source
|
||||||
|
self.handler = handler
|
||||||
|
names = [p.name.lower() for p in self.params]
|
||||||
|
if len(set(names)) != len(names):
|
||||||
|
raise ValueError(f"/{name}: parameter names must be unique")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def names(self):
|
||||||
|
return (self.name,) + self.aliases
|
||||||
|
|
||||||
|
@property
|
||||||
|
def local(self):
|
||||||
|
return self.source != "server"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_json(cls, data, source="server"):
|
||||||
|
return cls(
|
||||||
|
data["name"],
|
||||||
|
[Param.from_json(p) for p in data.get("params") or ()],
|
||||||
|
data.get("summary") or "",
|
||||||
|
data.get("description") or "",
|
||||||
|
data.get("aliases") or (),
|
||||||
|
data.get("reply") if data.get("reply") in REPLY_MODES else "none",
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_json(self):
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"aliases": list(self.aliases),
|
||||||
|
"summary": self.summary,
|
||||||
|
"description": self.description,
|
||||||
|
"params": [p.to_json() for p in self.params],
|
||||||
|
"reply": self.reply,
|
||||||
|
}
|
||||||
|
|
||||||
|
def usage(self):
|
||||||
|
return " ".join(["/" + self.name] + [p.placeholder() for p in self.params])
|
||||||
|
|
||||||
|
def hint(self):
|
||||||
|
"""The suggestion row's second line: parameters, then the summary."""
|
||||||
|
parts = [p.placeholder() for p in self.params]
|
||||||
|
if self.reply == "required":
|
||||||
|
parts.append("↩ reply")
|
||||||
|
head = " ".join(parts)
|
||||||
|
if head and self.summary:
|
||||||
|
return f"{head} · {self.summary}"
|
||||||
|
return head or self.summary
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""Discord-style arguments of a `/command`. Pure Python: tested offline.
|
||||||
|
|
||||||
|
Mirrors telegram-panel's `app/telegram/client_commands/parser.py`, which
|
||||||
|
parses the same text again on the server: keep the two in step.
|
||||||
|
|
||||||
|
- `/name args` (`/name@anything` too); the name is case-insensitive.
|
||||||
|
- Arguments split like a shell (quotes group words).
|
||||||
|
- `param:value` sets a parameter by name; other words fill the remaining
|
||||||
|
parameters in order, and the last remaining one, when a string, takes all
|
||||||
|
the words left over.
|
||||||
|
- An empty `param:` counts as not given.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
|
||||||
|
_COMMAND = re.compile(r"^/([A-Za-z0-9_]{1,64})(?:@\S*)?(?:\s+|$)")
|
||||||
|
_TRUE = {"true", "yes", "y", "on", "1", "+"}
|
||||||
|
_FALSE = {"false", "no", "n", "off", "0", "-"}
|
||||||
|
|
||||||
|
|
||||||
|
class CommandError(Exception):
|
||||||
|
"""A user-facing command error (bad arguments, nothing to act on, ...)."""
|
||||||
|
|
||||||
|
|
||||||
|
def split_command(text):
|
||||||
|
"""(lowercased name, argument text) of a `/command`, else None."""
|
||||||
|
text = (text or "").strip()
|
||||||
|
match = _COMMAND.match(text)
|
||||||
|
if match is None:
|
||||||
|
return None
|
||||||
|
return match.group(1).lower(), text[match.end():]
|
||||||
|
|
||||||
|
|
||||||
|
def command_name_at(text):
|
||||||
|
"""The name being typed while the text is a bare `/prefix` (no space
|
||||||
|
yet), else None: suggestions are shown for it."""
|
||||||
|
match = re.match(r"^/([A-Za-z0-9_]{0,64})$", text or "")
|
||||||
|
return match.group(1).lower() if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def _convert(param, raw):
|
||||||
|
if param.type == "integer":
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise CommandError(f"{param.name} must be a whole number") from None
|
||||||
|
if param.type == "number":
|
||||||
|
try:
|
||||||
|
return float(raw.replace(",", "."))
|
||||||
|
except ValueError:
|
||||||
|
raise CommandError(f"{param.name} must be a number") from None
|
||||||
|
if param.type == "boolean":
|
||||||
|
if raw.lower() in _TRUE:
|
||||||
|
return True
|
||||||
|
if raw.lower() in _FALSE:
|
||||||
|
return False
|
||||||
|
raise CommandError(f"{param.name} must be yes or no")
|
||||||
|
if param.type == "choice":
|
||||||
|
for choice in param.choices:
|
||||||
|
if choice.lower() == raw.lower():
|
||||||
|
return choice
|
||||||
|
raise CommandError(f"{param.name} must be one of: {', '.join(param.choices)}")
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def _assign(params, tokens):
|
||||||
|
"""Raw values by parameter name, and the parameter each token went to
|
||||||
|
([(token index, param name or None)])."""
|
||||||
|
by_name = {p.name.lower(): p for p in params}
|
||||||
|
raw, owners, positional = {}, [], []
|
||||||
|
for index, token in enumerate(tokens):
|
||||||
|
key, sep, value = token.partition(":")
|
||||||
|
param = by_name.get(key.lower()) if sep else None
|
||||||
|
if param is None:
|
||||||
|
positional.append(index)
|
||||||
|
continue
|
||||||
|
if param.name in raw:
|
||||||
|
raise CommandError(f"{param.name} is given twice")
|
||||||
|
owners.append((index, param.name))
|
||||||
|
if value:
|
||||||
|
raw[param.name] = value
|
||||||
|
free = [p for p in params if p.name not in raw]
|
||||||
|
greedy = bool(free) and free[-1].type == "string"
|
||||||
|
if len(positional) > len(free) and not greedy:
|
||||||
|
extra = " ".join(tokens[i] for i in positional[len(free):])
|
||||||
|
raise CommandError(f"Unexpected arguments: {extra}")
|
||||||
|
for slot, param in enumerate(free[: len(positional)]):
|
||||||
|
if slot == len(free) - 1 and greedy:
|
||||||
|
rest = positional[slot:]
|
||||||
|
raw[param.name] = " ".join(tokens[i] for i in rest)
|
||||||
|
owners += [(i, param.name) for i in rest]
|
||||||
|
else:
|
||||||
|
raw[param.name] = tokens[positional[slot]]
|
||||||
|
owners.append((positional[slot], param.name))
|
||||||
|
return raw, owners
|
||||||
|
|
||||||
|
|
||||||
|
def assign(params, args):
|
||||||
|
"""Raw string values by parameter name."""
|
||||||
|
try:
|
||||||
|
tokens = shlex.split(args)
|
||||||
|
except ValueError:
|
||||||
|
raise CommandError("Unclosed quote") from None
|
||||||
|
return _assign(params, tokens)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def parse(command, text):
|
||||||
|
"""Handler keyword arguments for `text` (`/name ...`)."""
|
||||||
|
split = split_command(text)
|
||||||
|
if split is None or split[0] not in command.names:
|
||||||
|
raise CommandError(f"Not a /{command.name} command")
|
||||||
|
raw = assign(command.params, split[1])
|
||||||
|
values = {}
|
||||||
|
for param in command.params:
|
||||||
|
if param.name in raw:
|
||||||
|
values[param.name] = _convert(param, raw[param.name])
|
||||||
|
elif param.required:
|
||||||
|
raise CommandError(f"Missing required parameter: {param.name}")
|
||||||
|
else:
|
||||||
|
values[param.name] = param.default
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def _tokens(args):
|
||||||
|
"""Shell-like words of `args` with their spans; tolerant of an unclosed
|
||||||
|
quote (the word runs to the end), for analysing text being typed."""
|
||||||
|
out, start, word, quote = [], None, [], None
|
||||||
|
for index, char in enumerate(args):
|
||||||
|
if quote:
|
||||||
|
if char == quote:
|
||||||
|
quote = None
|
||||||
|
else:
|
||||||
|
word.append(char)
|
||||||
|
elif char in "\"'":
|
||||||
|
quote = char
|
||||||
|
start = index if start is None else start
|
||||||
|
elif char.isspace():
|
||||||
|
if start is not None:
|
||||||
|
out.append((start, index, "".join(word)))
|
||||||
|
start, word = None, []
|
||||||
|
else:
|
||||||
|
start = index if start is None else start
|
||||||
|
word.append(char)
|
||||||
|
if start is not None:
|
||||||
|
out.append((start, len(args), "".join(word)))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class Analysis:
|
||||||
|
"""What the composer shows for a command being typed."""
|
||||||
|
|
||||||
|
__slots__ = ("values", "current", "missing", "error", "complete")
|
||||||
|
|
||||||
|
def __init__(self, values, current, missing, error):
|
||||||
|
self.values = values # raw values by parameter name
|
||||||
|
self.current = current # the parameter at the cursor, or None
|
||||||
|
self.missing = missing # required parameters not given yet
|
||||||
|
self.error = error # why it can't run (None when it can)
|
||||||
|
self.complete = error is None
|
||||||
|
|
||||||
|
|
||||||
|
def analyze(command, text, cursor=None, has_reply=False):
|
||||||
|
"""Values, the parameter at `cursor`, and whether `text` can run."""
|
||||||
|
split = split_command(text)
|
||||||
|
if split is None or split[0] not in command.names:
|
||||||
|
return Analysis({}, None, [p.name for p in command.params if p.required], "Not a command")
|
||||||
|
stripped = (text or "").strip()
|
||||||
|
args = split[1]
|
||||||
|
offset = len(text) - len(text.lstrip()) + len(stripped) - len(args)
|
||||||
|
cursor = len(text) if cursor is None else cursor
|
||||||
|
at = cursor - offset
|
||||||
|
tokens = _tokens(args)
|
||||||
|
error = None
|
||||||
|
try:
|
||||||
|
raw, owners = _assign(command.params, [t[2] for t in tokens])
|
||||||
|
except CommandError as exc:
|
||||||
|
raw, owners, error = {}, [], str(exc)
|
||||||
|
missing = [p.name for p in command.params if p.required and p.name not in raw]
|
||||||
|
current = None
|
||||||
|
if at >= 0:
|
||||||
|
inside = [i for i, (start, end, _) in enumerate(tokens) if start <= at <= end]
|
||||||
|
if inside:
|
||||||
|
current = dict(owners).get(inside[-1])
|
||||||
|
key, sep, _ = tokens[inside[-1]][2].partition(":")
|
||||||
|
if current is None and sep:
|
||||||
|
current = next((p.name for p in command.params if p.name.lower() == key.lower()), None)
|
||||||
|
else:
|
||||||
|
current = next((p.name for p in command.params if p.name not in raw), None)
|
||||||
|
if current is None and command.params and command.params[-1].type == "string":
|
||||||
|
current = command.params[-1].name # the greedy last one goes on
|
||||||
|
if error is None:
|
||||||
|
try:
|
||||||
|
parse(command, text)
|
||||||
|
except CommandError as exc:
|
||||||
|
error = str(exc)
|
||||||
|
if error is None and command.reply == "required" and not has_reply:
|
||||||
|
error = f"Reply to a message to use /{command.name}"
|
||||||
|
return Analysis(raw, current, missing, error)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Setting keys, defaults and constants."""
|
||||||
|
|
||||||
|
SERVER_URL = "server_url"
|
||||||
|
API_TOKENS = "api_tokens"
|
||||||
|
SEND_TO_CHAT = "send_to_chat"
|
||||||
|
SERVER_COMMANDS = "server_commands"
|
||||||
|
SHOW_PARAMS = "show_params"
|
||||||
|
|
||||||
|
DEFAULTS = {
|
||||||
|
SERVER_URL: "",
|
||||||
|
API_TOKENS: "",
|
||||||
|
SEND_TO_CHAT: False,
|
||||||
|
SERVER_COMMANDS: True,
|
||||||
|
SHOW_PARAMS: True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# The panel's list of an account's commands is asked for again after this
|
||||||
|
# long (the server may say otherwise in `refresh_seconds`).
|
||||||
|
REFRESH_SECONDS = 3600
|
||||||
|
# A failed refresh is retried sooner.
|
||||||
|
RETRY_SECONDS = 300
|
||||||
|
HTTP_TIMEOUT_SECONDS = 15
|
||||||
|
# Running a command may take a while on the server (it times out at 60 s).
|
||||||
|
EXECUTE_TIMEOUT_SECONDS = 75
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
"""Diagnostics mirrored to a file, since this device's logcat surfaces neither
|
||||||
|
Chaquopy stdout nor android_utils.log output.
|
||||||
|
|
||||||
|
The file lives under `get_files_dir()` (app external storage), so it is
|
||||||
|
readable over adb:
|
||||||
|
adb shell cat "/sdcard/Android/data/com.exteragram.messenger/files/exteraGram/exteraGram Files/client_commands/diag.log"
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
_MAX_BYTES = 256 * 1024
|
||||||
|
_path = None
|
||||||
|
|
||||||
|
|
||||||
|
def _file():
|
||||||
|
global _path
|
||||||
|
if _path is None:
|
||||||
|
try:
|
||||||
|
from file_utils import get_files_dir
|
||||||
|
|
||||||
|
directory = os.path.join(get_files_dir(), "client_commands")
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
_path = os.path.join(directory, "diag.log")
|
||||||
|
except Exception:
|
||||||
|
_path = ""
|
||||||
|
return _path
|
||||||
|
|
||||||
|
|
||||||
|
def log(message):
|
||||||
|
line = time.strftime("%H:%M:%S ") + str(message)
|
||||||
|
try:
|
||||||
|
from android_utils import log as android_log
|
||||||
|
|
||||||
|
android_log("[client_commands] " + line)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
path = _file()
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
mode = "a"
|
||||||
|
if os.path.exists(path) and os.path.getsize(path) > _MAX_BYTES:
|
||||||
|
mode = "w"
|
||||||
|
with open(path, mode, encoding="utf-8") as handle:
|
||||||
|
handle.write(line + "\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def error(where, exc=None):
|
||||||
|
detail = traceback.format_exc() if exc is not None else ""
|
||||||
|
log(f"ERROR {where}: {exc!r}\n{detail}")
|
||||||
@@ -1 +0,0 @@
|
|||||||
from .template import render, TemplateError
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
from textutil import normalize
|
|
||||||
|
|
||||||
|
|
||||||
class TemplateError(ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def render(template: str, name: str) -> str:
|
|
||||||
normalized = normalize(name)
|
|
||||||
try:
|
|
||||||
return template.format(name=normalized)
|
|
||||||
except (KeyError, IndexError, ValueError):
|
|
||||||
raise TemplateError(f"unsupported placeholder in {template!r}")
|
|
||||||
Whitespace-only changes.
@@ -0,0 +1,266 @@
|
|||||||
|
"""The message field: a command runs instead of going to the chat.
|
||||||
|
|
||||||
|
Hooks on ChatActivityEnterView (the chat's input):
|
||||||
|
- sendMessageInternal(notify, scheduleDate, ...), behind the send button, the
|
||||||
|
keyboard's send key and the send preview: a known command runs (on the
|
||||||
|
panel, or on the device for plugins' commands). One that can't run yet
|
||||||
|
(a required parameter missing, no reply) stays in the field and a bulletin
|
||||||
|
says why. Scheduled messages are sent as typed.
|
||||||
|
- onSendLongClick(View): holding send on a command opens a menu: run it,
|
||||||
|
send it to the chat as a plain message (the panel then handles it from the
|
||||||
|
update, as without this plugin), and the "Send response in chat" check.
|
||||||
|
- SendButton.onDraw(Canvas): while that check is on and the command can run,
|
||||||
|
a small "!" badge on the send button.
|
||||||
|
|
||||||
|
Not installed, the same text is an ordinary message, which the panel handles
|
||||||
|
from the account's outgoing update.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from base_plugin import MethodHook
|
||||||
|
|
||||||
|
import diag
|
||||||
|
from commands.parse import analyze, split_command
|
||||||
|
from config import SEND_TO_CHAT
|
||||||
|
from jutil import field, hook_methods, java_class, runnable, unhook_all
|
||||||
|
from runner import Invocation
|
||||||
|
from hooks import responses
|
||||||
|
from hooks.notify import bulletin
|
||||||
|
|
||||||
|
ENTER = "org.telegram.ui.Components.ChatActivityEnterView"
|
||||||
|
SEND_BUTTON = ENTER + "$SendButton"
|
||||||
|
|
||||||
|
|
||||||
|
class Field:
|
||||||
|
"""The command typed into one input, ready to run or not."""
|
||||||
|
|
||||||
|
__slots__ = ("enter", "chat", "account", "text", "command", "reply", "analysis")
|
||||||
|
|
||||||
|
def __init__(self, enter, chat, account, text, command, reply, analysis):
|
||||||
|
self.enter = enter
|
||||||
|
self.chat = chat
|
||||||
|
self.account = account
|
||||||
|
self.text = text
|
||||||
|
self.command = command
|
||||||
|
self.reply = reply
|
||||||
|
self.analysis = analysis
|
||||||
|
|
||||||
|
|
||||||
|
def reply_of(chat):
|
||||||
|
"""The message the chat is replying to (not one of our responses)."""
|
||||||
|
reply = field(chat, "replyingMessageObject")
|
||||||
|
if reply is None or responses.is_local(reply) or int(reply.getId()) <= 0:
|
||||||
|
return None
|
||||||
|
return reply
|
||||||
|
|
||||||
|
|
||||||
|
def topic_of(chat):
|
||||||
|
try:
|
||||||
|
return int(chat.getTopicId()) if field(chat, "isTopic") else 0
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _icon(*names):
|
||||||
|
from org.telegram.messenger import R
|
||||||
|
|
||||||
|
for name in names:
|
||||||
|
value = getattr(R.drawable, name, None)
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
class Composer:
|
||||||
|
def __init__(self, plugin, catalog, runner):
|
||||||
|
self.plugin = plugin
|
||||||
|
self.catalog = catalog
|
||||||
|
self.runner = runner
|
||||||
|
self.handles = []
|
||||||
|
self._badge = {} # (text, reply id) -> whether it can run
|
||||||
|
self._paints = []
|
||||||
|
|
||||||
|
def install(self):
|
||||||
|
composer = self
|
||||||
|
|
||||||
|
class Send(MethodHook):
|
||||||
|
def before_hooked_method(self, param):
|
||||||
|
try:
|
||||||
|
if int(param.args[1]) == 0 and composer.submit(param.thisObject):
|
||||||
|
param.setResult(False)
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("send", exc)
|
||||||
|
|
||||||
|
class LongSend(MethodHook):
|
||||||
|
def before_hooked_method(self, param):
|
||||||
|
try:
|
||||||
|
if composer.menu(param.thisObject, param.args[0]):
|
||||||
|
param.setResult(True)
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("send menu", exc)
|
||||||
|
|
||||||
|
class Badge(MethodHook):
|
||||||
|
def after_hooked_method(self, param):
|
||||||
|
try:
|
||||||
|
composer.draw_badge(param.thisObject, param.args[0])
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("send badge", exc)
|
||||||
|
|
||||||
|
self.handles += hook_methods(self.plugin, ENTER, "sendMessageInternal", Send(), arity=5)
|
||||||
|
self.handles += hook_methods(self.plugin, ENTER, "onSendLongClick", LongSend(), arity=1)
|
||||||
|
self.handles += hook_methods(self.plugin, SEND_BUTTON, "onDraw", Badge(), arity=1)
|
||||||
|
|
||||||
|
def uninstall(self):
|
||||||
|
unhook_all(self.plugin, self.handles)
|
||||||
|
|
||||||
|
# the field
|
||||||
|
|
||||||
|
def read(self, enter):
|
||||||
|
"""The Field of a known command typed into `enter`, else None."""
|
||||||
|
chat = field(enter, "parentFragment")
|
||||||
|
if chat is None or int(chat.getChatMode()) != 0 or field(enter, "editingMessageObject") is not None:
|
||||||
|
return None
|
||||||
|
text = enter.getFieldText()
|
||||||
|
text = str(text) if text is not None else ""
|
||||||
|
split = split_command(text)
|
||||||
|
if split is None:
|
||||||
|
return None
|
||||||
|
account = int(chat.getCurrentAccount())
|
||||||
|
command = self.catalog.find(account, split[0])
|
||||||
|
if command is None:
|
||||||
|
return None
|
||||||
|
reply = reply_of(chat)
|
||||||
|
edit = enter.getEditField()
|
||||||
|
cursor = int(edit.getSelectionEnd()) if edit is not None else len(text)
|
||||||
|
analysis = analyze(command, text, cursor, reply is not None)
|
||||||
|
return Field(enter, chat, account, text, command, reply, analysis)
|
||||||
|
|
||||||
|
def set_text(self, enter, text):
|
||||||
|
enter.setFieldText(text)
|
||||||
|
|
||||||
|
def submit(self, enter):
|
||||||
|
"""Runs the command typed into `enter`; False when it isn't one (or
|
||||||
|
should go to the chat as typed)."""
|
||||||
|
typed = self.read(enter)
|
||||||
|
if typed is None or self.runner.bypassed(typed.text):
|
||||||
|
return False
|
||||||
|
if not typed.analysis.complete:
|
||||||
|
bulletin(typed.analysis.error, error=True)
|
||||||
|
return True
|
||||||
|
chat = typed.chat
|
||||||
|
inv = Invocation(
|
||||||
|
typed.account,
|
||||||
|
chat.getDialogId(),
|
||||||
|
topic_of(chat),
|
||||||
|
typed.reply,
|
||||||
|
chat.getThreadMessage(),
|
||||||
|
typed.text,
|
||||||
|
typed.command,
|
||||||
|
self.plugin.get_setting(SEND_TO_CHAT, False),
|
||||||
|
)
|
||||||
|
enter.setFieldText("")
|
||||||
|
if typed.reply is not None:
|
||||||
|
chat.hideFieldPanel(False)
|
||||||
|
self.runner.run(inv)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def send_as_text(self, enter):
|
||||||
|
typed = enter.getFieldText()
|
||||||
|
if typed is None:
|
||||||
|
return
|
||||||
|
self.runner.bypass(str(typed))
|
||||||
|
enter.sendMessage()
|
||||||
|
|
||||||
|
# holding send
|
||||||
|
|
||||||
|
def menu(self, enter, button):
|
||||||
|
typed = self.read(enter)
|
||||||
|
if typed is None:
|
||||||
|
return False
|
||||||
|
from org.telegram.ui.Components import ItemOptions
|
||||||
|
|
||||||
|
name = typed.command.name
|
||||||
|
checked = bool(self.plugin.get_setting(SEND_TO_CHAT, False))
|
||||||
|
|
||||||
|
def toggle():
|
||||||
|
self.plugin.set_setting(SEND_TO_CHAT, not checked)
|
||||||
|
self.refresh(enter)
|
||||||
|
|
||||||
|
options = ItemOptions.makeOptions(typed.chat, button)
|
||||||
|
options.add(_icon("msg_bot", "msg_bots", "msg_send"), f"Run /{name}", runnable(lambda: self.submit(enter)))
|
||||||
|
options.add(_icon("msg_send"), "Send as message", runnable(lambda: self.send_as_text(enter)))
|
||||||
|
options.addChecked(checked, _icon("msg_discussion", "msg_message", "msg_chats"), "Send response in chat", runnable(toggle))
|
||||||
|
options.show()
|
||||||
|
return True
|
||||||
|
|
||||||
|
# the badge
|
||||||
|
|
||||||
|
def refresh(self, enter):
|
||||||
|
"""Redraws the send button (the field or the setting changed)."""
|
||||||
|
button = field(enter, "sendButton")
|
||||||
|
if button is not None:
|
||||||
|
button.invalidate()
|
||||||
|
|
||||||
|
def _enter_of(self, button):
|
||||||
|
enter_class = java_class(ENTER)
|
||||||
|
view = button.getParent()
|
||||||
|
while view is not None:
|
||||||
|
if enter_class is not None and enter_class.isInstance(view):
|
||||||
|
return view
|
||||||
|
view = view.getParent() if hasattr(view, "getParent") else None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _can_run(self, enter):
|
||||||
|
text = enter.getFieldText()
|
||||||
|
if text is None or not str(text).startswith("/"):
|
||||||
|
return False
|
||||||
|
chat = field(enter, "parentFragment")
|
||||||
|
reply = reply_of(chat) if chat is not None else None
|
||||||
|
key = (str(text), int(reply.getId()) if reply is not None else 0)
|
||||||
|
if key not in self._badge:
|
||||||
|
typed = self.read(enter)
|
||||||
|
if len(self._badge) > 64:
|
||||||
|
self._badge.clear()
|
||||||
|
self._badge[key] = typed is not None and typed.analysis.complete
|
||||||
|
return self._badge[key]
|
||||||
|
|
||||||
|
def draw_badge(self, button, canvas):
|
||||||
|
if not self.plugin.get_setting(SEND_TO_CHAT, False):
|
||||||
|
return
|
||||||
|
enter = self._enter_of(button)
|
||||||
|
if enter is None or not self._can_run(enter):
|
||||||
|
return
|
||||||
|
from android.graphics import Paint
|
||||||
|
from java import jint
|
||||||
|
from org.telegram.messenger import AndroidUtilities
|
||||||
|
from org.telegram.ui.ActionBar import Theme
|
||||||
|
|
||||||
|
if not self._paints:
|
||||||
|
self._paints = [Paint(Paint.ANTI_ALIAS_FLAG) for _ in range(3)]
|
||||||
|
self._paints[1].setTextAlign(Paint.Align.CENTER)
|
||||||
|
self._paints[1].setFakeBoldText(True)
|
||||||
|
fill, mark, ring = self._paints
|
||||||
|
dp = AndroidUtilities.dp
|
||||||
|
height = button.getMeasuredHeight()
|
||||||
|
rect = field(button, "backgroundRect")
|
||||||
|
if rect is not None and field(button, "isNewDesignSendButton") and rect.width() > 0:
|
||||||
|
cx, cy, r = rect.centerX(), rect.centerY(), rect.height() / 2
|
||||||
|
else:
|
||||||
|
cx, cy, r = button.getMeasuredWidth() - height / 2, height / 2, height / 2 - dp(6)
|
||||||
|
x, y, radius = cx + r * 0.62, cy - r * 0.62, dp(7)
|
||||||
|
fill.setColor(jint(Theme.getColor(Theme.key_text_RedBold)))
|
||||||
|
mark.setColor(jint(-1))
|
||||||
|
mark.setTextSize(dp(11))
|
||||||
|
# A ring of the input's background separates it from the button.
|
||||||
|
ring.setColor(jint(Theme.getColor(Theme.key_chat_messagePanelBackground)))
|
||||||
|
canvas.drawCircle(x, y, radius + dp(1.5), ring)
|
||||||
|
canvas.drawCircle(x, y, radius, fill)
|
||||||
|
canvas.drawText("!", x, y + dp(4), mark)
|
||||||
|
|
||||||
|
def forget(self):
|
||||||
|
"""The commands changed: whether a text can run is asked again."""
|
||||||
|
self._badge.clear()
|
||||||
|
|
||||||
|
def text_changed(self, enter):
|
||||||
|
"""The field's text changed: the badge may change."""
|
||||||
|
if self.plugin.get_setting(SEND_TO_CHAT, False):
|
||||||
|
self.refresh(enter)
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
""""Send in chat" first in the menu of a response shown only to you.
|
||||||
|
|
||||||
|
Tapping a response (an ephemeral message) opens the app's message menu. The
|
||||||
|
row is added to the built popup after ChatActivity.createMenu, above its
|
||||||
|
first row (as messageLogger's "Edit history"); ChatActivity.selectedObject
|
||||||
|
is the message. Sending posts the answer (without the command above it) as
|
||||||
|
the account, replying where the command replied, and takes the local copy
|
||||||
|
away.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from base_plugin import MethodHook
|
||||||
|
|
||||||
|
import diag
|
||||||
|
from hooks import responses
|
||||||
|
from jutil import field, hook_methods, method, unhook_all
|
||||||
|
|
||||||
|
CHAT = "org.telegram.ui.ChatActivity"
|
||||||
|
LABEL = "Send in chat"
|
||||||
|
|
||||||
|
|
||||||
|
def _items(view, found):
|
||||||
|
"""Every ActionBarMenuSubItem under `view`, in order."""
|
||||||
|
if view.getClass().getSimpleName() == "ActionBarMenuSubItem":
|
||||||
|
found.append(view)
|
||||||
|
elif hasattr(view, "getChildCount"):
|
||||||
|
for index in range(view.getChildCount()):
|
||||||
|
_items(view.getChildAt(index), found)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
class ResponseMenu:
|
||||||
|
def __init__(self, plugin, runner):
|
||||||
|
self.plugin = plugin
|
||||||
|
self.runner = runner
|
||||||
|
self.handles = []
|
||||||
|
|
||||||
|
def install(self):
|
||||||
|
menu = self
|
||||||
|
|
||||||
|
class AfterCreate(MethodHook):
|
||||||
|
def after_hooked_method(self, param):
|
||||||
|
try:
|
||||||
|
menu.add_item(param.thisObject)
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("response menu", exc)
|
||||||
|
|
||||||
|
self.handles += hook_methods(self.plugin, CHAT, "createMenu", AfterCreate())
|
||||||
|
|
||||||
|
def uninstall(self):
|
||||||
|
unhook_all(self.plugin, self.handles)
|
||||||
|
|
||||||
|
def add_item(self, chat):
|
||||||
|
message = field(chat, "selectedObject")
|
||||||
|
popup = field(chat, "scrimPopupWindow")
|
||||||
|
body = responses.sendable(message) if message is not None else None
|
||||||
|
if popup is None or body is None:
|
||||||
|
return
|
||||||
|
items = _items(popup.getContentView(), [])
|
||||||
|
# The createMenu overloads call one another: add the row once.
|
||||||
|
if not items or any(str(i.getTextView().getText()) == LABEL for i in items if i.getTextView()):
|
||||||
|
return
|
||||||
|
from android_utils import OnClickListener
|
||||||
|
from org.telegram.messenger import R
|
||||||
|
from org.telegram.ui.ActionBar import ActionBarMenuSubItem
|
||||||
|
|
||||||
|
anchor = items[0]
|
||||||
|
parent = anchor.getParent()
|
||||||
|
item = ActionBarMenuSubItem(anchor.getContext(), False, False)
|
||||||
|
item.setTextAndIcon(LABEL, R.drawable.msg_send)
|
||||||
|
color = anchor.getTextView().getCurrentTextColor()
|
||||||
|
item.setColors(color, color)
|
||||||
|
item.setMinimumWidth(anchor.getMinimumWidth())
|
||||||
|
|
||||||
|
def chosen(_view):
|
||||||
|
method(CHAT, "closeMenu").invoke(chat)
|
||||||
|
self.runner.send(
|
||||||
|
body["account"],
|
||||||
|
body["dialog_id"],
|
||||||
|
body["reply"],
|
||||||
|
body["thread"],
|
||||||
|
body["text"],
|
||||||
|
body["entities"],
|
||||||
|
)
|
||||||
|
responses.remove(body["account"], body["dialog_id"], message.getId())
|
||||||
|
|
||||||
|
item.setOnClickListener(OnClickListener(chosen))
|
||||||
|
parent.addView(item, parent.indexOfChild(anchor), anchor.getLayoutParams())
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Bulletins (shown on the UI thread, from any thread)."""
|
||||||
|
|
||||||
|
from jutil import on_ui
|
||||||
|
|
||||||
|
|
||||||
|
def bulletin(text, error=False, success=False):
|
||||||
|
def show():
|
||||||
|
from ui.bulletin import BulletinHelper
|
||||||
|
|
||||||
|
if error:
|
||||||
|
BulletinHelper.show_error(text)
|
||||||
|
elif success:
|
||||||
|
BulletinHelper.show_success(text)
|
||||||
|
else:
|
||||||
|
BulletinHelper.show_info(text)
|
||||||
|
|
||||||
|
on_ui(show)
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
"""Responses shown only on this device.
|
||||||
|
|
||||||
|
They are Telegram's own ephemeral messages: a TL_message with an ephemeral
|
||||||
|
id, sent by the account itself (an outgoing bubble), handed to the open chat
|
||||||
|
the way new messages arrive (ChatActivity.processNewMessages) and never
|
||||||
|
stored. The latest 20 come back when their chat is opened again, until the
|
||||||
|
app restarts (memory only). The app labels an outgoing
|
||||||
|
ephemeral message "Only visible to @<receiver>"; the receiver is the account,
|
||||||
|
and the label is made "Only visible to you" (ChatMessageCell's
|
||||||
|
setMessageObjectInternal). Deleting one, or replying to one, stays on the
|
||||||
|
device. When the chat isn't open, a bulletin shows the text instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
|
||||||
|
from base_plugin import MethodHook
|
||||||
|
|
||||||
|
import diag
|
||||||
|
from commands.markup import utf16_len
|
||||||
|
from jutil import array_list, field, hook_methods, java_class, method, set_field, unhook_all
|
||||||
|
|
||||||
|
CHAT = "org.telegram.ui.ChatActivity"
|
||||||
|
|
||||||
|
_ENTITY_CLASSES = {
|
||||||
|
"bold": "TL_messageEntityBold",
|
||||||
|
"italic": "TL_messageEntityItalic",
|
||||||
|
"underline": "TL_messageEntityUnderline",
|
||||||
|
"strike": "TL_messageEntityStrike",
|
||||||
|
"code": "TL_messageEntityCode",
|
||||||
|
"pre": "TL_messageEntityPre",
|
||||||
|
"text_link": "TL_messageEntityTextUrl",
|
||||||
|
"url": "TL_messageEntityUrl",
|
||||||
|
"email": "TL_messageEntityEmail",
|
||||||
|
"blockquote": "TL_messageEntityBlockquote",
|
||||||
|
"custom_emoji": "TL_messageEntityCustomEmoji",
|
||||||
|
"mention_name": "TL_messageEntityMentionName",
|
||||||
|
"spoiler": "TL_messageEntitySpoiler",
|
||||||
|
}
|
||||||
|
|
||||||
|
# The latest responses are kept (in memory only) and shown again when their
|
||||||
|
# chat is opened again, until the app restarts.
|
||||||
|
HISTORY = 20
|
||||||
|
|
||||||
|
|
||||||
|
def _state():
|
||||||
|
"""Kept across reloads of this plugin (not app restarts):
|
||||||
|
shown: (account, message id) -> what "Send in chat" sends, or None;
|
||||||
|
history: the latest HISTORY responses, oldest first."""
|
||||||
|
from library import shared
|
||||||
|
|
||||||
|
return shared().setdefault(
|
||||||
|
"responses",
|
||||||
|
{"shown": {}, "history": [], "counter": [random.randint(0x01000000, 0x0E000000)]},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def jboolean_type():
|
||||||
|
from java.lang import Boolean
|
||||||
|
|
||||||
|
return Boolean.TYPE
|
||||||
|
|
||||||
|
|
||||||
|
def tl_entities(entities):
|
||||||
|
"""ArrayList<TLRPC.MessageEntity> of entity dicts ({type, offset, length, ...})."""
|
||||||
|
from org.telegram.tgnet import TLRPC
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for data in entities or ():
|
||||||
|
name = _ENTITY_CLASSES.get(data.get("type"))
|
||||||
|
if name is None:
|
||||||
|
continue
|
||||||
|
entity = getattr(TLRPC, name)()
|
||||||
|
entity.offset = int(data["offset"])
|
||||||
|
entity.length = int(data["length"])
|
||||||
|
if data.get("url") is not None and hasattr(entity, "url"):
|
||||||
|
entity.url = data["url"]
|
||||||
|
if data.get("language") is not None and hasattr(entity, "language"):
|
||||||
|
entity.language = data["language"]
|
||||||
|
if data.get("user_id") is not None and hasattr(entity, "user_id"):
|
||||||
|
entity.user_id = int(data["user_id"])
|
||||||
|
if data.get("document_id") is not None and hasattr(entity, "document_id"):
|
||||||
|
entity.document_id = int(data["document_id"])
|
||||||
|
if data.get("collapsed") and hasattr(entity, "collapsed"):
|
||||||
|
entity.collapsed = True
|
||||||
|
out.append(entity)
|
||||||
|
return array_list(out)
|
||||||
|
|
||||||
|
|
||||||
|
def is_local(message_object):
|
||||||
|
"""Whether a MessageObject is a response shown here."""
|
||||||
|
try:
|
||||||
|
return (int(message_object.currentAccount), int(message_object.getId())) in _state()["shown"]
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def open_chat(account, dialog_id, topic_id=0):
|
||||||
|
"""The ChatActivity showing that chat (and topic) now, else None."""
|
||||||
|
from client_utils import get_last_fragment
|
||||||
|
|
||||||
|
fragment = get_last_fragment()
|
||||||
|
if fragment is None or not java_class(CHAT).isInstance(fragment):
|
||||||
|
return None
|
||||||
|
if int(fragment.getCurrentAccount()) != int(account) or int(fragment.getDialogId()) != int(dialog_id):
|
||||||
|
return None
|
||||||
|
if topic_id and int(fragment.getTopicId()) != int(topic_id):
|
||||||
|
return None
|
||||||
|
return fragment
|
||||||
|
|
||||||
|
|
||||||
|
def _build(account, dialog_id, topic_id, reply, text, entities, message_id, date):
|
||||||
|
from org.telegram.messenger import MessageObject, MessagesController, UserConfig
|
||||||
|
from org.telegram.tgnet import TLRPC
|
||||||
|
|
||||||
|
controller = MessagesController.getInstance(account)
|
||||||
|
own = UserConfig.getInstance(account).getClientUserId()
|
||||||
|
msg = TLRPC.TL_message()
|
||||||
|
msg.id = message_id
|
||||||
|
msg.dialog_id = dialog_id
|
||||||
|
msg.peer_id = controller.getPeer(dialog_id)
|
||||||
|
msg.from_id = controller.getPeer(own)
|
||||||
|
msg.flags |= 256
|
||||||
|
msg.date = date
|
||||||
|
msg.message = text
|
||||||
|
msg.entities = entities
|
||||||
|
if entities.size():
|
||||||
|
msg.flags |= 128
|
||||||
|
reply_id = int(reply.getId()) if reply is not None else 0
|
||||||
|
if reply_id or topic_id:
|
||||||
|
header = TLRPC.TL_messageReplyHeader()
|
||||||
|
header.reply_to_msg_id = reply_id or int(topic_id)
|
||||||
|
header.flags |= 16
|
||||||
|
if topic_id:
|
||||||
|
header.forum_topic = True
|
||||||
|
header.flags |= 8
|
||||||
|
if reply_id:
|
||||||
|
header.reply_to_top_id = int(topic_id)
|
||||||
|
header.flags |= 2
|
||||||
|
msg.reply_to = header
|
||||||
|
msg.flags |= 8
|
||||||
|
msg.out = True
|
||||||
|
msg.unread = False
|
||||||
|
msg.ephemeralReceiverBotId = own
|
||||||
|
obj = MessageObject(account, msg, True, True)
|
||||||
|
if reply is not None:
|
||||||
|
obj.replyMessageObject = reply
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def sendable(message_object):
|
||||||
|
"""What "Send in chat" sends for a response shown here, else None:
|
||||||
|
{account, dialog_id, reply, thread, text, entities}."""
|
||||||
|
try:
|
||||||
|
return _state()["shown"].get((int(message_object.currentAccount), int(message_object.getId())))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def show(
|
||||||
|
account, dialog_id, topic_id, reply, text, entities=(), header=None, thread=None, answer=False, keep=True
|
||||||
|
):
|
||||||
|
"""Shows a response in the open chat; its id, or None (a bulletin was
|
||||||
|
shown instead). `header` (the command) goes first, as code. An `answer`
|
||||||
|
can be sent into the chat from its menu (without the header). Kept
|
||||||
|
responses come back when the chat is opened again. UI thread."""
|
||||||
|
from org.telegram.messenger import MessageObject
|
||||||
|
from org.telegram.tgnet import ConnectionsManager
|
||||||
|
|
||||||
|
state = _state()
|
||||||
|
body = {
|
||||||
|
"account": int(account),
|
||||||
|
"dialog_id": int(dialog_id),
|
||||||
|
"reply": reply,
|
||||||
|
"thread": thread,
|
||||||
|
"text": text,
|
||||||
|
"entities": list(entities or ()),
|
||||||
|
}
|
||||||
|
if header:
|
||||||
|
shift = utf16_len(header) + 1
|
||||||
|
entities = [{"type": "code", "offset": 0, "length": utf16_len(header)}] + [
|
||||||
|
dict(e, offset=int(e["offset"]) + shift) for e in entities or ()
|
||||||
|
]
|
||||||
|
text = f"{header}\n{text}" if text else header
|
||||||
|
state["counter"][0] += 1
|
||||||
|
entry = {
|
||||||
|
"account": int(account),
|
||||||
|
"dialog_id": int(dialog_id),
|
||||||
|
"topic_id": int(topic_id or 0),
|
||||||
|
"id": int(MessageObject.ephemeralMessageIdPack(state["counter"][0])),
|
||||||
|
"date": int(ConnectionsManager.getInstance(account).getCurrentTime()),
|
||||||
|
"reply": reply,
|
||||||
|
"text": text,
|
||||||
|
"entities": list(entities or ()),
|
||||||
|
}
|
||||||
|
state["shown"][(entry["account"], entry["id"])] = body if answer else None
|
||||||
|
if keep:
|
||||||
|
history = state["history"]
|
||||||
|
history.append(entry)
|
||||||
|
del history[:-HISTORY]
|
||||||
|
fragment = open_chat(account, dialog_id, topic_id)
|
||||||
|
if fragment is None:
|
||||||
|
from hooks.notify import bulletin
|
||||||
|
|
||||||
|
bulletin(text[:300])
|
||||||
|
return None
|
||||||
|
_insert(fragment, [entry])
|
||||||
|
return entry["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def _insert(fragment, entries):
|
||||||
|
from java import jboolean
|
||||||
|
|
||||||
|
objects = [
|
||||||
|
_build(
|
||||||
|
e["account"], e["dialog_id"], e["topic_id"], e["reply"], e["text"],
|
||||||
|
tl_entities(e["entities"]), e["id"], e["date"],
|
||||||
|
)
|
||||||
|
for e in entries
|
||||||
|
]
|
||||||
|
method(CHAT, "processNewMessages", "java.util.ArrayList", jboolean_type()).invoke(
|
||||||
|
fragment, array_list(objects), jboolean(False)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def restore(fragment):
|
||||||
|
"""Shows a just opened chat's kept responses it doesn't show yet."""
|
||||||
|
state = _state()
|
||||||
|
account, dialog_id = int(fragment.getCurrentAccount()), int(fragment.getDialogId())
|
||||||
|
topic_id = int(fragment.getTopicId())
|
||||||
|
dicts = field(fragment, "messagesDict")
|
||||||
|
present = dicts[0] if dicts is not None else None
|
||||||
|
missing = [
|
||||||
|
e
|
||||||
|
for e in state["history"]
|
||||||
|
if e["account"] == account
|
||||||
|
and e["dialog_id"] == dialog_id
|
||||||
|
and e["topic_id"] == topic_id
|
||||||
|
and (present is None or present.get(e["id"]) is None)
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
_insert(fragment, missing)
|
||||||
|
|
||||||
|
|
||||||
|
def remove(account, dialog_id, message_id):
|
||||||
|
"""Takes a response out of the chat showing it, and out of the kept
|
||||||
|
ones. UI thread."""
|
||||||
|
state = _state()
|
||||||
|
state["shown"].pop((int(account), int(message_id)), None)
|
||||||
|
state["history"][:] = [
|
||||||
|
e for e in state["history"] if (e["account"], e["id"]) != (int(account), int(message_id))
|
||||||
|
]
|
||||||
|
fragment = open_chat(account, dialog_id)
|
||||||
|
if fragment is None:
|
||||||
|
return
|
||||||
|
from java import jboolean, jint, jlong
|
||||||
|
from java.lang import Long
|
||||||
|
from org.telegram.messenger import ChatObject
|
||||||
|
|
||||||
|
chat = fragment.getCurrentChat()
|
||||||
|
channel_id = int(chat.id) if chat is not None and ChatObject.isChannel(chat) else 0
|
||||||
|
method(CHAT, "processDeletedMessages", "java.util.ArrayList", Long.TYPE, jboolean_type()).invoke(
|
||||||
|
fragment, array_list([jint(int(message_id))]), jlong(channel_id), jboolean(False)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _label_only_you(cell):
|
||||||
|
"""The cell's "Only visible to @…" label, as "Only visible to you"."""
|
||||||
|
from android.text import Layout, SpannableStringBuilder, StaticLayout
|
||||||
|
from org.telegram.messenger import AndroidUtilities, LocaleController, R
|
||||||
|
from org.telegram.ui.ActionBar import Theme
|
||||||
|
|
||||||
|
layout = field(cell, "ephemeralLayout")
|
||||||
|
if layout is None:
|
||||||
|
return
|
||||||
|
label = SpannableStringBuilder(layout.getText())
|
||||||
|
you = LocaleController.getString(R.string.EphemeralMessageVisibleToYou)
|
||||||
|
if str(label)[2:] == str(you):
|
||||||
|
return
|
||||||
|
label.replace(2, label.length(), you) # after the icon and a space
|
||||||
|
relabeled = StaticLayout(
|
||||||
|
label, Theme.chat_ephemeralPaint, AndroidUtilities.dp(300), Layout.Alignment.ALIGN_NORMAL, 0.0, 0.0, False
|
||||||
|
)
|
||||||
|
set_field(cell, "ephemeralLayout", relabeled)
|
||||||
|
if relabeled.getLineCount() > 0:
|
||||||
|
from java import jint
|
||||||
|
|
||||||
|
set_field(cell, "ephemeralWidth", jint(int(relabeled.getLineWidth(0)) + AndroidUtilities.dp(10)))
|
||||||
|
|
||||||
|
|
||||||
|
class Responses:
|
||||||
|
"""Keeps deleting and replying to responses on the device."""
|
||||||
|
|
||||||
|
def __init__(self, plugin):
|
||||||
|
self.plugin = plugin
|
||||||
|
self.handles = []
|
||||||
|
# Chats (class guids) already given their kept responses; a chat
|
||||||
|
# restored before a reload of this plugin is skipped by its ids.
|
||||||
|
self._restored = set()
|
||||||
|
|
||||||
|
def install(self):
|
||||||
|
class LocalDelete(MethodHook):
|
||||||
|
# MessagesController.deleteEphemeralMessage(long dialogId, int topicId, MessageObject)
|
||||||
|
def before_hooked_method(self, param):
|
||||||
|
try:
|
||||||
|
message = param.args[2]
|
||||||
|
if is_local(message):
|
||||||
|
remove(int(message.currentAccount), int(param.args[0]), int(message.getId()))
|
||||||
|
param.setResult(None)
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("local delete", exc)
|
||||||
|
|
||||||
|
class NoReply(MethodHook):
|
||||||
|
# ChatActivity.showFieldPanelForReply(MessageObject)
|
||||||
|
def before_hooked_method(self, param):
|
||||||
|
try:
|
||||||
|
if param.args and is_local(param.args[0]):
|
||||||
|
param.setResult(None)
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("local reply", exc)
|
||||||
|
|
||||||
|
self.handles += hook_methods(
|
||||||
|
self.plugin, "org.telegram.messenger.MessagesController", "deleteEphemeralMessage", LocalDelete()
|
||||||
|
)
|
||||||
|
class OnlyYou(MethodHook):
|
||||||
|
# ChatMessageCell.setMessageObjectInternal(MessageObject)
|
||||||
|
def after_hooked_method(self, param):
|
||||||
|
try:
|
||||||
|
if is_local(param.args[0]):
|
||||||
|
_label_only_you(param.thisObject)
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("local label", exc)
|
||||||
|
|
||||||
|
restored = self._restored
|
||||||
|
|
||||||
|
class Visible(MethodHook):
|
||||||
|
# ChatActivity.updateMessagesVisiblePart: its messages are shown
|
||||||
|
def after_hooked_method(self, param):
|
||||||
|
try:
|
||||||
|
chat = param.thisObject
|
||||||
|
guid = int(chat.getClassGuid())
|
||||||
|
if guid in restored:
|
||||||
|
return
|
||||||
|
messages = field(chat, "messages")
|
||||||
|
if messages is None or messages.isEmpty():
|
||||||
|
return
|
||||||
|
restored.add(guid)
|
||||||
|
restore(chat)
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("restore responses", exc)
|
||||||
|
|
||||||
|
self.handles += hook_methods(self.plugin, CHAT, "updateMessagesVisiblePart", Visible())
|
||||||
|
self.handles += hook_methods(self.plugin, CHAT, "showFieldPanelForReply", NoReply(), arity=1)
|
||||||
|
self.handles += hook_methods(
|
||||||
|
self.plugin, "org.telegram.ui.Cells.ChatMessageCell", "setMessageObjectInternal", OnlyYou(), arity=1
|
||||||
|
)
|
||||||
|
|
||||||
|
def uninstall(self):
|
||||||
|
unhook_all(self.plugin, self.handles)
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
"""Commands in Telegram's own suggestion list above the input.
|
||||||
|
|
||||||
|
Typing `/` lists the account's commands as bot commands are listed (the
|
||||||
|
account's avatar, `/name`, then its parameters and summary), before the
|
||||||
|
chat's bot commands. Once a command is followed by a space, the list shows
|
||||||
|
its parameters Discord-style instead: the one at the cursor highlighted,
|
||||||
|
missing required ones in red, given ones with their value, and whether it
|
||||||
|
needs a reply.
|
||||||
|
|
||||||
|
MentionsAdapter.searchUsernameOrHashtag runs on every change of the field;
|
||||||
|
afterwards its command lists are filled in. Tapping a row is handled here
|
||||||
|
(the chat's item listeners are wrapped after ChatActivity.createView):
|
||||||
|
a command without required parameters runs, like a bot command is sent;
|
||||||
|
any other is put into the field to complete; a parameter row adds `name:`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from base_plugin import MethodHook
|
||||||
|
|
||||||
|
import diag
|
||||||
|
from commands.parse import analyze, command_name_at, split_command
|
||||||
|
from config import SHOW_PARAMS
|
||||||
|
from jutil import array_list, field, hook_methods, set_field, unhook_all
|
||||||
|
from hooks.composer import reply_of
|
||||||
|
from hooks.notify import bulletin
|
||||||
|
|
||||||
|
ADAPTER = "org.telegram.ui.Adapters.MentionsAdapter"
|
||||||
|
CHAT = "org.telegram.ui.ChatActivity"
|
||||||
|
|
||||||
|
_CLEARED = (
|
||||||
|
"searchResultUsernames",
|
||||||
|
"searchResultUsernamesMap",
|
||||||
|
"searchResultHashtags",
|
||||||
|
"searchResultSuggestions",
|
||||||
|
"searchResultBotContext",
|
||||||
|
"stickers",
|
||||||
|
"hintHashtag",
|
||||||
|
)
|
||||||
|
|
||||||
|
_COMMAND_LISTS = (
|
||||||
|
"searchResultCommands",
|
||||||
|
"searchResultCommandsHelp",
|
||||||
|
"searchResultCommandsUsers",
|
||||||
|
"searchResultCommandsEphemeral",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _identity(obj):
|
||||||
|
from java.lang import System
|
||||||
|
|
||||||
|
return int(System.identityHashCode(obj))
|
||||||
|
|
||||||
|
|
||||||
|
class Row:
|
||||||
|
__slots__ = ("kind", "command", "param", "label", "help", "style")
|
||||||
|
|
||||||
|
def __init__(self, kind, command, label, help, param=None, style=None):
|
||||||
|
self.kind = kind # command | header | param | reply
|
||||||
|
self.command = command
|
||||||
|
self.param = param
|
||||||
|
self.label = label
|
||||||
|
self.help = help
|
||||||
|
self.style = style # current | missing | filled | None
|
||||||
|
|
||||||
|
|
||||||
|
def param_rows(command, analysis, replying):
|
||||||
|
"""The rows describing `command`'s parameters while it is typed."""
|
||||||
|
rows = [Row("header", command, "/" + command.name, command.summary or command.usage())]
|
||||||
|
for param in command.params:
|
||||||
|
value = analysis.values.get(param.name)
|
||||||
|
need = "required" if param.required else "optional"
|
||||||
|
about = f"{param.type_label()} · {need}"
|
||||||
|
if param.description:
|
||||||
|
about += f" · {param.description}"
|
||||||
|
if value is not None:
|
||||||
|
about = f"✓ {value} · {about}"
|
||||||
|
if param.name == analysis.current:
|
||||||
|
style = "current"
|
||||||
|
elif value is not None:
|
||||||
|
style = "filled"
|
||||||
|
else:
|
||||||
|
style = "missing" if param.required else None
|
||||||
|
rows.append(Row("param", command, param.name, about, param, style))
|
||||||
|
if command.reply != "none":
|
||||||
|
if replying:
|
||||||
|
rows.append(Row("reply", command, "↩ reply", "✓ replying to a message", style="filled"))
|
||||||
|
else:
|
||||||
|
need = "required" if command.reply == "required" else "optional"
|
||||||
|
style = "missing" if command.reply == "required" else None
|
||||||
|
rows.append(Row("reply", command, "↩ reply", f"{need} · swipe a message to reply to it", style=style))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
class Suggest:
|
||||||
|
def __init__(self, plugin, catalog, composer):
|
||||||
|
self.plugin = plugin
|
||||||
|
self.catalog = catalog
|
||||||
|
self.composer = composer
|
||||||
|
self.handles = []
|
||||||
|
self._states = {} # adapter identity -> ([Row] first in its list, that list's identity)
|
||||||
|
self._proxies = {}
|
||||||
|
|
||||||
|
def install(self):
|
||||||
|
suggest = self
|
||||||
|
|
||||||
|
class Search(MethodHook):
|
||||||
|
def before_hooked_method(self, param):
|
||||||
|
try:
|
||||||
|
if suggest.before_search(param.thisObject, param.args):
|
||||||
|
param.setResult(None)
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("suggest search", exc)
|
||||||
|
|
||||||
|
def after_hooked_method(self, param):
|
||||||
|
try:
|
||||||
|
suggest.after_search(param.thisObject, param.args)
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("suggest search", exc)
|
||||||
|
|
||||||
|
class Bind(MethodHook):
|
||||||
|
def after_hooked_method(self, param):
|
||||||
|
try:
|
||||||
|
suggest.on_bind(param.thisObject, param.args[0], int(param.args[1]))
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("suggest bind", exc)
|
||||||
|
|
||||||
|
class Created(MethodHook):
|
||||||
|
def after_hooked_method(self, param):
|
||||||
|
try:
|
||||||
|
suggest.wrap_listeners(param.thisObject)
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("suggest listeners", exc)
|
||||||
|
|
||||||
|
self.handles += hook_methods(self.plugin, ADAPTER, "searchUsernameOrHashtag", Search(), arity=5)
|
||||||
|
self.handles += hook_methods(self.plugin, ADAPTER, "onBindViewHolder", Bind(), arity=2)
|
||||||
|
self.handles += hook_methods(self.plugin, CHAT, "createView", Created(), arity=1)
|
||||||
|
|
||||||
|
def uninstall(self):
|
||||||
|
unhook_all(self.plugin, self.handles)
|
||||||
|
self._states.clear()
|
||||||
|
|
||||||
|
# filling the list
|
||||||
|
|
||||||
|
def _typed(self, adapter, args):
|
||||||
|
"""(chat, text, cursor, commands) when a command may be being typed."""
|
||||||
|
chat = getattr(adapter, "parentFragment", None)
|
||||||
|
if chat is None or args[3] or args[4] or args[0] is None:
|
||||||
|
return None
|
||||||
|
text = str(args[0])
|
||||||
|
if not text.startswith("/") or int(chat.getChatMode()) != 0:
|
||||||
|
return None
|
||||||
|
commands = self.catalog.commands_for(int(chat.getCurrentAccount()))
|
||||||
|
return (chat, text, int(args[1]), commands) if commands else None
|
||||||
|
|
||||||
|
def before_search(self, adapter, args):
|
||||||
|
"""Parameters of a known command: shown instead of the app's search
|
||||||
|
(whose `:` emoji search would replace them); True to skip it."""
|
||||||
|
self._states.pop(_identity(adapter), None)
|
||||||
|
typed = self._typed(adapter, args)
|
||||||
|
if typed is None:
|
||||||
|
return False
|
||||||
|
chat, text, cursor, commands = typed
|
||||||
|
for name in _COMMAND_LISTS: # left from an earlier search
|
||||||
|
set_field(adapter, name, None)
|
||||||
|
split = split_command(text)
|
||||||
|
if split is None or not self.plugin.get_setting(SHOW_PARAMS, True):
|
||||||
|
return False
|
||||||
|
command = next((c for c in commands if split[0] in c.names), None)
|
||||||
|
if command is None or (not command.params and command.reply == "none"):
|
||||||
|
return False
|
||||||
|
reply = reply_of(chat)
|
||||||
|
analysis = analyze(command, text, cursor, reply is not None)
|
||||||
|
self._fill(adapter, chat, param_rows(command, analysis, reply is not None), keep_bots=False)
|
||||||
|
self._text_changed(chat)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def after_search(self, adapter, args):
|
||||||
|
"""Commands matching a bare `/prefix`, before the chat's bot commands."""
|
||||||
|
typed = self._typed(adapter, args)
|
||||||
|
chat = getattr(adapter, "parentFragment", None)
|
||||||
|
if chat is not None:
|
||||||
|
self._text_changed(chat)
|
||||||
|
if typed is None:
|
||||||
|
return
|
||||||
|
chat, text, cursor, commands = typed
|
||||||
|
prefix = command_name_at(text) if cursor >= len(text) else None
|
||||||
|
if prefix is None:
|
||||||
|
return
|
||||||
|
found = [c for c in commands if any(n.startswith(prefix) for n in c.names)]
|
||||||
|
if found:
|
||||||
|
rows = [Row("command", c, "/" + c.name, c.hint()) for c in found]
|
||||||
|
self._fill(adapter, chat, rows, keep_bots=True)
|
||||||
|
|
||||||
|
def _text_changed(self, chat):
|
||||||
|
enter = chat.getChatActivityEnterView()
|
||||||
|
if enter is not None:
|
||||||
|
self.composer.text_changed(enter)
|
||||||
|
|
||||||
|
def _fill(self, adapter, chat, rows, keep_bots):
|
||||||
|
from org.telegram.messenger import MessagesController, UserConfig
|
||||||
|
|
||||||
|
account = int(chat.getCurrentAccount())
|
||||||
|
me = MessagesController.getInstance(account).getUser(UserConfig.getInstance(account).getClientUserId())
|
||||||
|
names, helps, users, ephemeral = [], [], [], []
|
||||||
|
# Quick replies go too: the app binds command rows as if they were
|
||||||
|
# first in the list.
|
||||||
|
for row in rows:
|
||||||
|
names.append(row.label)
|
||||||
|
helps.append(row.help)
|
||||||
|
users.append(me if row.kind in ("command", "header") else None)
|
||||||
|
ephemeral.append(False)
|
||||||
|
if keep_bots:
|
||||||
|
bots = field(adapter, "searchResultCommands")
|
||||||
|
if bots is not None:
|
||||||
|
bot_help = field(adapter, "searchResultCommandsHelp")
|
||||||
|
bot_users = field(adapter, "searchResultCommandsUsers")
|
||||||
|
bot_ephemeral = field(adapter, "searchResultCommandsEphemeral")
|
||||||
|
for i in range(bots.size()):
|
||||||
|
names.append(bots.get(i))
|
||||||
|
helps.append(bot_help.get(i) if bot_help is not None and i < bot_help.size() else "")
|
||||||
|
users.append(bot_users.get(i) if bot_users is not None and i < bot_users.size() else None)
|
||||||
|
ephemeral.append(
|
||||||
|
bool(bot_ephemeral.get(i)) if bot_ephemeral is not None and i < bot_ephemeral.size() else False
|
||||||
|
)
|
||||||
|
for name in _CLEARED + ("quickReplies",):
|
||||||
|
set_field(adapter, name, None)
|
||||||
|
shown = array_list(names)
|
||||||
|
set_field(adapter, "searchResultCommands", shown)
|
||||||
|
set_field(adapter, "searchResultCommandsHelp", array_list(helps))
|
||||||
|
set_field(adapter, "searchResultCommandsUsers", array_list(users))
|
||||||
|
set_field(adapter, "searchResultCommandsEphemeral", array_list(ephemeral))
|
||||||
|
self._states[_identity(adapter)] = (rows, _identity(shown))
|
||||||
|
self.wrap_listeners(chat)
|
||||||
|
adapter.notifyDataSetChanged()
|
||||||
|
# Its diff compares the labels only: rows whose help or style
|
||||||
|
# changed have to be rebound too.
|
||||||
|
adapter.notifyItemRangeChanged(0, len(rows))
|
||||||
|
delegate = field(adapter, "delegate")
|
||||||
|
if delegate is not None:
|
||||||
|
delegate.needChangePanelVisibility(True)
|
||||||
|
|
||||||
|
def _rows(self, adapter):
|
||||||
|
"""Our rows while the adapter still shows them (an async search of
|
||||||
|
the app may have replaced them since)."""
|
||||||
|
state = self._states.get(_identity(adapter))
|
||||||
|
if state is None:
|
||||||
|
return ()
|
||||||
|
shown = field(adapter, "searchResultCommands")
|
||||||
|
if shown is None or _identity(shown) != state[1] or field(adapter, "searchResultSuggestions") is not None:
|
||||||
|
return ()
|
||||||
|
return state[0]
|
||||||
|
|
||||||
|
# styling rows
|
||||||
|
|
||||||
|
def on_bind(self, adapter, holder, position):
|
||||||
|
"""Colors our rows' names; a recycled cell gets its own color back.
|
||||||
|
The original color is kept in the name view's tag, which outlives
|
||||||
|
this plugin being reloaded."""
|
||||||
|
cell = holder.itemView
|
||||||
|
if cell.getClass().getSimpleName() != "MentionCell":
|
||||||
|
return
|
||||||
|
name_view = field(cell, "nameTextView")
|
||||||
|
if name_view is None:
|
||||||
|
return
|
||||||
|
from java import jint
|
||||||
|
|
||||||
|
rows = self._rows(adapter)
|
||||||
|
row = rows[position] if 0 <= position < len(rows) else None
|
||||||
|
original = name_view.getTag()
|
||||||
|
if row is None or row.style is None:
|
||||||
|
if original is not None:
|
||||||
|
name_view.setTextColor(jint(int(original)))
|
||||||
|
name_view.setTag(None)
|
||||||
|
return
|
||||||
|
from org.telegram.ui.ActionBar import Theme
|
||||||
|
|
||||||
|
if original is None:
|
||||||
|
name_view.setTag(int(name_view.getCurrentTextColor()))
|
||||||
|
color = {
|
||||||
|
"current": Theme.key_windowBackgroundWhiteBlueText,
|
||||||
|
"missing": Theme.key_text_RedRegular,
|
||||||
|
"filled": Theme.key_windowBackgroundWhiteGreenText,
|
||||||
|
}[row.style]
|
||||||
|
name_view.setTextColor(jint(Theme.getColor(color)))
|
||||||
|
|
||||||
|
# tapping rows
|
||||||
|
|
||||||
|
def wrap_listeners(self, chat):
|
||||||
|
"""Routes taps on the chat's suggestion list through `tapped`/`held`
|
||||||
|
(once per list and load of this plugin). The app's own listeners
|
||||||
|
handle every other row: the click one is ChatActivity's field, the
|
||||||
|
long-click one is kept across reloads (the list then holds ours)."""
|
||||||
|
from library import shared
|
||||||
|
|
||||||
|
container = field(chat, "mentionContainer")
|
||||||
|
if container is None:
|
||||||
|
return
|
||||||
|
list_view = container.getListView()
|
||||||
|
key = _identity(list_view)
|
||||||
|
mine = self._proxies.get(key)
|
||||||
|
current = field(list_view, "onItemClickListener")
|
||||||
|
if mine is not None and current is not None and _identity(current) == _identity(mine[0]):
|
||||||
|
return
|
||||||
|
click = field(chat, "mentionsOnItemClickListener")
|
||||||
|
long_clicks = shared().setdefault("long_clicks", {})
|
||||||
|
long_click = long_clicks.get(key) or field(list_view, "onItemLongClickListener")
|
||||||
|
long_clicks[key] = long_click
|
||||||
|
from java import dynamic_proxy
|
||||||
|
from org.telegram.ui.Components import RecyclerListView
|
||||||
|
|
||||||
|
suggest = self
|
||||||
|
|
||||||
|
class Click(dynamic_proxy(RecyclerListView.OnItemClickListener)):
|
||||||
|
def onItemClick(self, view, position):
|
||||||
|
handled = False
|
||||||
|
try:
|
||||||
|
handled = suggest.tapped(chat, container, position)
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("suggest tap", exc)
|
||||||
|
if not handled and click is not None:
|
||||||
|
click.onItemClick(view, position)
|
||||||
|
|
||||||
|
class LongClick(dynamic_proxy(RecyclerListView.OnItemLongClickListener)):
|
||||||
|
def onItemClick(self, view, position):
|
||||||
|
try:
|
||||||
|
if suggest.tapped(chat, container, position):
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("suggest hold", exc)
|
||||||
|
return bool(long_click.onItemClick(view, position)) if long_click is not None else False
|
||||||
|
|
||||||
|
proxies = (Click(), LongClick())
|
||||||
|
self._proxies[key] = proxies # referenced as long as the list lives
|
||||||
|
if len(self._proxies) > 32:
|
||||||
|
self._proxies.pop(next(iter(self._proxies)))
|
||||||
|
list_view.setOnItemClickListener(proxies[0])
|
||||||
|
if long_click is not None:
|
||||||
|
list_view.setOnItemLongClickListener(proxies[1])
|
||||||
|
|
||||||
|
def row_at(self, container, position):
|
||||||
|
"""Our row at a list position (the list's first item is padding)."""
|
||||||
|
rows = self._rows(container.getAdapter())
|
||||||
|
index = position - 1
|
||||||
|
return rows[index] if 0 <= index < len(rows) else None
|
||||||
|
|
||||||
|
def _our_command_at(self, chat, container, position):
|
||||||
|
"""One of our commands at a list position though our rows are gone
|
||||||
|
(a stale list): its row is never left to the app, which would send
|
||||||
|
it."""
|
||||||
|
item = container.getAdapter().getItem(position - 1)
|
||||||
|
if not isinstance(item, str) or not item.startswith("/"):
|
||||||
|
return None
|
||||||
|
name = item[1:].split("@", 1)[0]
|
||||||
|
return self.catalog.find(int(chat.getCurrentAccount()), name)
|
||||||
|
|
||||||
|
def tapped(self, chat, container, position):
|
||||||
|
"""A tap (or hold) on the list: True when it was one of our rows.
|
||||||
|
Rows only ever fill the field; sending is the send button's job."""
|
||||||
|
row = self.row_at(container, position)
|
||||||
|
if row is None:
|
||||||
|
command = self._our_command_at(chat, container, position)
|
||||||
|
if command is None:
|
||||||
|
return False
|
||||||
|
row = Row("command", command, "/" + command.name, "")
|
||||||
|
enter = chat.getChatActivityEnterView()
|
||||||
|
if enter is None:
|
||||||
|
return True
|
||||||
|
command = row.command
|
||||||
|
if row.kind in ("command", "header"):
|
||||||
|
takes = command.params or command.reply != "none"
|
||||||
|
text = str(enter.getFieldText() or "")
|
||||||
|
if row.kind == "command" or split_command(text) is None:
|
||||||
|
self.composer.set_text(enter, f"/{command.name} " if takes else f"/{command.name}")
|
||||||
|
elif command.description:
|
||||||
|
bulletin(command.description)
|
||||||
|
elif row.kind == "param":
|
||||||
|
text = str(enter.getFieldText() or "")
|
||||||
|
if row.param.name not in (self._current_values(chat, text) or {}):
|
||||||
|
gap = "" if not text or text.endswith(" ") else " "
|
||||||
|
self.composer.set_text(enter, f"{text}{gap}{row.param.name}:")
|
||||||
|
elif row.kind == "reply":
|
||||||
|
bulletin("Swipe a message to the left to reply to it")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _current_values(self, chat, text):
|
||||||
|
typed = self.composer.read(chat.getChatActivityEnterView())
|
||||||
|
return typed.analysis.values if typed is not None else None
|
||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
"""Chaquopy/Java helpers shared by the hooks (after messageLogger's jutil)."""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import diag
|
||||||
|
|
||||||
|
|
||||||
|
def java_list(items):
|
||||||
|
"""A java.util.List as a Python list (Java lists aren't iterable here)."""
|
||||||
|
if items is None:
|
||||||
|
return []
|
||||||
|
return [items.get(i) for i in range(items.size())]
|
||||||
|
|
||||||
|
|
||||||
|
def array_list(items):
|
||||||
|
from java.util import ArrayList
|
||||||
|
|
||||||
|
out = ArrayList()
|
||||||
|
for item in items:
|
||||||
|
out.add(item)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def java_class(name):
|
||||||
|
"""The real java.lang.Class: find_class returns a Chaquopy wrapper whose
|
||||||
|
getClass() is the Class."""
|
||||||
|
from hook_utils import find_class
|
||||||
|
|
||||||
|
wrapper = find_class(name)
|
||||||
|
if wrapper is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return wrapper.getClass()
|
||||||
|
except Exception:
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def hook_methods(plugin, class_name, name, hook, arity=None):
|
||||||
|
"""Hooks every overload of `class_name.name` (or those with `arity`
|
||||||
|
parameters); returns the unhook handles."""
|
||||||
|
cls = java_class(class_name)
|
||||||
|
if cls is None:
|
||||||
|
diag.log(f"class not found: {class_name}")
|
||||||
|
return []
|
||||||
|
handles = []
|
||||||
|
for method in cls.getDeclaredMethods():
|
||||||
|
if method.getName() != name:
|
||||||
|
continue
|
||||||
|
if arity is not None and len(method.getParameterTypes()) != arity:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
handle = plugin.hook_method(method, hook)
|
||||||
|
if handle is not None:
|
||||||
|
handles.append(handle)
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error(f"hook {class_name}.{name}", exc)
|
||||||
|
if not handles:
|
||||||
|
diag.log(f"nothing hooked for {class_name}.{name}")
|
||||||
|
return handles
|
||||||
|
|
||||||
|
|
||||||
|
def unhook_all(plugin, handles):
|
||||||
|
for handle in handles:
|
||||||
|
try:
|
||||||
|
plugin.unhook_method(handle)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
del handles[:]
|
||||||
|
|
||||||
|
|
||||||
|
def field(obj, name):
|
||||||
|
"""A (possibly private) field of a Java object."""
|
||||||
|
value = getattr(obj, name, None)
|
||||||
|
if value is not None:
|
||||||
|
return value
|
||||||
|
try:
|
||||||
|
from hook_utils import get_private_field
|
||||||
|
|
||||||
|
return get_private_field(obj, name)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def set_field(obj, name, value):
|
||||||
|
from hook_utils import set_private_field
|
||||||
|
|
||||||
|
set_private_field(obj, name, value)
|
||||||
|
|
||||||
|
|
||||||
|
_methods = {}
|
||||||
|
|
||||||
|
|
||||||
|
def method(class_name, name, *parameter_types):
|
||||||
|
"""A declared (possibly private) method, made accessible; cached.
|
||||||
|
`parameter_types` are java.lang.Class objects or class names."""
|
||||||
|
key = (class_name, name, tuple(str(t) for t in parameter_types))
|
||||||
|
if key not in _methods:
|
||||||
|
types = [java_class(t) if isinstance(t, str) else t for t in parameter_types]
|
||||||
|
found = java_class(class_name).getDeclaredMethod(name, *types)
|
||||||
|
found.setAccessible(True)
|
||||||
|
_methods[key] = found
|
||||||
|
return _methods[key]
|
||||||
|
|
||||||
|
|
||||||
|
def on_ui(fn, delay_ms=0):
|
||||||
|
from android_utils import run_on_ui_thread
|
||||||
|
|
||||||
|
def run():
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("ui task", exc)
|
||||||
|
|
||||||
|
run_on_ui_thread(run, delay_ms)
|
||||||
|
|
||||||
|
|
||||||
|
def in_background(fn):
|
||||||
|
"""Runs `fn` off the UI thread; exceptions are logged, not lost."""
|
||||||
|
|
||||||
|
def run():
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("background task", exc)
|
||||||
|
|
||||||
|
threading.Thread(target=run, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
_Runnable = []
|
||||||
|
|
||||||
|
|
||||||
|
def runnable(fn):
|
||||||
|
"""A java.lang.Runnable calling `fn` (exceptions logged)."""
|
||||||
|
if not _Runnable:
|
||||||
|
from java import dynamic_proxy
|
||||||
|
from java.lang import Runnable
|
||||||
|
|
||||||
|
class PyRunnable(dynamic_proxy(Runnable)):
|
||||||
|
def __init__(self, fn):
|
||||||
|
super().__init__()
|
||||||
|
self.fn = fn
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
self.fn()
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("runnable", exc)
|
||||||
|
|
||||||
|
_Runnable.append(PyRunnable)
|
||||||
|
return _Runnable[0](fn)
|
||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
"""The plugin as a library: other plugins register their own commands.
|
||||||
|
|
||||||
|
Published as the module `client_commands` (in `sys.modules`), so another
|
||||||
|
plugin can do:
|
||||||
|
|
||||||
|
import sys, types
|
||||||
|
|
||||||
|
def setup(api):
|
||||||
|
api.register(
|
||||||
|
"roll",
|
||||||
|
roll,
|
||||||
|
summary="Rolls a die",
|
||||||
|
params=[api.Param("sides", "integer", required=False, default=6)],
|
||||||
|
owner=__id__,
|
||||||
|
)
|
||||||
|
|
||||||
|
def roll(ctx, sides=6):
|
||||||
|
import random
|
||||||
|
return f"🎲 <b>{random.randint(1, sides)}</b>"
|
||||||
|
|
||||||
|
# in on_plugin_load: 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)
|
||||||
|
|
||||||
|
# in on_plugin_unload
|
||||||
|
sys.modules["client_commands"].unregister_owner(__id__)
|
||||||
|
|
||||||
|
A handler gets a `CommandContext` and the parameters as keyword arguments,
|
||||||
|
runs off the UI thread, and returns the response as Telegram-style HTML (or
|
||||||
|
None). `raise api.CommandError("...")` shows a user-facing error.
|
||||||
|
|
||||||
|
The registry outlives the module object (the plugin engine drops modules a
|
||||||
|
plugin added when it unloads): it hangs off `builtins`, so registrations
|
||||||
|
survive this plugin being updated or turned off and on, and an `api` kept
|
||||||
|
from an earlier load still registers into it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
|
from commands.markup import escape
|
||||||
|
from commands.model import Command, Param
|
||||||
|
from commands.parse import CommandError
|
||||||
|
|
||||||
|
MODULE = "client_commands"
|
||||||
|
VERSION = 1
|
||||||
|
_STATE = "__client_commands_state__"
|
||||||
|
|
||||||
|
|
||||||
|
def shared():
|
||||||
|
"""State kept across loads of this plugin: {"registry": {name: Command},
|
||||||
|
...}; the hooks keep what they must not lose on a reload here too."""
|
||||||
|
import builtins
|
||||||
|
|
||||||
|
state = getattr(builtins, _STATE, None)
|
||||||
|
if state is None:
|
||||||
|
state = {"registry": {}}
|
||||||
|
setattr(builtins, _STATE, state)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
class CommandContext:
|
||||||
|
"""Where a command runs. `reply_to` is the replied MessageObject (or
|
||||||
|
None), `values` the parsed parameters, `send_to_chat` whether the
|
||||||
|
response goes into the chat (as the account) instead of only to you."""
|
||||||
|
|
||||||
|
def __init__(self, account, dialog_id, topic_id, reply_to, text, command, values, send_to_chat):
|
||||||
|
self.account = account
|
||||||
|
self.dialog_id = dialog_id
|
||||||
|
self.topic_id = topic_id
|
||||||
|
self.reply_to = reply_to
|
||||||
|
self.text = text
|
||||||
|
self.command = command
|
||||||
|
self.values = values
|
||||||
|
self.send_to_chat = send_to_chat
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client(self):
|
||||||
|
"""The SDK's AccountClient of the account (send_text, send_request...)."""
|
||||||
|
from client_utils import get_client
|
||||||
|
|
||||||
|
return get_client(self.account)
|
||||||
|
|
||||||
|
|
||||||
|
class Library:
|
||||||
|
def __init__(self):
|
||||||
|
self.module = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _registry(self):
|
||||||
|
return shared()["registry"]
|
||||||
|
|
||||||
|
def install(self):
|
||||||
|
"""Publishes the API (keeping earlier registrations) and runs the
|
||||||
|
setups other plugins queued before this plugin loaded."""
|
||||||
|
module = sys.modules.get(MODULE)
|
||||||
|
if module is None:
|
||||||
|
module = types.ModuleType(MODULE)
|
||||||
|
sys.modules[MODULE] = module
|
||||||
|
self.module = module
|
||||||
|
module.__doc__ = __doc__
|
||||||
|
module.VERSION = VERSION
|
||||||
|
module.Param = Param
|
||||||
|
module.CommandError = CommandError
|
||||||
|
module.CommandContext = CommandContext
|
||||||
|
module.escape = escape
|
||||||
|
module.register = self.register
|
||||||
|
module.unregister = self.unregister
|
||||||
|
module.unregister_owner = self.unregister_owner
|
||||||
|
module.commands = self.commands
|
||||||
|
module.available = True
|
||||||
|
module._registry = self._registry
|
||||||
|
pending = module.__dict__.pop("pending", [])
|
||||||
|
for setup in pending:
|
||||||
|
try:
|
||||||
|
setup(module)
|
||||||
|
except Exception as exc:
|
||||||
|
import diag
|
||||||
|
|
||||||
|
diag.error("library pending setup", exc)
|
||||||
|
|
||||||
|
def uninstall(self):
|
||||||
|
if self.module is not None:
|
||||||
|
self.module.available = False
|
||||||
|
|
||||||
|
def register(
|
||||||
|
self,
|
||||||
|
name,
|
||||||
|
handler,
|
||||||
|
*,
|
||||||
|
params=(),
|
||||||
|
summary="",
|
||||||
|
description="",
|
||||||
|
aliases=(),
|
||||||
|
reply="none",
|
||||||
|
owner=None,
|
||||||
|
):
|
||||||
|
"""Adds (or replaces) a command run by `handler(ctx, **values)`.
|
||||||
|
`params` are `Param`s or dicts like the panel lists them."""
|
||||||
|
if not callable(handler):
|
||||||
|
raise TypeError("handler must be callable")
|
||||||
|
params = [p if isinstance(p, Param) else Param.from_json(p) for p in params]
|
||||||
|
command = Command(
|
||||||
|
name,
|
||||||
|
params,
|
||||||
|
summary,
|
||||||
|
description,
|
||||||
|
aliases,
|
||||||
|
reply,
|
||||||
|
source=str(owner or "plugin"),
|
||||||
|
handler=handler,
|
||||||
|
)
|
||||||
|
self._registry[command.name] = command
|
||||||
|
return command
|
||||||
|
|
||||||
|
def unregister(self, name):
|
||||||
|
return self._registry.pop(str(name).lstrip("/").lower(), None) is not None
|
||||||
|
|
||||||
|
def unregister_owner(self, owner):
|
||||||
|
names = [n for n, c in self._registry.items() if c.source == str(owner)]
|
||||||
|
for name in names:
|
||||||
|
self._registry.pop(name)
|
||||||
|
return len(names)
|
||||||
|
|
||||||
|
def commands(self):
|
||||||
|
"""Every command plugins registered, as dicts."""
|
||||||
|
return [dict(c.to_json(), owner=c.source) for c in self.local_commands()]
|
||||||
|
|
||||||
|
def local_commands(self):
|
||||||
|
return sorted(self._registry.values(), key=lambda c: c.name)
|
||||||
+193
-39
@@ -1,50 +1,204 @@
|
|||||||
from base_plugin import BasePlugin, HookResult, HookStrategy
|
from base_plugin import BasePlugin, HookResult, HookStrategy
|
||||||
from android_utils import log
|
from ui.settings import Divider, Header, Input, Switch, Text
|
||||||
from ui.settings import Header, Input, Text
|
|
||||||
from greeting import render, TemplateError
|
|
||||||
|
|
||||||
DEFAULT_TEMPLATE = "Hello, {name}!"
|
import diag
|
||||||
|
from api import Api, ApiError
|
||||||
|
from catalog import Catalog
|
||||||
|
from commands.parse import split_command
|
||||||
|
from config import API_TOKENS, DEFAULTS, SEND_TO_CHAT, SERVER_COMMANDS, SERVER_URL, SHOW_PARAMS
|
||||||
|
from jutil import in_background, on_ui
|
||||||
|
from library import Library
|
||||||
|
from runner import Invocation, Runner
|
||||||
|
from hooks.composer import Composer
|
||||||
|
from hooks.menu import ResponseMenu
|
||||||
|
from hooks.notify import bulletin
|
||||||
|
from hooks.responses import Responses
|
||||||
|
from hooks.suggest import Suggest
|
||||||
|
|
||||||
|
|
||||||
class HelloPlugin(BasePlugin):
|
class ClientCommandsPlugin(BasePlugin):
|
||||||
def on_plugin_load(self):
|
def on_plugin_load(self):
|
||||||
log("hello_bundle loaded")
|
self.library = Library()
|
||||||
|
self.library.install()
|
||||||
|
self.api = Api(self)
|
||||||
|
self.catalog = Catalog(self, self.api, self.library)
|
||||||
|
self.runner = Runner(self, self.api, self.catalog, self.library)
|
||||||
|
self.responses = Responses(self)
|
||||||
|
self.composer = Composer(self, self.catalog, self.runner)
|
||||||
|
self.suggest = Suggest(self, self.catalog, self.composer)
|
||||||
|
self.menu = ResponseMenu(self, self.runner)
|
||||||
|
self.catalog.on_change(self.composer.forget)
|
||||||
|
for part in (self.responses, self.composer, self.suggest, self.menu):
|
||||||
|
try:
|
||||||
|
part.install()
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error(f"install {type(part).__name__}", exc)
|
||||||
self.add_on_send_message_hook()
|
self.add_on_send_message_hook()
|
||||||
|
self.catalog.start()
|
||||||
|
diag.log("loaded")
|
||||||
|
|
||||||
def on_plugin_unload(self):
|
def on_plugin_unload(self):
|
||||||
log("hello_bundle unloaded")
|
for part in ("menu", "suggest", "composer", "responses"):
|
||||||
|
if hasattr(self, part):
|
||||||
def create_settings(self):
|
getattr(self, part).uninstall()
|
||||||
return [
|
if hasattr(self, "catalog"):
|
||||||
Header(text="Hello Bundle"),
|
self.catalog.stop()
|
||||||
Input(
|
if hasattr(self, "library"):
|
||||||
key="template",
|
self.library.uninstall()
|
||||||
text="Greeting template",
|
diag.log("unloaded")
|
||||||
default=DEFAULT_TEMPLATE,
|
|
||||||
subtext="Use {name} as placeholder.",
|
|
||||||
icon="msg_edit",
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
text="Example",
|
|
||||||
subtext=".hello alice smith -> Hello, Alice Smith!",
|
|
||||||
icon="msg_info",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
def on_send_message_hook(self, account, params):
|
def on_send_message_hook(self, account, params):
|
||||||
message = getattr(params, "message", None)
|
"""Commands sent some other way than the field (the field's own
|
||||||
if not isinstance(message, str):
|
sending is handled by the composer): they run when complete, else go
|
||||||
return HookResult()
|
back into the field. Never sent as text, unless "Send as message"."""
|
||||||
stripped = message.strip()
|
|
||||||
if not stripped.startswith(".hello"):
|
|
||||||
return HookResult()
|
|
||||||
parts = stripped.split(" ", 1)
|
|
||||||
name = parts[1] if len(parts) > 1 else ""
|
|
||||||
if not name.strip():
|
|
||||||
params.message = "Usage: .hello <name>"
|
|
||||||
else:
|
|
||||||
try:
|
try:
|
||||||
params.message = render(self.get_setting("template", DEFAULT_TEMPLATE), name)
|
return self._sent(int(account), params)
|
||||||
except TemplateError as exc:
|
except Exception as exc:
|
||||||
params.message = "Bad template: " + str(exc)
|
diag.error("send hook", exc)
|
||||||
return HookResult(strategy=HookStrategy.MODIFY, params=params)
|
return HookResult()
|
||||||
|
|
||||||
|
def _sent(self, account, params):
|
||||||
|
text = getattr(params, "message", None)
|
||||||
|
if not isinstance(text, str) or not text.startswith("/"):
|
||||||
|
return HookResult()
|
||||||
|
if self.runner.bypassed(text, consume=True) or int(getattr(params, "scheduleDate", 0) or 0):
|
||||||
|
return HookResult()
|
||||||
|
split = split_command(text)
|
||||||
|
command = self.catalog.find(account, split[0]) if split else None
|
||||||
|
if command is None:
|
||||||
|
return HookResult()
|
||||||
|
from commands.parse import analyze
|
||||||
|
|
||||||
|
reply = getattr(params, "replyToMsg", None)
|
||||||
|
thread = getattr(params, "replyToTopMsg", None)
|
||||||
|
if reply is not None and thread is not None and int(reply.getId()) == int(thread.getId()):
|
||||||
|
reply = None # the topic itself, not a message in it
|
||||||
|
analysis = analyze(command, text, None, reply is not None)
|
||||||
|
if not analysis.complete:
|
||||||
|
on_ui(lambda: self._refill(account, int(params.peer), text, analysis.error), 80)
|
||||||
|
return HookResult(strategy=HookStrategy.CANCEL)
|
||||||
|
topic = int(thread.getId()) if thread is not None else 0
|
||||||
|
inv = Invocation(
|
||||||
|
account,
|
||||||
|
int(params.peer),
|
||||||
|
topic,
|
||||||
|
reply,
|
||||||
|
thread,
|
||||||
|
text,
|
||||||
|
command,
|
||||||
|
self.get_setting(SEND_TO_CHAT, False),
|
||||||
|
)
|
||||||
|
on_ui(lambda: self.runner.run(inv))
|
||||||
|
return HookResult(strategy=HookStrategy.CANCEL)
|
||||||
|
|
||||||
|
def _refill(self, account, dialog_id, text, reason):
|
||||||
|
"""Puts an incomplete command back into the open chat's field (after
|
||||||
|
the app cleared it for sending)."""
|
||||||
|
from hooks.responses import open_chat
|
||||||
|
|
||||||
|
chat = open_chat(account, dialog_id)
|
||||||
|
enter = chat.getChatActivityEnterView() if chat is not None else None
|
||||||
|
if enter is not None:
|
||||||
|
self.composer.set_text(enter, text if text.endswith(" ") else text + " ")
|
||||||
|
bulletin(reason, error=True)
|
||||||
|
|
||||||
|
# settings
|
||||||
|
|
||||||
|
def _tokens_changed(self, _value=None):
|
||||||
|
self.catalog.refresh(reset=True)
|
||||||
|
|
||||||
|
def _test_connection(self, _view=None):
|
||||||
|
def run():
|
||||||
|
if not self.api.configured:
|
||||||
|
bulletin("Client Commands: set the panel URL and API tokens", error=True)
|
||||||
|
return
|
||||||
|
lines, failed = [], False
|
||||||
|
for token in self.api.tokens():
|
||||||
|
try:
|
||||||
|
me = self.api.me(token)
|
||||||
|
state = "✓" if me.get("running") else "✓ (not running on the panel)"
|
||||||
|
lines.append(f"{me.get('account_name')} {state}")
|
||||||
|
except ApiError as exc:
|
||||||
|
failed = True
|
||||||
|
lines.append(f"{token[:10]}…: {exc}")
|
||||||
|
bulletin("Accounts: " + "; ".join(lines), error=failed)
|
||||||
|
self.catalog.refresh()
|
||||||
|
|
||||||
|
in_background(run)
|
||||||
|
|
||||||
|
def _refresh(self, _view=None):
|
||||||
|
self.catalog.refresh(reset=True)
|
||||||
|
bulletin("Client Commands: asking the panel for commands")
|
||||||
|
|
||||||
|
def _command_rows(self):
|
||||||
|
from client_utils import get_selected_account
|
||||||
|
|
||||||
|
account = get_selected_account()
|
||||||
|
commands = self.catalog.commands_for(account)
|
||||||
|
if not commands:
|
||||||
|
return [Text(text="No commands yet", subtext="Set up the panel above, or install a plugin that adds some")]
|
||||||
|
rows = []
|
||||||
|
for command in commands:
|
||||||
|
source = "panel" if not command.local else command.source
|
||||||
|
about = command.summary or command.description
|
||||||
|
rows.append(
|
||||||
|
Text(
|
||||||
|
text=command.usage(),
|
||||||
|
subtext=f"{about} · {source}" if about else source,
|
||||||
|
icon="msg_bot",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def create_settings(self):
|
||||||
|
rows = [
|
||||||
|
Header(text="Panel"),
|
||||||
|
Input(
|
||||||
|
key=SERVER_URL,
|
||||||
|
text="telegram-panel URL",
|
||||||
|
default=DEFAULTS[SERVER_URL],
|
||||||
|
subtext="e.g. https://panel.example.com",
|
||||||
|
icon="msg_link",
|
||||||
|
on_change=self._tokens_changed,
|
||||||
|
),
|
||||||
|
Input(
|
||||||
|
key=API_TOKENS,
|
||||||
|
text="API tokens",
|
||||||
|
default=DEFAULTS[API_TOKENS],
|
||||||
|
subtext="Client commands tokens, one per account, separated by spaces "
|
||||||
|
"(panel: Settings → API Tokens). Not the message logger ones.",
|
||||||
|
icon="msg_permissions",
|
||||||
|
on_change=self._tokens_changed,
|
||||||
|
),
|
||||||
|
Text(text="Test connection", icon="msg_info", on_click=self._test_connection),
|
||||||
|
Text(text="Refresh commands", icon="msg_retry", on_click=self._refresh),
|
||||||
|
Header(text="Commands"),
|
||||||
|
Switch(
|
||||||
|
key=SERVER_COMMANDS,
|
||||||
|
text="Panel commands",
|
||||||
|
default=DEFAULTS[SERVER_COMMANDS],
|
||||||
|
subtext="Run the panel's commands from here instead of sending them",
|
||||||
|
icon="msg_bot",
|
||||||
|
),
|
||||||
|
Switch(
|
||||||
|
key=SEND_TO_CHAT,
|
||||||
|
text="Send response in chat",
|
||||||
|
default=DEFAULTS[SEND_TO_CHAT],
|
||||||
|
subtext="Responses go into the chat as you, not only to you. Also in the menu of a held send button",
|
||||||
|
icon="msg_discussion",
|
||||||
|
),
|
||||||
|
Switch(
|
||||||
|
key=SHOW_PARAMS,
|
||||||
|
text="Parameter hints",
|
||||||
|
default=DEFAULTS[SHOW_PARAMS],
|
||||||
|
subtext="List a command's parameters while you type it",
|
||||||
|
icon="msg_help",
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
text="Available commands",
|
||||||
|
icon="msg_bots",
|
||||||
|
create_sub_fragment=self._command_rows,
|
||||||
|
),
|
||||||
|
Divider(text="Hold send on a command to send it as a plain message instead."),
|
||||||
|
]
|
||||||
|
return rows
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Telegram Android dialog ids <-> the panel's marked (Telethon) chat ids.
|
||||||
|
|
||||||
|
Android: users are positive, basic groups and channels both `-id`.
|
||||||
|
Telethon: users positive, basic groups `-id`, channels `-(10**12 + id)`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
CHANNEL_OFFSET = 10**12
|
||||||
|
|
||||||
|
|
||||||
|
def marked_chat_id(account, dialog_id):
|
||||||
|
"""The panel's chat id for a dialog, or None (secret chats)."""
|
||||||
|
from org.telegram.messenger import ChatObject, DialogObject, MessagesController
|
||||||
|
|
||||||
|
dialog_id = int(dialog_id)
|
||||||
|
if DialogObject.isEncryptedDialog(dialog_id):
|
||||||
|
return None
|
||||||
|
if dialog_id > 0:
|
||||||
|
return dialog_id
|
||||||
|
chat = MessagesController.getInstance(account).getChat(-dialog_id)
|
||||||
|
if chat is not None and ChatObject.isChannel(chat):
|
||||||
|
return -(CHANNEL_OFFSET + -dialog_id)
|
||||||
|
return dialog_id
|
||||||
+190
@@ -0,0 +1,190 @@
|
|||||||
|
"""Running a command: on the panel, or on the device for plugins' commands.
|
||||||
|
|
||||||
|
While it runs, a "Running…" response stands in the chat; then the response
|
||||||
|
(or the error) replaces it, shown only to you, or the response goes into the
|
||||||
|
chat as the account when "Send response in chat" is on.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import diag
|
||||||
|
import peers
|
||||||
|
from api import ApiError
|
||||||
|
from commands import markup
|
||||||
|
from commands.parse import parse
|
||||||
|
from jutil import array_list, in_background, on_ui
|
||||||
|
from hooks import responses
|
||||||
|
|
||||||
|
_RUNNING = "⏳ Running…"
|
||||||
|
_DONE = "✓ Done"
|
||||||
|
|
||||||
|
|
||||||
|
class Invocation:
|
||||||
|
"""A command the composer is about to run (built on the UI thread)."""
|
||||||
|
|
||||||
|
def __init__(self, account, dialog_id, topic_id, reply, thread, text, command, send_to_chat):
|
||||||
|
self.account = int(account)
|
||||||
|
self.dialog_id = int(dialog_id)
|
||||||
|
self.topic_id = int(topic_id or 0)
|
||||||
|
self.reply = reply # the replied MessageObject, or None
|
||||||
|
self.thread = thread # the topic's MessageObject, or None
|
||||||
|
self.text = text.strip()
|
||||||
|
self.command = command
|
||||||
|
self.send_to_chat = bool(send_to_chat)
|
||||||
|
self.chat_id = peers.marked_chat_id(account, dialog_id)
|
||||||
|
|
||||||
|
|
||||||
|
class Runner:
|
||||||
|
def __init__(self, plugin, api, catalog, library):
|
||||||
|
self.plugin = plugin
|
||||||
|
self.api = api
|
||||||
|
self.catalog = catalog
|
||||||
|
self.library = library
|
||||||
|
# Texts this plugin sends itself: the send hook lets them through.
|
||||||
|
self._bypass = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
# sending as plain messages
|
||||||
|
|
||||||
|
def bypass(self, text):
|
||||||
|
with self._lock:
|
||||||
|
self._bypass[text.strip()] = time.monotonic() + 30
|
||||||
|
|
||||||
|
def bypassed(self, text, consume=False):
|
||||||
|
key = (text or "").strip()
|
||||||
|
with self._lock:
|
||||||
|
until = self._bypass.get(key, 0)
|
||||||
|
if until < time.monotonic():
|
||||||
|
self._bypass.pop(key, None)
|
||||||
|
return False
|
||||||
|
if consume:
|
||||||
|
self._bypass.pop(key, None)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# running
|
||||||
|
|
||||||
|
def run(self, inv):
|
||||||
|
"""Starts `inv` (UI thread)."""
|
||||||
|
pending = responses.show(
|
||||||
|
inv.account, inv.dialog_id, inv.topic_id, inv.reply, _RUNNING, header=inv.text, keep=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def work():
|
||||||
|
try:
|
||||||
|
result = self._local(inv) if inv.command.local else self._server(inv)
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error(f"/{inv.command.name}", exc)
|
||||||
|
result = {"ok": False, "error": f"/{inv.command.name} failed: {exc}"}
|
||||||
|
on_ui(lambda: self._finish(inv, pending, result))
|
||||||
|
|
||||||
|
in_background(work)
|
||||||
|
|
||||||
|
def _server(self, inv):
|
||||||
|
if inv.chat_id is None:
|
||||||
|
return {"ok": False, "error": "Panel commands don't work in secret chats"}
|
||||||
|
token = self.catalog.token_for(inv.account)
|
||||||
|
if token is None:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": "No API token for this account: create a Client commands token "
|
||||||
|
"for it in the panel (Settings → API Tokens) and add it in the plugin settings",
|
||||||
|
}
|
||||||
|
from catalog import own_user_id
|
||||||
|
|
||||||
|
reply_id = int(inv.reply.getId()) if inv.reply is not None else None
|
||||||
|
try:
|
||||||
|
return self.api.execute(
|
||||||
|
token,
|
||||||
|
inv.text,
|
||||||
|
inv.chat_id,
|
||||||
|
reply_id,
|
||||||
|
inv.topic_id or None,
|
||||||
|
inv.send_to_chat,
|
||||||
|
own_user_id(inv.account),
|
||||||
|
)
|
||||||
|
except ApiError as exc:
|
||||||
|
if exc.refused:
|
||||||
|
self.catalog.refresh(reset=True)
|
||||||
|
return {"ok": False, "error": str(exc)}
|
||||||
|
|
||||||
|
def _local(self, inv):
|
||||||
|
from library import CommandContext
|
||||||
|
|
||||||
|
try:
|
||||||
|
values = parse(inv.command, inv.text)
|
||||||
|
ctx = CommandContext(
|
||||||
|
inv.account,
|
||||||
|
inv.dialog_id,
|
||||||
|
inv.topic_id,
|
||||||
|
inv.reply,
|
||||||
|
inv.text,
|
||||||
|
inv.command.name,
|
||||||
|
values,
|
||||||
|
inv.send_to_chat,
|
||||||
|
)
|
||||||
|
html = inv.command.handler(ctx, **values)
|
||||||
|
except Exception as exc:
|
||||||
|
if type(exc).__name__ == "CommandError":
|
||||||
|
return {"ok": False, "error": str(exc)}
|
||||||
|
raise
|
||||||
|
html = "" if html is None else str(html)
|
||||||
|
text, entities = markup.parse(html)
|
||||||
|
return {"ok": True, "html": html, "text": text, "entities": entities}
|
||||||
|
|
||||||
|
def _finish(self, inv, pending, result):
|
||||||
|
if pending is not None:
|
||||||
|
responses.remove(inv.account, inv.dialog_id, pending)
|
||||||
|
if not result.get("ok"):
|
||||||
|
error = result.get("error") or "Failed"
|
||||||
|
responses.show(inv.account, inv.dialog_id, inv.topic_id, inv.reply, f"❌ {error}", header=inv.text)
|
||||||
|
return
|
||||||
|
text = result.get("text") or ""
|
||||||
|
if inv.send_to_chat:
|
||||||
|
if inv.command.local and text.strip():
|
||||||
|
self.send(inv.account, inv.dialog_id, inv.reply, inv.thread, text, result.get("entities") or ())
|
||||||
|
return
|
||||||
|
if not text.strip():
|
||||||
|
responses.show(inv.account, inv.dialog_id, inv.topic_id, inv.reply, _DONE, header=inv.text)
|
||||||
|
return
|
||||||
|
responses.show(
|
||||||
|
inv.account,
|
||||||
|
inv.dialog_id,
|
||||||
|
inv.topic_id,
|
||||||
|
inv.reply,
|
||||||
|
text,
|
||||||
|
result.get("entities") or (),
|
||||||
|
header=inv.text,
|
||||||
|
thread=inv.thread,
|
||||||
|
answer=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def send(self, account, dialog_id, reply, thread, text, entities):
|
||||||
|
"""Sends a response into the chat as the account: an ordinary text
|
||||||
|
message with formatting entities (not a rich or ephemeral one; a reply
|
||||||
|
to an ephemeral message would make it one). UI thread."""
|
||||||
|
from org.telegram.messenger import SendMessagesHelper
|
||||||
|
|
||||||
|
if reply is not None and reply.isEphemeral():
|
||||||
|
reply = None
|
||||||
|
self.bypass(text)
|
||||||
|
params = SendMessagesHelper.SendMessageParams.of(
|
||||||
|
text,
|
||||||
|
dialog_id,
|
||||||
|
reply,
|
||||||
|
thread,
|
||||||
|
None,
|
||||||
|
False,
|
||||||
|
responses.tl_entities(entities) if entities else array_list([]),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
True,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
params.richMessage = None
|
||||||
|
if hasattr(params, "ephemeralReceiverBotId"):
|
||||||
|
params.ephemeralReceiverBotId = 0
|
||||||
|
SendMessagesHelper.getInstance(account).sendMessage(params)
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
def normalize(name: str) -> str:
|
|
||||||
return " ".join(w[:1].upper() + w[1:] for w in name.split())
|
|
||||||
+312
-138
@@ -1,6 +1,11 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Offline verification harness for a built .plugin bundle. Runs without a phone.
|
"""Offline verification harness for a built .plugin bundle. Runs without a phone.
|
||||||
|
|
||||||
|
Executes the bundle against faked SDK modules and checks what can be checked
|
||||||
|
off the device: metadata, the argument parser (in step with telegram-panel's),
|
||||||
|
HTML responses, the library API other plugins use, the command catalog
|
||||||
|
against a fake panel, and plugin commands run by the runner.
|
||||||
|
|
||||||
Usage: python3 tools/verify_bundle.py <bundle.plugin>
|
Usage: python3 tools/verify_bundle.py <bundle.plugin>
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -8,7 +13,7 @@ from __future__ import annotations
|
|||||||
import ast
|
import ast
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
import traceback
|
import tempfile
|
||||||
import types
|
import types
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
@@ -31,6 +36,14 @@ def check(name: str, condition: bool, detail: str = "") -> None:
|
|||||||
_FAILURES.append(name)
|
_FAILURES.append(name)
|
||||||
|
|
||||||
|
|
||||||
|
def raises(fn, error_type, text=None):
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
except error_type as exc:
|
||||||
|
return text is None or text in str(exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class HookStrategy(Enum):
|
class HookStrategy(Enum):
|
||||||
DEFAULT = auto()
|
DEFAULT = auto()
|
||||||
MODIFY = auto()
|
MODIFY = auto()
|
||||||
@@ -44,7 +57,8 @@ class HookResult:
|
|||||||
params: object = None
|
params: object = None
|
||||||
|
|
||||||
|
|
||||||
LOG_LINES: list[str] = []
|
class MethodHook:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class BasePlugin:
|
class BasePlugin:
|
||||||
@@ -54,56 +68,85 @@ class BasePlugin:
|
|||||||
def get_setting(self, key, default=None):
|
def get_setting(self, key, default=None):
|
||||||
return self._settings.get(key, default)
|
return self._settings.get(key, default)
|
||||||
|
|
||||||
def set_setting(self, key, value):
|
def set_setting(self, key, value, reload_settings=False):
|
||||||
self._settings[key] = value
|
self._settings[key] = value
|
||||||
|
|
||||||
def add_on_send_message_hook(self):
|
def add_on_send_message_hook(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def on_plugin_load(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def on_plugin_unload(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def create_settings(self):
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def fake_log(*args, **kwargs):
|
|
||||||
LOG_LINES.append(" ".join(str(a) for a in args))
|
|
||||||
|
|
||||||
|
|
||||||
class _SettingsRow:
|
class _SettingsRow:
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
self.__dict__.update(kwargs)
|
self.__dict__.update(kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class _UserConfig:
|
||||||
|
"""org.telegram.messenger.UserConfig: account slot n is Telegram user 1000 + n."""
|
||||||
|
|
||||||
|
def __init__(self, slot):
|
||||||
|
self.slot = slot
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def getInstance(cls, slot):
|
||||||
|
return cls(slot)
|
||||||
|
|
||||||
|
def getClientUserId(self):
|
||||||
|
return 1000 + self.slot
|
||||||
|
|
||||||
|
|
||||||
|
FILES_DIR = tempfile.mkdtemp(prefix="cc-verify-")
|
||||||
|
|
||||||
|
|
||||||
def install_fakes() -> None:
|
def install_fakes() -> None:
|
||||||
base_plugin_mod = types.ModuleType("base_plugin")
|
def module(name, **attrs):
|
||||||
base_plugin_mod.BasePlugin = BasePlugin
|
mod = types.ModuleType(name)
|
||||||
base_plugin_mod.HookResult = HookResult
|
mod.__dict__.update(attrs)
|
||||||
base_plugin_mod.HookStrategy = HookStrategy
|
sys.modules[name] = mod
|
||||||
sys.modules["base_plugin"] = base_plugin_mod
|
return mod
|
||||||
|
|
||||||
android_utils_mod = types.ModuleType("android_utils")
|
module(
|
||||||
android_utils_mod.log = fake_log
|
"base_plugin",
|
||||||
sys.modules["android_utils"] = android_utils_mod
|
BasePlugin=BasePlugin,
|
||||||
|
HookResult=HookResult,
|
||||||
ui_mod = types.ModuleType("ui")
|
HookStrategy=HookStrategy,
|
||||||
ui_mod.__path__ = []
|
MethodHook=MethodHook,
|
||||||
sys.modules["ui"] = ui_mod
|
)
|
||||||
|
module("android_utils", log=lambda *a, **k: None, run_on_ui_thread=lambda fn, delay=0: fn())
|
||||||
ui_settings_mod = types.ModuleType("ui.settings")
|
module("file_utils", get_files_dir=lambda: FILES_DIR)
|
||||||
for cls_name in ("Header", "Input", "Text", "Switch"):
|
ui = module("ui")
|
||||||
setattr(ui_settings_mod, cls_name, type(cls_name, (_SettingsRow,), {}))
|
ui.__path__ = []
|
||||||
sys.modules["ui.settings"] = ui_settings_mod
|
ui.settings = module(
|
||||||
ui_mod.settings = ui_settings_mod
|
"ui.settings",
|
||||||
|
**{n: type(n, (_SettingsRow,), {}) for n in ("Header", "Input", "Text", "Switch", "Divider")},
|
||||||
|
)
|
||||||
|
for name in ("org", "org.telegram"):
|
||||||
|
module(name).__path__ = []
|
||||||
|
module("org.telegram.messenger", UserConfig=_UserConfig)
|
||||||
|
|
||||||
|
|
||||||
class SimpleParams:
|
class FakeApi:
|
||||||
def __init__(self, message):
|
"""The panel, as api.Api's methods answer."""
|
||||||
self.message = message
|
|
||||||
|
def __init__(self, tokens):
|
||||||
|
self._tokens = tokens # token -> (tg id, [command json]) or an ApiError
|
||||||
|
self.configured = True
|
||||||
|
|
||||||
|
def tokens(self):
|
||||||
|
return list(self._tokens)
|
||||||
|
|
||||||
|
def _of(self, token):
|
||||||
|
found = self._tokens[token]
|
||||||
|
if isinstance(found, Exception):
|
||||||
|
raise found
|
||||||
|
return found
|
||||||
|
|
||||||
|
def me(self, token):
|
||||||
|
tg_id, _ = self._of(token)
|
||||||
|
return {"tg_id": tg_id, "account_name": f"acc{tg_id}", "running": True}
|
||||||
|
|
||||||
|
def commands(self, token):
|
||||||
|
_, commands = self._of(token)
|
||||||
|
return {"commands": commands, "refresh_seconds": 3600}
|
||||||
|
|
||||||
|
|
||||||
KEY_TO_DUNDER = {
|
KEY_TO_DUNDER = {
|
||||||
@@ -133,6 +176,223 @@ def expected_dunders(meta: dict) -> dict:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def check_metadata(source, ns, bundle_path):
|
||||||
|
meta = json.loads(PLUGIN_JSON.read_text(encoding="utf-8"))
|
||||||
|
expected = expected_dunders(meta)
|
||||||
|
mismatches = [
|
||||||
|
f"{dunder}: expected {value!r}, got {ns.get(dunder)!r}"
|
||||||
|
for dunder, value in expected.items()
|
||||||
|
if ns.get(dunder) != value
|
||||||
|
]
|
||||||
|
check("metadata constants match plugin.json", not mismatches, "; ".join(mismatches))
|
||||||
|
tree = ast.parse(source, filename=str(bundle_path))
|
||||||
|
assigned = {
|
||||||
|
t.id for node in tree.body if isinstance(node, ast.Assign) for t in node.targets if isinstance(t, ast.Name)
|
||||||
|
}
|
||||||
|
check("metadata exposed as top-level literal assignments (AST)", set(expected) <= assigned)
|
||||||
|
plugins = [v for v in ns.values() if isinstance(v, type) and issubclass(v, BasePlugin) and v is not BasePlugin]
|
||||||
|
check("exactly one BasePlugin subclass defined", len(plugins) == 1, f"found {len(plugins)}")
|
||||||
|
return plugins[0] if len(plugins) == 1 else None
|
||||||
|
|
||||||
|
|
||||||
|
def check_parser(load):
|
||||||
|
model, parse = load("commands.model"), load("commands.parse")
|
||||||
|
Param, Command = model.Param, model.Command
|
||||||
|
ban = Command(
|
||||||
|
"ban",
|
||||||
|
[
|
||||||
|
Param("days", "integer"),
|
||||||
|
Param("mode", "choice", False, default="soft", choices=("soft", "hard")),
|
||||||
|
Param("silent", "boolean", False, default=False),
|
||||||
|
Param("reason", "string", False),
|
||||||
|
],
|
||||||
|
"Bans the replied user.",
|
||||||
|
aliases=["b"],
|
||||||
|
reply="required",
|
||||||
|
)
|
||||||
|
# The same cases as telegram-panel's tests/test_client_commands.py.
|
||||||
|
check("split /Ban@me", parse.split_command("/Ban@me 3") == ("ban", "3"))
|
||||||
|
check("no command mid-text", parse.split_command("hello /ban") is None)
|
||||||
|
check(
|
||||||
|
"positional, choice, boolean and the greedy last string",
|
||||||
|
parse.parse(ban, "/b 3 HARD yes for spamming links")
|
||||||
|
== {"days": 3, "mode": "hard", "silent": True, "reason": "for spamming links"},
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"named and quoted",
|
||||||
|
parse.parse(ban, '/ban reason:"too loud" days:7') == {"days": 7, "mode": "soft", "silent": False, "reason": "too loud"},
|
||||||
|
)
|
||||||
|
check("an empty name: is not given", parse.parse(ban, "/ban 1 mode:")["mode"] == "soft")
|
||||||
|
for text, error in (
|
||||||
|
("/ban", "Missing required parameter: days"),
|
||||||
|
("/ban x", "days must be a whole number"),
|
||||||
|
("/ban 1 medium", "mode must be one of: soft, hard"),
|
||||||
|
("/ban 1 soft maybe", "silent must be yes or no"),
|
||||||
|
('/ban 1 "open', "Unclosed quote"),
|
||||||
|
("/ban days:1 days:2", "days is given twice"),
|
||||||
|
):
|
||||||
|
check(f"error for {text!r}", raises(lambda: parse.parse(ban, text), parse.CommandError, error))
|
||||||
|
add = Command("add", [Param("a", "integer"), Param("b", "integer")])
|
||||||
|
check("extra words without a string to take them", raises(lambda: parse.parse(add, "/add 1 2 3"), parse.CommandError, "Unexpected arguments: 3"))
|
||||||
|
|
||||||
|
check("bare /prefix is a name being typed", parse.command_name_at("/ba") == "ba" and parse.command_name_at("/ba ") is None)
|
||||||
|
typing = parse.analyze(ban, "/ban 3 ", None, has_reply=True)
|
||||||
|
check("analyze: next free parameter is current", typing.current == "mode" and typing.complete, typing.current)
|
||||||
|
named = parse.analyze(ban, "/ban reason:so", None, has_reply=True)
|
||||||
|
check("analyze: a named parameter at the cursor", named.current == "reason" and named.missing == ["days"])
|
||||||
|
check("analyze: missing days blocks running", not named.complete and "days" in named.error)
|
||||||
|
no_reply = parse.analyze(ban, "/ban 3", None, has_reply=False)
|
||||||
|
check("analyze: a required reply blocks running", no_reply.error == "Reply to a message to use /ban")
|
||||||
|
middle = parse.analyze(ban, "/ban 3 hard", 6, has_reply=True)
|
||||||
|
check("analyze: the cursor inside a word", middle.current == "days", middle.current)
|
||||||
|
check("usage and hint", ban.usage() == "/ban <days> [mode] [silent] [reason]" and ban.hint().startswith("<days> [mode]"))
|
||||||
|
back = Command.from_json(ban.to_json())
|
||||||
|
check("command JSON round trip", back.to_json() == ban.to_json() and back.names == ("ban", "b"))
|
||||||
|
check("bad names are refused", raises(lambda: Command("no spaces"), ValueError))
|
||||||
|
|
||||||
|
|
||||||
|
def check_markup(load):
|
||||||
|
markup = load("commands.markup")
|
||||||
|
text, entities = markup.parse('😀 <b>bold</b> <a href="https://x.y">link</a><br><tg-spoiler>s</tg-spoiler>')
|
||||||
|
check("HTML text", text == "😀 bold link\ns", repr(text))
|
||||||
|
check(
|
||||||
|
"HTML entities in UTF-16 units",
|
||||||
|
[(e["type"], e["offset"], e["length"]) for e in entities] == [("bold", 3, 4), ("text_link", 8, 4), ("spoiler", 13, 1)],
|
||||||
|
repr(entities),
|
||||||
|
)
|
||||||
|
_, pre = markup.parse('<pre><code class="language-py">x = 1</code></pre>')
|
||||||
|
check("pre with a language", pre == [{"type": "pre", "offset": 0, "length": 5, "language": "py"}], repr(pre))
|
||||||
|
_, mention = markup.parse('<a href="tg://user?id=42">Ann</a>')
|
||||||
|
check("tg://user links are mentions", mention[0]["type"] == "mention_name" and mention[0]["user_id"] == 42)
|
||||||
|
check("escape", markup.escape("<a&b>") == "<a&b>")
|
||||||
|
|
||||||
|
|
||||||
|
def check_library(load):
|
||||||
|
import builtins
|
||||||
|
|
||||||
|
sys.modules.pop("client_commands", None)
|
||||||
|
builtins.__dict__.pop("__client_commands_state__", None)
|
||||||
|
queued = []
|
||||||
|
early = types.ModuleType("client_commands")
|
||||||
|
early.pending = [lambda api: queued.append(api.register("early", lambda ctx: "hi", owner="other"))]
|
||||||
|
sys.modules["client_commands"] = early
|
||||||
|
lib = load("library").Library()
|
||||||
|
lib.install()
|
||||||
|
api = sys.modules["client_commands"]
|
||||||
|
check("library published as client_commands", api is early and api.available and hasattr(api, "register"))
|
||||||
|
check("setups queued before loading ran", len(queued) == 1 and "pending" not in api.__dict__)
|
||||||
|
api.register(
|
||||||
|
"roll",
|
||||||
|
lambda ctx, sides=6: f"🎲 <b>{sides}</b>",
|
||||||
|
params=[api.Param("sides", "integer", required=False, default=6)],
|
||||||
|
summary="Rolls a die",
|
||||||
|
owner="dice",
|
||||||
|
)
|
||||||
|
names = [c["name"] for c in api.commands()]
|
||||||
|
check("registered commands listed", names == ["early", "roll"], repr(names))
|
||||||
|
lib.uninstall()
|
||||||
|
sys.modules.pop("client_commands") # the engine drops modules a plugin added
|
||||||
|
again = load("library").Library()
|
||||||
|
again.install()
|
||||||
|
check("registrations survive a reload", [c.name for c in again.local_commands()] == ["early", "roll"])
|
||||||
|
api.register("late", lambda ctx: None, owner="other") # an api kept from before
|
||||||
|
check("an old api registers into the same registry", "late" in [c.name for c in again.local_commands()])
|
||||||
|
api = sys.modules["client_commands"]
|
||||||
|
check("unregister_owner", api.unregister_owner("other") == 2 and not api.unregister("early"))
|
||||||
|
return again
|
||||||
|
|
||||||
|
|
||||||
|
def check_catalog(load, library):
|
||||||
|
from_api = load("api")
|
||||||
|
catalog_mod = load("catalog")
|
||||||
|
getid = {
|
||||||
|
"name": "getid",
|
||||||
|
"summary": "Ids",
|
||||||
|
"reply": "optional",
|
||||||
|
"params": [{"name": "target", "type": "string", "required": False}],
|
||||||
|
}
|
||||||
|
roll_shadow = {"name": "roll", "summary": "Panel roll", "params": []}
|
||||||
|
api = FakeApi(
|
||||||
|
{
|
||||||
|
"tok_a": (1000, [getid, roll_shadow]),
|
||||||
|
"tok_b": (1001, [{"name": "ping"}]),
|
||||||
|
"tok_bad": from_api.ApiError("403: Not a commands token", 403),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
plugin = BasePlugin()
|
||||||
|
catalog = catalog_mod.Catalog(plugin, api, library)
|
||||||
|
catalog._refresh_due()
|
||||||
|
names0 = [c.name for c in catalog.commands_for(0)]
|
||||||
|
check("account 0: panel commands, then plugins'", names0 == ["getid", "roll"], repr(names0))
|
||||||
|
check("a panel command shadows a plugin's", catalog.find(0, "roll").summary == "Panel roll")
|
||||||
|
names1 = [c.name for c in catalog.commands_for(1)]
|
||||||
|
check("account 1 has its own panel commands", names1 == ["ping", "roll"], repr(names1))
|
||||||
|
check("tokens picked by Telegram id", catalog.token_for(0) == "tok_a" and catalog.token_for(1) == "tok_b")
|
||||||
|
check("a refused token is reported", "tok_bad" in catalog.errors())
|
||||||
|
check("accounts without a token only get plugins'", [c.name for c in catalog.commands_for(5)] == ["roll"])
|
||||||
|
cached = catalog_mod.Catalog(plugin, FakeApi({}), library)
|
||||||
|
check("commands cached on disk", [c.name for c in cached.commands_for(0)] == ["getid", "roll"])
|
||||||
|
plugin.set_setting("server_commands", False)
|
||||||
|
check("panel commands can be turned off", [c.name for c in catalog.commands_for(0)] == ["roll"])
|
||||||
|
plugin.set_setting("server_commands", True)
|
||||||
|
api._tokens.pop("tok_b")
|
||||||
|
api._tokens.pop("tok_bad")
|
||||||
|
catalog._refresh_due()
|
||||||
|
check("a removed token's commands go", [c.name for c in catalog.commands_for(1)] == ["roll"])
|
||||||
|
|
||||||
|
|
||||||
|
def check_runner(load, library):
|
||||||
|
runner_mod = load("runner")
|
||||||
|
runner = runner_mod.Runner(BasePlugin(), None, None, library)
|
||||||
|
roll = next(c for c in library.local_commands() if c.name == "roll")
|
||||||
|
inv = types.SimpleNamespace(
|
||||||
|
account=0, dialog_id=5, topic_id=0, reply=None, text="/roll 20", command=roll, send_to_chat=False
|
||||||
|
)
|
||||||
|
result = runner._local(inv)
|
||||||
|
check(
|
||||||
|
"plugin command response parsed",
|
||||||
|
result["ok"] and result["text"] == "🎲 20" and result["entities"] == [{"type": "bold", "offset": 3, "length": 2}],
|
||||||
|
repr(result),
|
||||||
|
)
|
||||||
|
api = sys.modules["client_commands"]
|
||||||
|
|
||||||
|
def strict(ctx, n):
|
||||||
|
raise api.CommandError(f"no {n}")
|
||||||
|
|
||||||
|
api.register("strict", strict, params=[{"name": "n", "type": "integer", "required": True}], owner="t")
|
||||||
|
inv.command = next(c for c in library.local_commands() if c.name == "strict")
|
||||||
|
inv.text = "/strict 3"
|
||||||
|
check("CommandError is a user-facing error", runner._local(inv) == {"ok": False, "error": "no 3"})
|
||||||
|
inv.text = "/strict"
|
||||||
|
check("bad arguments are a user-facing error", runner._local(inv)["error"] == "Missing required parameter: n")
|
||||||
|
runner.bypass("/strict 1")
|
||||||
|
check("bypassed text goes through once", runner.bypassed("/strict 1", consume=True) and not runner.bypassed("/strict 1"))
|
||||||
|
|
||||||
|
|
||||||
|
def check_send_hook(plugin_cls):
|
||||||
|
plugin = plugin_cls()
|
||||||
|
plugin.runner = types.SimpleNamespace(bypassed=lambda text, consume=False: False)
|
||||||
|
plugin.catalog = types.SimpleNamespace(find=lambda account, name: None)
|
||||||
|
params = types.SimpleNamespace(message="hello", scheduleDate=0)
|
||||||
|
check("plain messages pass the send hook", plugin.on_send_message_hook(0, params).strategy == HookStrategy.DEFAULT)
|
||||||
|
params.message = "/unknown"
|
||||||
|
check("unknown commands pass the send hook", plugin.on_send_message_hook(0, params).strategy == HookStrategy.DEFAULT)
|
||||||
|
model = sys.modules.get("_cc_model")
|
||||||
|
demo = model.Command("demo", [model.Param("count", "integer")])
|
||||||
|
refills = []
|
||||||
|
plugin.catalog = types.SimpleNamespace(find=lambda account, name: demo if name == "demo" else None)
|
||||||
|
plugin._refill = lambda *args: refills.append(args)
|
||||||
|
params = types.SimpleNamespace(message="/demo", scheduleDate=0, peer=5, replyToMsg=None, replyToTopMsg=None)
|
||||||
|
result = plugin.on_send_message_hook(0, params)
|
||||||
|
check(
|
||||||
|
"an incomplete command is never sent: it goes back into the field",
|
||||||
|
result.strategy == HookStrategy.CANCEL and refills and refills[0][2] == "/demo",
|
||||||
|
repr(refills),
|
||||||
|
)
|
||||||
|
keys = {getattr(r, "key", None) for r in plugin.create_settings()}
|
||||||
|
check("settings have the panel URL and tokens", {"server_url", "api_tokens", "send_to_chat"} <= keys, repr(keys))
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None) -> int:
|
def main(argv=None) -> int:
|
||||||
argv = sys.argv[1:] if argv is None else argv
|
argv = sys.argv[1:] if argv is None else argv
|
||||||
if len(argv) != 1:
|
if len(argv) != 1:
|
||||||
@@ -145,112 +405,26 @@ def main(argv=None) -> int:
|
|||||||
|
|
||||||
install_fakes()
|
install_fakes()
|
||||||
source = bundle_path.read_text(encoding="utf-8")
|
source = bundle_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
modules_before = set(sys.modules)
|
modules_before = set(sys.modules)
|
||||||
ns = {"__name__": "hello_bundle"}
|
ns = {"__name__": "client_commands_plugin"}
|
||||||
exec(compile(source, str(bundle_path), "exec"), ns)
|
exec(compile(source, str(bundle_path), "exec"), ns)
|
||||||
new_modules = set(sys.modules) - modules_before
|
|
||||||
|
|
||||||
meta = json.loads(PLUGIN_JSON.read_text(encoding="utf-8"))
|
plugin_cls = check_metadata(source, ns, bundle_path)
|
||||||
expected = expected_dunders(meta)
|
load = ns.get("_bundle_load")
|
||||||
mismatches = [
|
check("bundle exposes its module loader", load is not None)
|
||||||
f"{dunder}: expected {value!r}, got {ns.get(dunder)!r}"
|
if load is not None:
|
||||||
for dunder, value in expected.items()
|
sys.modules["_cc_model"] = load("commands.model")
|
||||||
if ns.get(dunder) != value
|
check_parser(load)
|
||||||
]
|
check_markup(load)
|
||||||
check("metadata constants match plugin.json", not mismatches, "; ".join(mismatches))
|
library = check_library(load)
|
||||||
|
check_catalog(load, library)
|
||||||
tree = ast.parse(source, filename=str(bundle_path))
|
check_runner(load, library)
|
||||||
top_level_assigned = {
|
|
||||||
target.id
|
|
||||||
for node in tree.body
|
|
||||||
if isinstance(node, ast.Assign)
|
|
||||||
for target in node.targets
|
|
||||||
if isinstance(target, ast.Name)
|
|
||||||
}
|
|
||||||
check(
|
|
||||||
"metadata exposed as top-level literal assignments (AST)",
|
|
||||||
set(expected) <= top_level_assigned,
|
|
||||||
f"missing: {set(expected) - top_level_assigned}",
|
|
||||||
)
|
|
||||||
|
|
||||||
plugin_classes = [
|
|
||||||
value
|
|
||||||
for value in ns.values()
|
|
||||||
if isinstance(value, type) and issubclass(value, BasePlugin) and value is not BasePlugin
|
|
||||||
]
|
|
||||||
check(
|
|
||||||
"exactly one BasePlugin subclass defined",
|
|
||||||
len(plugin_classes) == 1,
|
|
||||||
f"found {len(plugin_classes)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
plugin_cls = plugin_classes[0] if len(plugin_classes) == 1 else None
|
|
||||||
if plugin_cls is not None:
|
if plugin_cls is not None:
|
||||||
check(
|
check_send_hook(plugin_cls)
|
||||||
"plugin class __module__ matches executed namespace",
|
|
||||||
plugin_cls.__module__ == ns["__name__"],
|
|
||||||
f"__module__={plugin_cls.__module__!r} != {ns['__name__']!r}",
|
|
||||||
)
|
|
||||||
|
|
||||||
plugin = plugin_cls()
|
sys.modules.pop("_cc_model", None)
|
||||||
LOG_LINES.clear()
|
leaked = {"commands", "hooks", "catalog", "runner", "main"} & (set(sys.modules) - modules_before)
|
||||||
plugin.on_plugin_load()
|
check("bundled modules stay out of sys.modules", not leaked, f"leaked: {leaked}")
|
||||||
check(
|
|
||||||
"on_plugin_load emits load log line",
|
|
||||||
any("hello_bundle loaded" in line for line in LOG_LINES),
|
|
||||||
f"log lines: {LOG_LINES}",
|
|
||||||
)
|
|
||||||
|
|
||||||
params = SimpleParams(".hello alice smith")
|
|
||||||
result = plugin.on_send_message_hook(account=0, params=params)
|
|
||||||
check(
|
|
||||||
"'.hello alice smith' renders greeting",
|
|
||||||
params.message == "Hello, Alice Smith!",
|
|
||||||
f"got {params.message!r}",
|
|
||||||
)
|
|
||||||
check(
|
|
||||||
"on_send_message_hook returns MODIFY strategy",
|
|
||||||
result.strategy == HookStrategy.MODIFY,
|
|
||||||
f"got {result.strategy}",
|
|
||||||
)
|
|
||||||
|
|
||||||
plugin.set_setting("template", "{nope}")
|
|
||||||
params2 = SimpleParams(".hello someone")
|
|
||||||
plugin.on_send_message_hook(account=0, params=params2)
|
|
||||||
check(
|
|
||||||
"bad template produces 'Bad template:' message",
|
|
||||||
params2.message.startswith("Bad template:"),
|
|
||||||
f"got {params2.message!r}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# main.py imports `render`/`TemplateError` by name, so they live directly
|
|
||||||
# in the executed namespace — call render() directly to inspect the raised
|
|
||||||
# traceback (proves linecache registration for bundled, non-file modules).
|
|
||||||
render = ns.get("render")
|
|
||||||
template_error = ns.get("TemplateError")
|
|
||||||
traceback_text = ""
|
|
||||||
if render is not None and template_error is not None:
|
|
||||||
try:
|
|
||||||
render("{nope}", "x")
|
|
||||||
except template_error:
|
|
||||||
traceback_text = traceback.format_exc()
|
|
||||||
check(
|
|
||||||
"render() traceback names src/greeting/template.py",
|
|
||||||
"src/greeting/template.py" in traceback_text,
|
|
||||||
f"traceback:\n{traceback_text}",
|
|
||||||
)
|
|
||||||
check(
|
|
||||||
"traceback includes the raising source line text",
|
|
||||||
"unsupported placeholder" in traceback_text,
|
|
||||||
f"traceback:\n{traceback_text}",
|
|
||||||
)
|
|
||||||
|
|
||||||
check(
|
|
||||||
"sys.modules gained no entry for bundled module names",
|
|
||||||
not ({"greeting", "textutil", "main"} & new_modules),
|
|
||||||
f"leaked: {({'greeting', 'textutil', 'main'} & new_modules)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
if _FAILURES:
|
if _FAILURES:
|
||||||
print(f"\n{len(_FAILURES)} check(s) failed: {', '.join(_FAILURES)}", file=sys.stderr)
|
print(f"\n{len(_FAILURES)} check(s) failed: {', '.join(_FAILURES)}", file=sys.stderr)
|
||||||
|
|||||||
Reference in new issue
Block a user