Client commands tokens cover all your panel accounts
Publish plugin / publish (push) Successful in 5s

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 <noreply@anthropic.com>
This commit is contained in:
lyuksovannyyandClaude Opus 5.5 committed 2026-10-09 16:59:32 +02:00
1 parent ecb4c0d97f
commit 0b174fea7e
7 files changed
+104 -59

No files matched your search

+7 -5
View File
@@ -50,11 +50,13 @@ you choose so.
## Setup ## Setup
1. In the panel, open Settings → API Tokens and create a **Client commands** token for each 1. In the panel, open Settings → API Tokens and create a **Client commands** token. One token
of your accounts that uses exteraGram. Message logger tokens don't work here, and these covers all your accounts on the panel: each request names the account it comes from.
tokens don't work for the message logger. Message logger tokens don't work here, and these tokens don't work for the message
2. In the plugin settings, enter the panel URL and the tokens, separated by spaces. logger.
3. Tap *Test connection*. *Available commands* lists what the current account has. 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. Your panel user needs the `client:commands:use` permission.
+10 -6
View File
@@ -30,17 +30,20 @@ A command reaches the handler in one of two ways:
## The client API ## The client API
`/api/v1/client/commands` is authenticated by a **commands** API token. Each token is bound to `/api/v1/client/commands` is authenticated by a **commands** API token. A token isn't bound
one user account of yours. A token is refused by the routes of the other kind of token. 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 | | Route | Answer |
|---|---| |---|---|
| `GET /me` | the bound account: `account_id`, `tg_id` (the Telegram user id the plugin matches), `account_name`, `running` | | `GET /me` | `{name, accounts: [{account_id, tg_id, name, running}]}`: the accounts the token covers |
| `GET /` | `{commands: [{name, aliases, summary, description, reply, params: [{name, type, required, description, default, choices}]}], refresh_seconds}` | | `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}` | | `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 `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 `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 offsets. Command errors come back as `ok: false` with a reason. HTTP errors mean the token
was refused or the panel failed. 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 ## 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. 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 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. for every account, unless a panel command has the same name.
+1 -1
View File
@@ -3,7 +3,7 @@
"name": "Client Commands", "name": "Client Commands",
"description": "Your telegram-panel account's commands as slash commands, with Discord-style parameters; other plugins can add their own.", "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": "1.0.1",
"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",
+9 -8
View File
@@ -1,9 +1,10 @@
"""telegram-panel client API (`/api/v1/client/commands`). """telegram-panel client API (`/api/v1/client/commands`).
Each token runs the client commands of one panel account, the Telegram A token (panel: Settings -> API Tokens -> Client commands) runs the client
account it is bound to (panel: Settings -> API Tokens -> Client commands); commands of any of its owner's accounts on the panel; requests name the
the plugin holds one per account and picks it by the Telegram user id account by its Telegram user id (`/me` lists them). One token covers every
`/me` reports. Message logger tokens are refused here, and these there. 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`). Calls are blocking: run them off the UI thread (`jutil.in_background`).
""" """
@@ -70,12 +71,12 @@ class Api:
return response.json() return response.json()
def me(self, token): 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") return self._call(token, "GET", "/me")
def commands(self, token): def commands(self, token, tg_id):
"""{commands: [...], refresh_seconds}.""" """{commands: [...], refresh_seconds} of the account `tg_id`."""
return self._call(token, "GET", "/") 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): 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`: """{ok, error, text, entities, html, sent_message_id}. `sender_id`:
+41 -22
View File
@@ -1,8 +1,9 @@
"""Which commands each signed-in account has. """Which commands each signed-in account has.
- The panel's: every token is asked for its account (`/me`) and that - The panel's: a token runs any of its owner's accounts on the panel. It is
account's commands, again once an hour (or when the panel says so), and asked which (`/me`), then for the commands of each one signed in here,
cached on disk, so a restart shows them at once, even offline. 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 - The ones plugins registered through the library (`library.py`): for every
account. account.
@@ -26,6 +27,18 @@ def own_user_id(slot):
return int(UserConfig.getInstance(int(slot)).getClientUserId()) 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: class Catalog:
def __init__(self, plugin, api, library): def __init__(self, plugin, api, library):
self.plugin = plugin self.plugin = plugin
@@ -73,13 +86,11 @@ class Catalog:
token = self._token_of.get(tg_id) token = self._token_of.get(tg_id)
if token is None: if token is None:
for candidate in self.api.tokens(): for candidate in self.api.tokens():
if candidate not in self._token_of.values():
try: try:
self._learn(candidate) if tg_id in self._learn(candidate):
return candidate
except ApiError: except ApiError:
continue continue
with self._lock:
token = self._token_of.get(tg_id)
return token return token
def account_name(self, slot): def account_name(self, slot):
@@ -151,40 +162,48 @@ class Catalog:
return (min(upcoming) - time.time()) if upcoming else REFRESH_SECONDS return (min(upcoming) - time.time()) if upcoming else REFRESH_SECONDS
def _learn(self, token): 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) me = self.api.me(token)
tg_id = int(me.get("tg_id") or 0) here = signed_in()
if not tg_id: mine = {
raise ApiError("the panel doesn't know this account's Telegram id yet") 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: with self._lock:
for tg_id in mine:
self._token_of[tg_id] = token self._token_of[tg_id] = token
return tg_id, me return mine
def _refresh(self, token): def _refresh(self, token):
try: try:
tg_id, me = self._learn(token) mine = self._learn(token)
listing = self.api.commands(token) listings = {tg_id: self.api.commands(token, tg_id) for tg_id in mine}
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: except ApiError as exc:
diag.log(f"refresh {token[:10]}…: {exc}") diag.log(f"refresh {token[:10]}…: {exc}")
with self._lock: with self._lock:
self._errors[token] = str(exc) self._errors[token] = str(exc)
self._due[token] = time.time() + (REFRESH_SECONDS if exc.refused else RETRY_SECONDS) self._due[token] = time.time() + (REFRESH_SECONDS if exc.refused else RETRY_SECONDS)
return return
refresh = int(listing.get("refresh_seconds") or REFRESH_SECONDS) refresh = REFRESH_SECONDS
with self._lock: with self._lock:
self._errors.pop(token, None) self._errors.pop(token, None)
self._due[token] = time.time() + max(60, refresh) 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] = { self._server[tg_id] = {
"commands": commands, "commands": commands,
"account": me.get("account_name"), "account": mine[tg_id],
"fetched": time.time(), "fetched": time.time(),
} }
diag.log(f"{len(commands)} commands for {tg_id}") diag.log(f"{len(commands)} commands for {tg_id}")
self._due[token] = time.time() + max(60, refresh)
self._save() self._save()
self._changed() self._changed()
+15 -5
View File
@@ -112,15 +112,25 @@ class ClientCommandsPlugin(BasePlugin):
if not self.api.configured: if not self.api.configured:
bulletin("Client Commands: set the panel URL and API tokens", error=True) bulletin("Client Commands: set the panel URL and API tokens", error=True)
return return
from catalog import signed_in
here = signed_in()
lines, failed = [], False lines, failed = [], False
for token in self.api.tokens(): for token in self.api.tokens():
try: try:
me = self.api.me(token) 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: except ApiError as exc:
failed = True failed = True
lines.append(f"{token[:10]}…: {exc}") 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) bulletin("Accounts: " + "; ".join(lines), error=failed)
self.catalog.refresh() self.catalog.refresh()
@@ -163,10 +173,10 @@ class ClientCommandsPlugin(BasePlugin):
), ),
Input( Input(
key=API_TOKENS, key=API_TOKENS,
text="API tokens", text="API token",
default=DEFAULTS[API_TOKENS], default=DEFAULTS[API_TOKENS],
subtext="Client commands tokens, one per account, separated by spaces " subtext="A Client commands token (panel: Settings → API Tokens); it covers all "
"(panel: Settings → API Tokens). Not the message logger ones.", "your accounts. Several, separated by spaces, for several panel users.",
icon="msg_permissions", icon="msg_permissions",
on_change=self._tokens_changed, on_change=self._tokens_changed,
), ),
+21 -12
View File
@@ -81,7 +81,10 @@ class _SettingsRow:
class _UserConfig: 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): def __init__(self, slot):
self.slot = slot self.slot = slot
@@ -93,6 +96,9 @@ class _UserConfig:
def getClientUserId(self): def getClientUserId(self):
return 1000 + self.slot return 1000 + self.slot
def isClientActivated(self):
return self.slot < 3
FILES_DIR = tempfile.mkdtemp(prefix="cc-verify-") FILES_DIR = tempfile.mkdtemp(prefix="cc-verify-")
@@ -125,11 +131,13 @@ def install_fakes() -> None:
class FakeApi: 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): def __init__(self, tokens):
self._tokens = tokens # token -> (tg id, [command json]) or an ApiError self._tokens = tokens
self.configured = True self.configured = True
self.listed = []
def tokens(self): def tokens(self):
return list(self._tokens) return list(self._tokens)
@@ -141,12 +149,12 @@ class FakeApi:
return found return found
def me(self, token): def me(self, token):
tg_id, _ = self._of(token) accounts = [{"tg_id": tg, "name": f"acc{tg}", "running": True} for tg in self._of(token)]
return {"tg_id": tg_id, "account_name": f"acc{tg_id}", "running": True} return {"name": "Op", "accounts": accounts}
def commands(self, token): def commands(self, token, tg_id):
_, commands = self._of(token) self.listed.append(tg_id)
return {"commands": commands, "refresh_seconds": 3600} return {"commands": self._of(token)[tg_id], "refresh_seconds": 3600}
KEY_TO_DUNDER = { KEY_TO_DUNDER = {
@@ -314,8 +322,8 @@ def check_catalog(load, library):
roll_shadow = {"name": "roll", "summary": "Panel roll", "params": []} roll_shadow = {"name": "roll", "summary": "Panel roll", "params": []}
api = FakeApi( api = FakeApi(
{ {
"tok_a": (1000, [getid, roll_shadow]), # One token for all of a panel user's accounts; 1009 isn't signed in here.
"tok_b": (1001, [{"name": "ping"}]), "tok_a": {1000: [getid, roll_shadow], 1001: [{"name": "ping"}], 1009: []},
"tok_bad": from_api.ApiError("403: Not a commands token", 403), "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") 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)] names1 = [c.name for c in catalog.commands_for(1)]
check("account 1 has its own panel commands", names1 == ["ping", "roll"], repr(names1)) 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("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"]) 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) cached = catalog_mod.Catalog(plugin, FakeApi({}), library)
@@ -335,7 +344,7 @@ def check_catalog(load, library):
plugin.set_setting("server_commands", False) plugin.set_setting("server_commands", False)
check("panel commands can be turned off", [c.name for c in catalog.commands_for(0)] == ["roll"]) check("panel commands can be turned off", [c.name for c in catalog.commands_for(0)] == ["roll"])
plugin.set_setting("server_commands", True) plugin.set_setting("server_commands", True)
api._tokens.pop("tok_b") api._tokens.pop("tok_a")
api._tokens.pop("tok_bad") api._tokens.pop("tok_bad")
catalog._refresh_due() catalog._refresh_due()
check("a removed token's commands go", [c.name for c in catalog.commands_for(1)] == ["roll"]) check("a removed token's commands go", [c.name for c in catalog.commands_for(1)] == ["roll"])