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
+112
-67
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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`:
|
||||||
|
|||||||
+49
-30
@@ -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:
|
if tg_id in self._learn(candidate):
|
||||||
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:
|
||||||
self._token_of[tg_id] = token
|
for tg_id in mine:
|
||||||
return tg_id, me
|
self._token_of[tg_id] = token
|
||||||
|
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)
|
||||||
|
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._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._save()
|
||||||
self._changed()
|
self._changed()
|
||||||
|
|
||||||
|
|||||||
+15
-5
@@ -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
@@ -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"])
|
||||||
|
|||||||
Reference in new issue
Block a user