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

Lists the panel account's client commands as Telegram slash commands (hourly
refresh, cached on the device), shows their parameters while typing, runs
complete commands on the panel instead of sending them and shows the answer
as an "Only visible to you" message (latest 20 kept in memory). Holding send
offers Run / Send as message / Send response in chat (red ! badge); an
answer's menu starts with Send in chat. Other plugins register their own
commands through the client_commands library module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lyuksovannyyandClaude Opus 5.5 committed 2026-10-09 16:19:20 +02:00
1 parent 84bdc44a76
commit 46a1f6ee1f
29 files changed
+3293 -220

No files matched your search

+312 -138
View File
@@ -1,6 +1,11 @@
#!/usr/bin/env python3
"""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>
"""
from __future__ import annotations
@@ -8,7 +13,7 @@ from __future__ import annotations
import ast
import json
import sys
import traceback
import tempfile
import types
from dataclasses import dataclass
from enum import Enum, auto
@@ -31,6 +36,14 @@ def check(name: str, condition: bool, detail: str = "") -> None:
_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):
DEFAULT = auto()
MODIFY = auto()
@@ -44,7 +57,8 @@ class HookResult:
params: object = None
LOG_LINES: list[str] = []
class MethodHook:
pass
class BasePlugin:
@@ -54,56 +68,85 @@ class BasePlugin:
def get_setting(self, key, default=None):
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
def add_on_send_message_hook(self):
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:
def __init__(self, **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:
base_plugin_mod = types.ModuleType("base_plugin")
base_plugin_mod.BasePlugin = BasePlugin
base_plugin_mod.HookResult = HookResult
base_plugin_mod.HookStrategy = HookStrategy
sys.modules["base_plugin"] = base_plugin_mod
def module(name, **attrs):
mod = types.ModuleType(name)
mod.__dict__.update(attrs)
sys.modules[name] = mod
return mod
android_utils_mod = types.ModuleType("android_utils")
android_utils_mod.log = fake_log
sys.modules["android_utils"] = android_utils_mod
ui_mod = types.ModuleType("ui")
ui_mod.__path__ = []
sys.modules["ui"] = ui_mod
ui_settings_mod = types.ModuleType("ui.settings")
for cls_name in ("Header", "Input", "Text", "Switch"):
setattr(ui_settings_mod, cls_name, type(cls_name, (_SettingsRow,), {}))
sys.modules["ui.settings"] = ui_settings_mod
ui_mod.settings = ui_settings_mod
module(
"base_plugin",
BasePlugin=BasePlugin,
HookResult=HookResult,
HookStrategy=HookStrategy,
MethodHook=MethodHook,
)
module("android_utils", log=lambda *a, **k: None, run_on_ui_thread=lambda fn, delay=0: fn())
module("file_utils", get_files_dir=lambda: FILES_DIR)
ui = module("ui")
ui.__path__ = []
ui.settings = module(
"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:
def __init__(self, message):
self.message = message
class FakeApi:
"""The panel, as api.Api's methods answer."""
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 = {
@@ -133,6 +176,223 @@ def expected_dunders(meta: dict) -> dict:
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>") == "&lt;a&amp;b&gt;")
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:
argv = sys.argv[1:] if argv is None else argv
if len(argv) != 1:
@@ -145,112 +405,26 @@ def main(argv=None) -> int:
install_fakes()
source = bundle_path.read_text(encoding="utf-8")
modules_before = set(sys.modules)
ns = {"__name__": "hello_bundle"}
ns = {"__name__": "client_commands_plugin"}
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"))
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))
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
plugin_cls = check_metadata(source, ns, bundle_path)
load = ns.get("_bundle_load")
check("bundle exposes its module loader", load is not None)
if load is not None:
sys.modules["_cc_model"] = load("commands.model")
check_parser(load)
check_markup(load)
library = check_library(load)
check_catalog(load, library)
check_runner(load, library)
if plugin_cls is not None:
check(
"plugin class __module__ matches executed namespace",
plugin_cls.__module__ == ns["__name__"],
f"__module__={plugin_cls.__module__!r} != {ns['__name__']!r}",
)
check_send_hook(plugin_cls)
plugin = plugin_cls()
LOG_LINES.clear()
plugin.on_plugin_load()
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)}",
)
sys.modules.pop("_cc_model", None)
leaked = {"commands", "hooks", "catalog", "runner", "main"} & (set(sys.modules) - modules_before)
check("bundled modules stay out of sys.modules", not leaked, f"leaked: {leaked}")
if _FAILURES:
print(f"\n{len(_FAILURES)} check(s) failed: {', '.join(_FAILURES)}", file=sys.stderr)