generated from exteraGramPlugins/pluginBase
Client commands tokens cover all your panel accounts
Publish plugin / publish (push) Successful in 5s
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:
1 parent
ecb4c0d97f
commit
0b174fea7e
7 files changed
+104
-59
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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`:
|
||||
|
||||
+41
-22
@@ -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)
|
||||
if tg_id in self._learn(candidate):
|
||||
return candidate
|
||||
except ApiError:
|
||||
continue
|
||||
with self._lock:
|
||||
token = self._token_of.get(tg_id)
|
||||
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:
|
||||
for tg_id in mine:
|
||||
self._token_of[tg_id] = token
|
||||
return tg_id, me
|
||||
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)
|
||||
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] = {
|
||||
"commands": commands,
|
||||
"account": me.get("account_name"),
|
||||
"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._save()
|
||||
self._changed()
|
||||
|
||||
|
||||
+15
-5
@@ -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
@@ -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"])
|
||||
|
||||
Reference in new issue
Block a user