1 Commits
Author SHA1 Message Date
lyuksovannyyandClaude Opus 5.5 0b174fea7e 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>
2026-10-09 16:59:32 +02:00
7 changed files with 112 additions and 67 deletions

No files matched your search

+7 -5
View File
@@ -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.
+10 -6
View File
@@ -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.
+1 -1
View File
@@ -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",
+9 -8
View File
@@ -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`:
+49 -30
View File
@@ -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()
+15 -5
View File
@@ -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,
),
+21 -12
View File
@@ -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"])