From 0b174fea7ecef119d88c266c55efccb613cfb094 Mon Sep 17 00:00:00 2001 From: lyuksovannyy Date: Fri, 9 Oct 2026 16:59:32 +0200 Subject: [PATCH] Client commands tokens cover all your panel accounts One token runs the commands of every account of its owner on the panel: /me lists them, the plugin asks for the commands of each one signed in on the device, and every request names its account by Telegram user id. Co-Authored-By: Claude Opus 5.5 --- README.md | 12 ++++--- docs/how-it-works.md | 16 +++++---- plugin.json | 2 +- src/api.py | 17 ++++----- src/catalog.py | 79 ++++++++++++++++++++++++++---------------- src/main.py | 20 ++++++++--- tools/verify_bundle.py | 33 +++++++++++------- 7 files changed, 112 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index 90ba9f2..3aa4090 100644 --- a/README.md +++ b/README.md @@ -50,11 +50,13 @@ you choose so. ## 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. +1. In the panel, open Settings → API Tokens and create a **Client commands** token. One token + covers all your accounts on the panel: each request names the account it comes from. + 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 token. +3. Tap *Test connection*: it lists your panel accounts signed in on this device. + *Available commands* lists what the current account has. Your panel user needs the `client:commands:use` permission. diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 53d5b24..5e27b0d 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -30,17 +30,20 @@ A command reaches the handler in one of two ways: ## 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. +`/api/v1/client/commands` is authenticated by a **commands** API token. A token isn't bound +to an account: it covers every user account of its owner (the accounts they added, or all for +a superuser), and each request names the account by its Telegram user id. 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}` | +| `GET /me` | `{name, accounts: [{account_id, tg_id, name, running}]}`: the accounts the token covers | +| `GET /?tg_id=…` | that account's `{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`. +the command when it isn't one of the token owner's accounts, 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. @@ -59,7 +62,8 @@ parse the same way. Keep the two in step: `tools/verify_bundle.py` and the panel ## The plugin side -- **Catalog** (`catalog.py`): asks `/me` for each token, then that account's commands. It +- **Catalog** (`catalog.py`): asks `/me` which accounts the token covers, then the commands + of each one signed in on the device. 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. diff --git a/plugin.json b/plugin.json index d125978..7ca7ee4 100644 --- a/plugin.json +++ b/plugin.json @@ -3,7 +3,7 @@ "name": "Client Commands", "description": "Your telegram-panel account's commands as slash commands, with Discord-style parameters; other plugins can add their own.", "author": "@lyuksovannyy", - "version": "1.0.0", + "version": "1.0.1", "icon": "exteraGramPluginIcons/6", "app_version": ">=12.5.1", "sdk_version": ">=1.4.4.3", diff --git a/src/api.py b/src/api.py index 21c680a..0002efb 100644 --- a/src/api.py +++ b/src/api.py @@ -1,9 +1,10 @@ """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. +A token (panel: Settings -> API Tokens -> Client commands) runs the client +commands of any of its owner's accounts on the panel; requests name the +account by its Telegram user id (`/me` lists them). One token covers every +account of yours; several only for several panel users. Message logger +tokens are refused here, and these there. Calls are blocking: run them off the UI thread (`jutil.in_background`). """ @@ -70,12 +71,12 @@ class Api: return response.json() def me(self, token): - """{account_id, tg_id, account_name, running, ...} of the token.""" + """{name, accounts: [{account_id, tg_id, name, running}]} of the token.""" return self._call(token, "GET", "/me") - def commands(self, token): - """{commands: [...], refresh_seconds}.""" - return self._call(token, "GET", "/") + def commands(self, token, tg_id): + """{commands: [...], refresh_seconds} of the account `tg_id`.""" + return self._call(token, "GET", "/", params={"tg_id": int(tg_id)}) 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`: diff --git a/src/catalog.py b/src/catalog.py index fb54c26..eb13aae 100644 --- a/src/catalog.py +++ b/src/catalog.py @@ -1,8 +1,9 @@ """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 panel's: a token runs any of its owner's accounts on the panel. It is + asked which (`/me`), then for the commands of each one signed in here, + again once an hour (or when the panel says so), and they are cached on + disk, so a restart shows them at once, even offline. - The ones plugins registered through the library (`library.py`): for every account. @@ -26,6 +27,18 @@ def own_user_id(slot): return int(UserConfig.getInstance(int(slot)).getClientUserId()) +def signed_in(): + """Telegram user ids of the accounts signed in on this device.""" + from org.telegram.messenger import UserConfig + + out = set() + for slot in range(UserConfig.MAX_ACCOUNT_COUNT): + config = UserConfig.getInstance(slot) + if config.isClientActivated(): + out.add(int(config.getClientUserId())) + return out + + class Catalog: def __init__(self, plugin, api, library): self.plugin = plugin @@ -73,13 +86,11 @@ class Catalog: 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) + try: + if tg_id in self._learn(candidate): + return candidate + except ApiError: + continue return token def account_name(self, slot): @@ -151,40 +162,48 @@ class Catalog: return (min(upcoming) - time.time()) if upcoming else REFRESH_SECONDS def _learn(self, token): + """{tg id: account name} of the token's accounts here; the token is + remembered for them.""" 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") + here = signed_in() + mine = { + int(a["tg_id"]): a.get("name") + for a in me.get("accounts") or () + if a.get("tg_id") and int(a["tg_id"]) in here + } with self._lock: - self._token_of[tg_id] = token - return tg_id, me + for tg_id in mine: + self._token_of[tg_id] = token + return mine 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}") + mine = self._learn(token) + listings = {tg_id: self.api.commands(token, tg_id) for tg_id in mine} 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) + refresh = REFRESH_SECONDS with self._lock: self._errors.pop(token, None) + for tg_id, listing in listings.items(): + 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}") + refresh = min(refresh, int(listing.get("refresh_seconds") or REFRESH_SECONDS)) + self._server[tg_id] = { + "commands": commands, + "account": mine[tg_id], + "fetched": time.time(), + } + diag.log(f"{len(commands)} commands for {tg_id}") 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() diff --git a/src/main.py b/src/main.py index fa35a01..bc680bc 100644 --- a/src/main.py +++ b/src/main.py @@ -112,15 +112,25 @@ class ClientCommandsPlugin(BasePlugin): if not self.api.configured: bulletin("Client Commands: set the panel URL and API tokens", error=True) return + from catalog import signed_in + + here = signed_in() 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}") + continue + for account in me.get("accounts") or (): + if account.get("tg_id") not in here: + continue + state = "✓" if account.get("running") else "✓ (not running on the panel)" + lines.append(f"{account.get('name')} {state}") + if not lines: + failed = True + lines.append("none of the accounts here are on the panel") bulletin("Accounts: " + "; ".join(lines), error=failed) self.catalog.refresh() @@ -163,10 +173,10 @@ class ClientCommandsPlugin(BasePlugin): ), Input( key=API_TOKENS, - text="API tokens", + text="API token", default=DEFAULTS[API_TOKENS], - subtext="Client commands tokens, one per account, separated by spaces " - "(panel: Settings → API Tokens). Not the message logger ones.", + subtext="A Client commands token (panel: Settings → API Tokens); it covers all " + "your accounts. Several, separated by spaces, for several panel users.", icon="msg_permissions", on_change=self._tokens_changed, ), diff --git a/tools/verify_bundle.py b/tools/verify_bundle.py index 8853a6a..5926892 100755 --- a/tools/verify_bundle.py +++ b/tools/verify_bundle.py @@ -81,7 +81,10 @@ class _SettingsRow: class _UserConfig: - """org.telegram.messenger.UserConfig: account slot n is Telegram user 1000 + n.""" + """org.telegram.messenger.UserConfig: account slot n is Telegram user + 1000 + n; slots 0-2 are signed in.""" + + MAX_ACCOUNT_COUNT = 5 def __init__(self, slot): self.slot = slot @@ -93,6 +96,9 @@ class _UserConfig: def getClientUserId(self): return 1000 + self.slot + def isClientActivated(self): + return self.slot < 3 + FILES_DIR = tempfile.mkdtemp(prefix="cc-verify-") @@ -125,11 +131,13 @@ def install_fakes() -> None: class FakeApi: - """The panel, as api.Api's methods answer.""" + """The panel, as api.Api's methods answer: token -> {tg id: [command + json]} (the owner's accounts), or an ApiError.""" def __init__(self, tokens): - self._tokens = tokens # token -> (tg id, [command json]) or an ApiError + self._tokens = tokens self.configured = True + self.listed = [] def tokens(self): return list(self._tokens) @@ -141,12 +149,12 @@ class FakeApi: return found def me(self, token): - tg_id, _ = self._of(token) - return {"tg_id": tg_id, "account_name": f"acc{tg_id}", "running": True} + accounts = [{"tg_id": tg, "name": f"acc{tg}", "running": True} for tg in self._of(token)] + return {"name": "Op", "accounts": accounts} - def commands(self, token): - _, commands = self._of(token) - return {"commands": commands, "refresh_seconds": 3600} + def commands(self, token, tg_id): + self.listed.append(tg_id) + return {"commands": self._of(token)[tg_id], "refresh_seconds": 3600} KEY_TO_DUNDER = { @@ -314,8 +322,8 @@ def check_catalog(load, library): roll_shadow = {"name": "roll", "summary": "Panel roll", "params": []} api = FakeApi( { - "tok_a": (1000, [getid, roll_shadow]), - "tok_b": (1001, [{"name": "ping"}]), + # One token for all of a panel user's accounts; 1009 isn't signed in here. + "tok_a": {1000: [getid, roll_shadow], 1001: [{"name": "ping"}], 1009: []}, "tok_bad": from_api.ApiError("403: Not a commands token", 403), } ) @@ -327,7 +335,8 @@ def check_catalog(load, library): 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("one token covers every account", catalog.token_for(0) == "tok_a" and catalog.token_for(1) == "tok_a") + check("only accounts signed in here are asked for", sorted(api.listed) == [1000, 1001], repr(api.listed)) 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) @@ -335,7 +344,7 @@ def check_catalog(load, library): 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_a") 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"])