Client Commands: telegram-panel slash commands with Discord-style parameters

Lists the panel account's client commands as Telegram slash commands (hourly
refresh, cached on the device), shows their parameters while typing, runs
complete commands on the panel instead of sending them and shows the answer
as an "Only visible to you" message (latest 20 kept in memory). Holding send
offers Run / Send as message / Send response in chat (red ! badge); an
answer's menu starts with Send in chat. Other plugins register their own
commands through the client_commands library module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lyuksovannyyandClaude Opus 5.5 committed 2026-10-09 16:19:20 +02:00
1 parent 84bdc44a76
commit 46a1f6ee1f
29 files changed
+3293 -220

No files matched your search

+91
View File
@@ -0,0 +1,91 @@
"""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.
Calls are blocking: run them off the UI thread (`jutil.in_background`).
"""
import re
from config import API_TOKENS, EXECUTE_TIMEOUT_SECONDS, HTTP_TIMEOUT_SECONDS, SERVER_URL
class ApiError(Exception):
def __init__(self, message, status=None):
super().__init__(message)
self.status = status
@property
def refused(self):
"""The token itself is no good (unknown, expired, wrong kind...)."""
return self.status in (401, 403)
class Api:
def __init__(self, plugin):
self.plugin = plugin
@property
def configured(self):
return bool(self._base() and self.tokens())
def _base(self):
url = str(self.plugin.get_setting(SERVER_URL, "") or "").strip().rstrip("/")
return url + "/api/v1/client/commands" if url else ""
def tokens(self):
raw = str(self.plugin.get_setting(API_TOKENS, "") or "")
seen = []
for token in re.split(r"[\s,;]+", raw):
if token and token not in seen:
seen.append(token)
return seen
def _call(self, token, method, path, timeout=HTTP_TIMEOUT_SECONDS, **kwargs):
import requests
if not self._base():
raise ApiError("The panel URL is not set")
try:
response = requests.request(
method,
self._base() + path,
headers={"Authorization": "Bearer " + token},
timeout=timeout,
**kwargs,
)
except requests.RequestException as exc:
raise ApiError(f"Panel unreachable: {exc}") from exc
if response.status_code >= 400:
try:
detail = response.json().get("detail")
except Exception:
detail = response.text[:200]
if response.status_code == 404 and detail == "Not Found":
detail = "the panel doesn't support client commands yet (update telegram-panel)"
raise ApiError(f"{response.status_code}: {detail}", response.status_code)
return response.json()
def me(self, token):
"""{account_id, tg_id, account_name, running, ...} of the token."""
return self._call(token, "GET", "/me")
def commands(self, token):
"""{commands: [...], refresh_seconds}."""
return self._call(token, "GET", "/")
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`:
the Telegram user id of the account that typed the command."""
body = {
"text": text,
"sender_id": sender_id,
"chat_id": chat_id,
"reply_to_message_id": reply_to_id,
"topic_id": topic_id,
"send": bool(send),
}
return self._call(token, "POST", "/execute", timeout=EXECUTE_TIMEOUT_SECONDS, json=body)
+244
View File
@@ -0,0 +1,244 @@
"""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 ones plugins registered through the library (`library.py`): for every
account.
A panel command shadows a plugin's command of the same name.
"""
import json
import os
import threading
import time
import diag
from api import ApiError
from commands.model import Command
from config import REFRESH_SECONDS, RETRY_SECONDS, SERVER_COMMANDS
def own_user_id(slot):
from org.telegram.messenger import UserConfig
return int(UserConfig.getInstance(int(slot)).getClientUserId())
class Catalog:
def __init__(self, plugin, api, library):
self.plugin = plugin
self.api = api
self.library = library
self._lock = threading.Lock()
self._server = {} # tg id -> {"commands": [Command], "account": name, "fetched": epoch}
self._token_of = {} # tg id -> token
self._due = {} # token -> epoch of the next refresh
self._errors = {} # token -> last error text
self._wake = threading.Event()
self._running = False
self._listeners = []
self._load()
# commands
def commands_for(self, slot):
"""[Command] of the account in `slot`: the panel's, then plugins'."""
out, names = [], set()
if self.plugin.get_setting(SERVER_COMMANDS, True):
with self._lock:
entry = self._server.get(own_user_id(slot))
for command in entry["commands"] if entry else ():
out.append(command)
names.update(command.names)
for command in self.library.local_commands():
if not names.intersection(command.names):
out.append(command)
names.update(command.names)
return out
def find(self, slot, name):
name = (name or "").lower()
for command in self.commands_for(slot):
if name in command.names:
return command
return None
def token_for(self, slot):
"""The token of the account in `slot`, learning it if needed
(blocking: not on the UI thread)."""
tg_id = own_user_id(slot)
with self._lock:
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)
return token
def account_name(self, slot):
with self._lock:
entry = self._server.get(own_user_id(slot))
return entry.get("account") if entry else None
def on_change(self, listener):
self._listeners.append(listener)
# refreshing
def start(self):
if self._running:
return
self._running = True
thread = threading.Thread(target=self._loop, name="client-commands-catalog", daemon=True)
thread.start()
def stop(self):
self._running = False
self._wake.set()
def refresh(self, reset=False):
"""Asks the panel again now; `reset` forgets which token is whose
(the tokens changed)."""
with self._lock:
self._due.clear()
if reset:
self._token_of.clear()
self._errors.clear()
self._wake.set()
def errors(self):
with self._lock:
return dict(self._errors)
def _loop(self):
while self._running:
try:
delay = self._refresh_due()
except Exception as exc:
diag.error("catalog refresh", exc)
delay = RETRY_SECONDS
self._wake.wait(max(5, delay))
self._wake.clear()
def _refresh_due(self):
"""Refreshes the tokens due; seconds until the next one is."""
tokens = self.api.tokens() if self.api.configured else []
now = time.time()
for token in tokens:
if self._due.get(token, 0) <= now:
self._refresh(token)
with self._lock:
for token in list(self._due):
if token not in tokens:
self._due.pop(token)
self._errors.pop(token, None)
live = {tg for tg, token in self._token_of.items() if token in tokens}
dropped = [tg for tg in self._server if tg not in live and not self._errors]
for tg in dropped:
self._server.pop(tg)
self._token_of = {tg: t for tg, t in self._token_of.items() if t in tokens}
if dropped:
self._save()
self._changed()
upcoming = [self._due[t] for t in tokens if t in self._due]
return (min(upcoming) - time.time()) if upcoming else REFRESH_SECONDS
def _learn(self, token):
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")
with self._lock:
self._token_of[tg_id] = token
return tg_id, me
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}")
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)
with self._lock:
self._errors.pop(token, None)
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()
def _changed(self):
for listener in list(self._listeners):
try:
listener()
except Exception as exc:
diag.error("catalog listener", exc)
# disk cache
def _path(self):
try:
from file_utils import get_files_dir
directory = os.path.join(get_files_dir(), "client_commands")
os.makedirs(directory, exist_ok=True)
return os.path.join(directory, "catalog.json")
except Exception:
return None
def _load(self):
path = self._path()
if not path or not os.path.exists(path):
return
try:
with open(path, encoding="utf-8") as handle:
data = json.load(handle)
for tg_id, entry in data.items():
self._server[int(tg_id)] = {
"commands": [Command.from_json(c) for c in entry.get("commands") or ()],
"account": entry.get("account"),
"fetched": float(entry.get("fetched") or 0),
}
except Exception as exc:
diag.error("catalog load", exc)
def _save(self):
path = self._path()
if not path:
return
with self._lock:
data = {
str(tg_id): {
"commands": [c.to_json() for c in entry["commands"]],
"account": entry.get("account"),
"fetched": entry.get("fetched"),
}
for tg_id, entry in self._server.items()
}
try:
with open(path + ".tmp", "w", encoding="utf-8") as handle:
json.dump(data, handle)
os.replace(path + ".tmp", path)
except Exception as exc:
diag.error("catalog save", exc)
View File
Whitespace-only changes.
+106
View File
@@ -0,0 +1,106 @@
"""Telegram-style HTML -> (text, entities), for responses of commands that
run on the device (server responses arrive parsed). Offsets are UTF-16 code
units, as Telegram counts them. Pure Python: tested offline.
Tags: b strong i em u ins s strike del code pre (`<code class="language-x">`
inside), a href (tg://user?id=N mentions), blockquote [expandable],
tg-spoiler / span class="tg-spoiler", tg-emoji emoji-id, br.
"""
from html.parser import HTMLParser
_SIMPLE = {
"b": "bold",
"strong": "bold",
"i": "italic",
"em": "italic",
"u": "underline",
"ins": "underline",
"s": "strike",
"strike": "strike",
"del": "strike",
"code": "code",
"pre": "pre",
"blockquote": "blockquote",
"tg-spoiler": "spoiler",
}
def utf16_len(text):
return len(text.encode("utf-16-le")) // 2
class _Parser(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.parts = []
self.length = 0 # UTF-16 units so far
self.open = [] # [(tag, entity or None)]
self.entities = []
def _entity(self, tag, attrs):
attrs = dict(attrs)
if tag == "code" and any(t == "pre" for t, _ in self.open):
pre = next(e for t, e in reversed(self.open) if t == "pre")
language = (attrs.get("class") or "").removeprefix("language-")
if pre is not None and language:
pre["language"] = language
return None
if tag == "span" and attrs.get("class") == "tg-spoiler":
return {"type": "spoiler"}
if tag in _SIMPLE:
entity = {"type": _SIMPLE[tag]}
if tag == "blockquote" and "expandable" in attrs:
entity["collapsed"] = True
return entity
if tag == "a" and attrs.get("href"):
href = attrs["href"]
if href.startswith("tg://user?id="):
try:
return {"type": "mention_name", "user_id": int(href.split("=", 1)[1])}
except ValueError:
pass
return {"type": "text_link", "url": href}
if tag == "tg-emoji":
try:
return {"type": "custom_emoji", "document_id": int(attrs.get("emoji-id"))}
except (TypeError, ValueError):
return None
return None
def handle_starttag(self, tag, attrs):
if tag == "br":
self.handle_data("\n")
return
entity = self._entity(tag, attrs)
if entity is not None:
entity["offset"] = self.length
self.open.append((tag, entity))
def handle_endtag(self, tag):
for index in range(len(self.open) - 1, -1, -1):
if self.open[index][0] == tag:
_, entity = self.open.pop(index)
if entity is not None:
entity["length"] = self.length - entity["offset"]
if entity["length"] > 0:
self.entities.append(entity)
return
def handle_data(self, data):
self.parts.append(data)
self.length += utf16_len(data)
def parse(html):
"""(plain text, entities) of `html`; entities ordered by offset."""
parser = _Parser()
parser.feed(html or "")
parser.close()
entities = sorted(parser.entities, key=lambda e: (e["offset"], -e["length"]))
return "".join(parser.parts), entities
def escape(text):
"""`text` made safe to put into a response."""
return str(text).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
+156
View File
@@ -0,0 +1,156 @@
"""Commands and their parameters, as the panel lists them (and as plugins
register them through the library). Pure Python: tested offline."""
TYPES = ("string", "integer", "number", "boolean", "choice")
REPLY_MODES = ("none", "optional", "required")
# What the composer shows for a parameter's type.
TYPE_LABELS = {
"string": "text",
"integer": "whole number",
"number": "number",
"boolean": "yes / no",
"choice": "choice",
}
class Param:
__slots__ = ("name", "type", "required", "description", "default", "choices")
def __init__(
self,
name,
type="string",
required=True,
description="",
default=None,
choices=(),
):
if type not in TYPES:
raise ValueError(f"unknown parameter type {type!r}")
self.name = str(name)
self.type = type
self.required = bool(required)
self.description = str(description or "")
self.default = default
self.choices = tuple(str(c) for c in choices or ())
if self.type == "choice" and not self.choices:
raise ValueError(f"choice parameter {name!r} needs choices")
@classmethod
def from_json(cls, data):
kind = data.get("type") or "string"
return cls(
data["name"],
kind if kind in TYPES else "string",
data.get("required", True),
data.get("description") or "",
data.get("default"),
data.get("choices") or (),
)
def to_json(self):
return {
"name": self.name,
"type": self.type,
"required": self.required,
"description": self.description,
"default": self.default,
"choices": list(self.choices),
}
def placeholder(self):
"""`<name>` when required, `[name]` when optional."""
return f"<{self.name}>" if self.required else f"[{self.name}]"
def type_label(self):
if self.type == "choice":
return " | ".join(self.choices)
return TYPE_LABELS.get(self.type, self.type)
class Command:
"""A command: `source` is "server" (the panel runs it) or the id of the
plugin that registered it (then `handler` runs it on the device)."""
__slots__ = (
"name",
"aliases",
"summary",
"description",
"params",
"reply",
"source",
"handler",
)
def __init__(
self,
name,
params=(),
summary="",
description="",
aliases=(),
reply="none",
source="server",
handler=None,
):
name = str(name).lstrip("/").lower()
if not name or not all(c.isalnum() and c.isascii() or c == "_" for c in name):
raise ValueError(f"bad command name {name!r}: use a-z, 0-9 and _")
if reply not in REPLY_MODES:
raise ValueError(f"reply must be one of {REPLY_MODES}")
self.name = name
self.aliases = tuple(str(a).lstrip("/").lower() for a in aliases or ())
self.summary = str(summary or "")
self.description = str(description or "")
self.params = tuple(params or ())
self.reply = reply
self.source = source
self.handler = handler
names = [p.name.lower() for p in self.params]
if len(set(names)) != len(names):
raise ValueError(f"/{name}: parameter names must be unique")
@property
def names(self):
return (self.name,) + self.aliases
@property
def local(self):
return self.source != "server"
@classmethod
def from_json(cls, data, source="server"):
return cls(
data["name"],
[Param.from_json(p) for p in data.get("params") or ()],
data.get("summary") or "",
data.get("description") or "",
data.get("aliases") or (),
data.get("reply") if data.get("reply") in REPLY_MODES else "none",
source,
)
def to_json(self):
return {
"name": self.name,
"aliases": list(self.aliases),
"summary": self.summary,
"description": self.description,
"params": [p.to_json() for p in self.params],
"reply": self.reply,
}
def usage(self):
return " ".join(["/" + self.name] + [p.placeholder() for p in self.params])
def hint(self):
"""The suggestion row's second line: parameters, then the summary."""
parts = [p.placeholder() for p in self.params]
if self.reply == "required":
parts.append("↩ reply")
head = " ".join(parts)
if head and self.summary:
return f"{head} · {self.summary}"
return head or self.summary
+199
View File
@@ -0,0 +1,199 @@
"""Discord-style arguments of a `/command`. Pure Python: tested offline.
Mirrors telegram-panel's `app/telegram/client_commands/parser.py`, which
parses the same text again on the server: keep the two in step.
- `/name args` (`/name@anything` too); the name is case-insensitive.
- Arguments split like a shell (quotes group words).
- `param:value` sets a parameter by name; other words fill the remaining
parameters in order, and the last remaining one, when a string, takes all
the words left over.
- An empty `param:` counts as not given.
"""
import re
import shlex
_COMMAND = re.compile(r"^/([A-Za-z0-9_]{1,64})(?:@\S*)?(?:\s+|$)")
_TRUE = {"true", "yes", "y", "on", "1", "+"}
_FALSE = {"false", "no", "n", "off", "0", "-"}
class CommandError(Exception):
"""A user-facing command error (bad arguments, nothing to act on, ...)."""
def split_command(text):
"""(lowercased name, argument text) of a `/command`, else None."""
text = (text or "").strip()
match = _COMMAND.match(text)
if match is None:
return None
return match.group(1).lower(), text[match.end():]
def command_name_at(text):
"""The name being typed while the text is a bare `/prefix` (no space
yet), else None: suggestions are shown for it."""
match = re.match(r"^/([A-Za-z0-9_]{0,64})$", text or "")
return match.group(1).lower() if match else None
def _convert(param, raw):
if param.type == "integer":
try:
return int(raw)
except ValueError:
raise CommandError(f"{param.name} must be a whole number") from None
if param.type == "number":
try:
return float(raw.replace(",", "."))
except ValueError:
raise CommandError(f"{param.name} must be a number") from None
if param.type == "boolean":
if raw.lower() in _TRUE:
return True
if raw.lower() in _FALSE:
return False
raise CommandError(f"{param.name} must be yes or no")
if param.type == "choice":
for choice in param.choices:
if choice.lower() == raw.lower():
return choice
raise CommandError(f"{param.name} must be one of: {', '.join(param.choices)}")
return raw
def _assign(params, tokens):
"""Raw values by parameter name, and the parameter each token went to
([(token index, param name or None)])."""
by_name = {p.name.lower(): p for p in params}
raw, owners, positional = {}, [], []
for index, token in enumerate(tokens):
key, sep, value = token.partition(":")
param = by_name.get(key.lower()) if sep else None
if param is None:
positional.append(index)
continue
if param.name in raw:
raise CommandError(f"{param.name} is given twice")
owners.append((index, param.name))
if value:
raw[param.name] = value
free = [p for p in params if p.name not in raw]
greedy = bool(free) and free[-1].type == "string"
if len(positional) > len(free) and not greedy:
extra = " ".join(tokens[i] for i in positional[len(free):])
raise CommandError(f"Unexpected arguments: {extra}")
for slot, param in enumerate(free[: len(positional)]):
if slot == len(free) - 1 and greedy:
rest = positional[slot:]
raw[param.name] = " ".join(tokens[i] for i in rest)
owners += [(i, param.name) for i in rest]
else:
raw[param.name] = tokens[positional[slot]]
owners.append((positional[slot], param.name))
return raw, owners
def assign(params, args):
"""Raw string values by parameter name."""
try:
tokens = shlex.split(args)
except ValueError:
raise CommandError("Unclosed quote") from None
return _assign(params, tokens)[0]
def parse(command, text):
"""Handler keyword arguments for `text` (`/name ...`)."""
split = split_command(text)
if split is None or split[0] not in command.names:
raise CommandError(f"Not a /{command.name} command")
raw = assign(command.params, split[1])
values = {}
for param in command.params:
if param.name in raw:
values[param.name] = _convert(param, raw[param.name])
elif param.required:
raise CommandError(f"Missing required parameter: {param.name}")
else:
values[param.name] = param.default
return values
def _tokens(args):
"""Shell-like words of `args` with their spans; tolerant of an unclosed
quote (the word runs to the end), for analysing text being typed."""
out, start, word, quote = [], None, [], None
for index, char in enumerate(args):
if quote:
if char == quote:
quote = None
else:
word.append(char)
elif char in "\"'":
quote = char
start = index if start is None else start
elif char.isspace():
if start is not None:
out.append((start, index, "".join(word)))
start, word = None, []
else:
start = index if start is None else start
word.append(char)
if start is not None:
out.append((start, len(args), "".join(word)))
return out
class Analysis:
"""What the composer shows for a command being typed."""
__slots__ = ("values", "current", "missing", "error", "complete")
def __init__(self, values, current, missing, error):
self.values = values # raw values by parameter name
self.current = current # the parameter at the cursor, or None
self.missing = missing # required parameters not given yet
self.error = error # why it can't run (None when it can)
self.complete = error is None
def analyze(command, text, cursor=None, has_reply=False):
"""Values, the parameter at `cursor`, and whether `text` can run."""
split = split_command(text)
if split is None or split[0] not in command.names:
return Analysis({}, None, [p.name for p in command.params if p.required], "Not a command")
stripped = (text or "").strip()
args = split[1]
offset = len(text) - len(text.lstrip()) + len(stripped) - len(args)
cursor = len(text) if cursor is None else cursor
at = cursor - offset
tokens = _tokens(args)
error = None
try:
raw, owners = _assign(command.params, [t[2] for t in tokens])
except CommandError as exc:
raw, owners, error = {}, [], str(exc)
missing = [p.name for p in command.params if p.required and p.name not in raw]
current = None
if at >= 0:
inside = [i for i, (start, end, _) in enumerate(tokens) if start <= at <= end]
if inside:
current = dict(owners).get(inside[-1])
key, sep, _ = tokens[inside[-1]][2].partition(":")
if current is None and sep:
current = next((p.name for p in command.params if p.name.lower() == key.lower()), None)
else:
current = next((p.name for p in command.params if p.name not in raw), None)
if current is None and command.params and command.params[-1].type == "string":
current = command.params[-1].name # the greedy last one goes on
if error is None:
try:
parse(command, text)
except CommandError as exc:
error = str(exc)
if error is None and command.reply == "required" and not has_reply:
error = f"Reply to a message to use /{command.name}"
return Analysis(raw, current, missing, error)
+24
View File
@@ -0,0 +1,24 @@
"""Setting keys, defaults and constants."""
SERVER_URL = "server_url"
API_TOKENS = "api_tokens"
SEND_TO_CHAT = "send_to_chat"
SERVER_COMMANDS = "server_commands"
SHOW_PARAMS = "show_params"
DEFAULTS = {
SERVER_URL: "",
API_TOKENS: "",
SEND_TO_CHAT: False,
SERVER_COMMANDS: True,
SHOW_PARAMS: True,
}
# The panel's list of an account's commands is asked for again after this
# long (the server may say otherwise in `refresh_seconds`).
REFRESH_SECONDS = 3600
# A failed refresh is retried sooner.
RETRY_SECONDS = 300
HTTP_TIMEOUT_SECONDS = 15
# Running a command may take a while on the server (it times out at 60 s).
EXECUTE_TIMEOUT_SECONDS = 75
+54
View File
@@ -0,0 +1,54 @@
"""Diagnostics mirrored to a file, since this device's logcat surfaces neither
Chaquopy stdout nor android_utils.log output.
The file lives under `get_files_dir()` (app external storage), so it is
readable over adb:
adb shell cat "/sdcard/Android/data/com.exteragram.messenger/files/exteraGram/exteraGram Files/client_commands/diag.log"
"""
import os
import time
import traceback
_MAX_BYTES = 256 * 1024
_path = None
def _file():
global _path
if _path is None:
try:
from file_utils import get_files_dir
directory = os.path.join(get_files_dir(), "client_commands")
os.makedirs(directory, exist_ok=True)
_path = os.path.join(directory, "diag.log")
except Exception:
_path = ""
return _path
def log(message):
line = time.strftime("%H:%M:%S ") + str(message)
try:
from android_utils import log as android_log
android_log("[client_commands] " + line)
except Exception:
pass
path = _file()
if not path:
return
try:
mode = "a"
if os.path.exists(path) and os.path.getsize(path) > _MAX_BYTES:
mode = "w"
with open(path, mode, encoding="utf-8") as handle:
handle.write(line + "\n")
except Exception:
pass
def error(where, exc=None):
detail = traceback.format_exc() if exc is not None else ""
log(f"ERROR {where}: {exc!r}\n{detail}")
-1
View File
@@ -1 +0,0 @@
from .template import render, TemplateError
-13
View File
@@ -1,13 +0,0 @@
from textutil import normalize
class TemplateError(ValueError):
pass
def render(template: str, name: str) -> str:
normalized = normalize(name)
try:
return template.format(name=normalized)
except (KeyError, IndexError, ValueError):
raise TemplateError(f"unsupported placeholder in {template!r}")
View File
Whitespace-only changes.
+266
View File
@@ -0,0 +1,266 @@
"""The message field: a command runs instead of going to the chat.
Hooks on ChatActivityEnterView (the chat's input):
- sendMessageInternal(notify, scheduleDate, ...), behind the send button, the
keyboard's send key and the send preview: a known command runs (on the
panel, or on the device for plugins' commands). One that can't run yet
(a required parameter missing, no reply) stays in the field and a bulletin
says why. Scheduled messages are sent as typed.
- onSendLongClick(View): holding send on a command opens a menu: run it,
send it to the chat as a plain message (the panel then handles it from the
update, as without this plugin), and the "Send response in chat" check.
- SendButton.onDraw(Canvas): while that check is on and the command can run,
a small "!" badge on the send button.
Not installed, the same text is an ordinary message, which the panel handles
from the account's outgoing update.
"""
from base_plugin import MethodHook
import diag
from commands.parse import analyze, split_command
from config import SEND_TO_CHAT
from jutil import field, hook_methods, java_class, runnable, unhook_all
from runner import Invocation
from hooks import responses
from hooks.notify import bulletin
ENTER = "org.telegram.ui.Components.ChatActivityEnterView"
SEND_BUTTON = ENTER + "$SendButton"
class Field:
"""The command typed into one input, ready to run or not."""
__slots__ = ("enter", "chat", "account", "text", "command", "reply", "analysis")
def __init__(self, enter, chat, account, text, command, reply, analysis):
self.enter = enter
self.chat = chat
self.account = account
self.text = text
self.command = command
self.reply = reply
self.analysis = analysis
def reply_of(chat):
"""The message the chat is replying to (not one of our responses)."""
reply = field(chat, "replyingMessageObject")
if reply is None or responses.is_local(reply) or int(reply.getId()) <= 0:
return None
return reply
def topic_of(chat):
try:
return int(chat.getTopicId()) if field(chat, "isTopic") else 0
except Exception:
return 0
def _icon(*names):
from org.telegram.messenger import R
for name in names:
value = getattr(R.drawable, name, None)
if value:
return value
return 0
class Composer:
def __init__(self, plugin, catalog, runner):
self.plugin = plugin
self.catalog = catalog
self.runner = runner
self.handles = []
self._badge = {} # (text, reply id) -> whether it can run
self._paints = []
def install(self):
composer = self
class Send(MethodHook):
def before_hooked_method(self, param):
try:
if int(param.args[1]) == 0 and composer.submit(param.thisObject):
param.setResult(False)
except Exception as exc:
diag.error("send", exc)
class LongSend(MethodHook):
def before_hooked_method(self, param):
try:
if composer.menu(param.thisObject, param.args[0]):
param.setResult(True)
except Exception as exc:
diag.error("send menu", exc)
class Badge(MethodHook):
def after_hooked_method(self, param):
try:
composer.draw_badge(param.thisObject, param.args[0])
except Exception as exc:
diag.error("send badge", exc)
self.handles += hook_methods(self.plugin, ENTER, "sendMessageInternal", Send(), arity=5)
self.handles += hook_methods(self.plugin, ENTER, "onSendLongClick", LongSend(), arity=1)
self.handles += hook_methods(self.plugin, SEND_BUTTON, "onDraw", Badge(), arity=1)
def uninstall(self):
unhook_all(self.plugin, self.handles)
# the field
def read(self, enter):
"""The Field of a known command typed into `enter`, else None."""
chat = field(enter, "parentFragment")
if chat is None or int(chat.getChatMode()) != 0 or field(enter, "editingMessageObject") is not None:
return None
text = enter.getFieldText()
text = str(text) if text is not None else ""
split = split_command(text)
if split is None:
return None
account = int(chat.getCurrentAccount())
command = self.catalog.find(account, split[0])
if command is None:
return None
reply = reply_of(chat)
edit = enter.getEditField()
cursor = int(edit.getSelectionEnd()) if edit is not None else len(text)
analysis = analyze(command, text, cursor, reply is not None)
return Field(enter, chat, account, text, command, reply, analysis)
def set_text(self, enter, text):
enter.setFieldText(text)
def submit(self, enter):
"""Runs the command typed into `enter`; False when it isn't one (or
should go to the chat as typed)."""
typed = self.read(enter)
if typed is None or self.runner.bypassed(typed.text):
return False
if not typed.analysis.complete:
bulletin(typed.analysis.error, error=True)
return True
chat = typed.chat
inv = Invocation(
typed.account,
chat.getDialogId(),
topic_of(chat),
typed.reply,
chat.getThreadMessage(),
typed.text,
typed.command,
self.plugin.get_setting(SEND_TO_CHAT, False),
)
enter.setFieldText("")
if typed.reply is not None:
chat.hideFieldPanel(False)
self.runner.run(inv)
return True
def send_as_text(self, enter):
typed = enter.getFieldText()
if typed is None:
return
self.runner.bypass(str(typed))
enter.sendMessage()
# holding send
def menu(self, enter, button):
typed = self.read(enter)
if typed is None:
return False
from org.telegram.ui.Components import ItemOptions
name = typed.command.name
checked = bool(self.plugin.get_setting(SEND_TO_CHAT, False))
def toggle():
self.plugin.set_setting(SEND_TO_CHAT, not checked)
self.refresh(enter)
options = ItemOptions.makeOptions(typed.chat, button)
options.add(_icon("msg_bot", "msg_bots", "msg_send"), f"Run /{name}", runnable(lambda: self.submit(enter)))
options.add(_icon("msg_send"), "Send as message", runnable(lambda: self.send_as_text(enter)))
options.addChecked(checked, _icon("msg_discussion", "msg_message", "msg_chats"), "Send response in chat", runnable(toggle))
options.show()
return True
# the badge
def refresh(self, enter):
"""Redraws the send button (the field or the setting changed)."""
button = field(enter, "sendButton")
if button is not None:
button.invalidate()
def _enter_of(self, button):
enter_class = java_class(ENTER)
view = button.getParent()
while view is not None:
if enter_class is not None and enter_class.isInstance(view):
return view
view = view.getParent() if hasattr(view, "getParent") else None
return None
def _can_run(self, enter):
text = enter.getFieldText()
if text is None or not str(text).startswith("/"):
return False
chat = field(enter, "parentFragment")
reply = reply_of(chat) if chat is not None else None
key = (str(text), int(reply.getId()) if reply is not None else 0)
if key not in self._badge:
typed = self.read(enter)
if len(self._badge) > 64:
self._badge.clear()
self._badge[key] = typed is not None and typed.analysis.complete
return self._badge[key]
def draw_badge(self, button, canvas):
if not self.plugin.get_setting(SEND_TO_CHAT, False):
return
enter = self._enter_of(button)
if enter is None or not self._can_run(enter):
return
from android.graphics import Paint
from java import jint
from org.telegram.messenger import AndroidUtilities
from org.telegram.ui.ActionBar import Theme
if not self._paints:
self._paints = [Paint(Paint.ANTI_ALIAS_FLAG) for _ in range(3)]
self._paints[1].setTextAlign(Paint.Align.CENTER)
self._paints[1].setFakeBoldText(True)
fill, mark, ring = self._paints
dp = AndroidUtilities.dp
height = button.getMeasuredHeight()
rect = field(button, "backgroundRect")
if rect is not None and field(button, "isNewDesignSendButton") and rect.width() > 0:
cx, cy, r = rect.centerX(), rect.centerY(), rect.height() / 2
else:
cx, cy, r = button.getMeasuredWidth() - height / 2, height / 2, height / 2 - dp(6)
x, y, radius = cx + r * 0.62, cy - r * 0.62, dp(7)
fill.setColor(jint(Theme.getColor(Theme.key_text_RedBold)))
mark.setColor(jint(-1))
mark.setTextSize(dp(11))
# A ring of the input's background separates it from the button.
ring.setColor(jint(Theme.getColor(Theme.key_chat_messagePanelBackground)))
canvas.drawCircle(x, y, radius + dp(1.5), ring)
canvas.drawCircle(x, y, radius, fill)
canvas.drawText("!", x, y + dp(4), mark)
def forget(self):
"""The commands changed: whether a text can run is asked again."""
self._badge.clear()
def text_changed(self, enter):
"""The field's text changed: the badge may change."""
if self.plugin.get_setting(SEND_TO_CHAT, False):
self.refresh(enter)
+87
View File
@@ -0,0 +1,87 @@
""""Send in chat" first in the menu of a response shown only to you.
Tapping a response (an ephemeral message) opens the app's message menu. The
row is added to the built popup after ChatActivity.createMenu, above its
first row (as messageLogger's "Edit history"); ChatActivity.selectedObject
is the message. Sending posts the answer (without the command above it) as
the account, replying where the command replied, and takes the local copy
away.
"""
from base_plugin import MethodHook
import diag
from hooks import responses
from jutil import field, hook_methods, method, unhook_all
CHAT = "org.telegram.ui.ChatActivity"
LABEL = "Send in chat"
def _items(view, found):
"""Every ActionBarMenuSubItem under `view`, in order."""
if view.getClass().getSimpleName() == "ActionBarMenuSubItem":
found.append(view)
elif hasattr(view, "getChildCount"):
for index in range(view.getChildCount()):
_items(view.getChildAt(index), found)
return found
class ResponseMenu:
def __init__(self, plugin, runner):
self.plugin = plugin
self.runner = runner
self.handles = []
def install(self):
menu = self
class AfterCreate(MethodHook):
def after_hooked_method(self, param):
try:
menu.add_item(param.thisObject)
except Exception as exc:
diag.error("response menu", exc)
self.handles += hook_methods(self.plugin, CHAT, "createMenu", AfterCreate())
def uninstall(self):
unhook_all(self.plugin, self.handles)
def add_item(self, chat):
message = field(chat, "selectedObject")
popup = field(chat, "scrimPopupWindow")
body = responses.sendable(message) if message is not None else None
if popup is None or body is None:
return
items = _items(popup.getContentView(), [])
# The createMenu overloads call one another: add the row once.
if not items or any(str(i.getTextView().getText()) == LABEL for i in items if i.getTextView()):
return
from android_utils import OnClickListener
from org.telegram.messenger import R
from org.telegram.ui.ActionBar import ActionBarMenuSubItem
anchor = items[0]
parent = anchor.getParent()
item = ActionBarMenuSubItem(anchor.getContext(), False, False)
item.setTextAndIcon(LABEL, R.drawable.msg_send)
color = anchor.getTextView().getCurrentTextColor()
item.setColors(color, color)
item.setMinimumWidth(anchor.getMinimumWidth())
def chosen(_view):
method(CHAT, "closeMenu").invoke(chat)
self.runner.send(
body["account"],
body["dialog_id"],
body["reply"],
body["thread"],
body["text"],
body["entities"],
)
responses.remove(body["account"], body["dialog_id"], message.getId())
item.setOnClickListener(OnClickListener(chosen))
parent.addView(item, parent.indexOfChild(anchor), anchor.getLayoutParams())
+17
View File
@@ -0,0 +1,17 @@
"""Bulletins (shown on the UI thread, from any thread)."""
from jutil import on_ui
def bulletin(text, error=False, success=False):
def show():
from ui.bulletin import BulletinHelper
if error:
BulletinHelper.show_error(text)
elif success:
BulletinHelper.show_success(text)
else:
BulletinHelper.show_info(text)
on_ui(show)
+358
View File
@@ -0,0 +1,358 @@
"""Responses shown only on this device.
They are Telegram's own ephemeral messages: a TL_message with an ephemeral
id, sent by the account itself (an outgoing bubble), handed to the open chat
the way new messages arrive (ChatActivity.processNewMessages) and never
stored. The latest 20 come back when their chat is opened again, until the
app restarts (memory only). The app labels an outgoing
ephemeral message "Only visible to @<receiver>"; the receiver is the account,
and the label is made "Only visible to you" (ChatMessageCell's
setMessageObjectInternal). Deleting one, or replying to one, stays on the
device. When the chat isn't open, a bulletin shows the text instead.
"""
import random
from base_plugin import MethodHook
import diag
from commands.markup import utf16_len
from jutil import array_list, field, hook_methods, java_class, method, set_field, unhook_all
CHAT = "org.telegram.ui.ChatActivity"
_ENTITY_CLASSES = {
"bold": "TL_messageEntityBold",
"italic": "TL_messageEntityItalic",
"underline": "TL_messageEntityUnderline",
"strike": "TL_messageEntityStrike",
"code": "TL_messageEntityCode",
"pre": "TL_messageEntityPre",
"text_link": "TL_messageEntityTextUrl",
"url": "TL_messageEntityUrl",
"email": "TL_messageEntityEmail",
"blockquote": "TL_messageEntityBlockquote",
"custom_emoji": "TL_messageEntityCustomEmoji",
"mention_name": "TL_messageEntityMentionName",
"spoiler": "TL_messageEntitySpoiler",
}
# The latest responses are kept (in memory only) and shown again when their
# chat is opened again, until the app restarts.
HISTORY = 20
def _state():
"""Kept across reloads of this plugin (not app restarts):
shown: (account, message id) -> what "Send in chat" sends, or None;
history: the latest HISTORY responses, oldest first."""
from library import shared
return shared().setdefault(
"responses",
{"shown": {}, "history": [], "counter": [random.randint(0x01000000, 0x0E000000)]},
)
def jboolean_type():
from java.lang import Boolean
return Boolean.TYPE
def tl_entities(entities):
"""ArrayList<TLRPC.MessageEntity> of entity dicts ({type, offset, length, ...})."""
from org.telegram.tgnet import TLRPC
out = []
for data in entities or ():
name = _ENTITY_CLASSES.get(data.get("type"))
if name is None:
continue
entity = getattr(TLRPC, name)()
entity.offset = int(data["offset"])
entity.length = int(data["length"])
if data.get("url") is not None and hasattr(entity, "url"):
entity.url = data["url"]
if data.get("language") is not None and hasattr(entity, "language"):
entity.language = data["language"]
if data.get("user_id") is not None and hasattr(entity, "user_id"):
entity.user_id = int(data["user_id"])
if data.get("document_id") is not None and hasattr(entity, "document_id"):
entity.document_id = int(data["document_id"])
if data.get("collapsed") and hasattr(entity, "collapsed"):
entity.collapsed = True
out.append(entity)
return array_list(out)
def is_local(message_object):
"""Whether a MessageObject is a response shown here."""
try:
return (int(message_object.currentAccount), int(message_object.getId())) in _state()["shown"]
except Exception:
return False
def open_chat(account, dialog_id, topic_id=0):
"""The ChatActivity showing that chat (and topic) now, else None."""
from client_utils import get_last_fragment
fragment = get_last_fragment()
if fragment is None or not java_class(CHAT).isInstance(fragment):
return None
if int(fragment.getCurrentAccount()) != int(account) or int(fragment.getDialogId()) != int(dialog_id):
return None
if topic_id and int(fragment.getTopicId()) != int(topic_id):
return None
return fragment
def _build(account, dialog_id, topic_id, reply, text, entities, message_id, date):
from org.telegram.messenger import MessageObject, MessagesController, UserConfig
from org.telegram.tgnet import TLRPC
controller = MessagesController.getInstance(account)
own = UserConfig.getInstance(account).getClientUserId()
msg = TLRPC.TL_message()
msg.id = message_id
msg.dialog_id = dialog_id
msg.peer_id = controller.getPeer(dialog_id)
msg.from_id = controller.getPeer(own)
msg.flags |= 256
msg.date = date
msg.message = text
msg.entities = entities
if entities.size():
msg.flags |= 128
reply_id = int(reply.getId()) if reply is not None else 0
if reply_id or topic_id:
header = TLRPC.TL_messageReplyHeader()
header.reply_to_msg_id = reply_id or int(topic_id)
header.flags |= 16
if topic_id:
header.forum_topic = True
header.flags |= 8
if reply_id:
header.reply_to_top_id = int(topic_id)
header.flags |= 2
msg.reply_to = header
msg.flags |= 8
msg.out = True
msg.unread = False
msg.ephemeralReceiverBotId = own
obj = MessageObject(account, msg, True, True)
if reply is not None:
obj.replyMessageObject = reply
return obj
def sendable(message_object):
"""What "Send in chat" sends for a response shown here, else None:
{account, dialog_id, reply, thread, text, entities}."""
try:
return _state()["shown"].get((int(message_object.currentAccount), int(message_object.getId())))
except Exception:
return None
def show(
account, dialog_id, topic_id, reply, text, entities=(), header=None, thread=None, answer=False, keep=True
):
"""Shows a response in the open chat; its id, or None (a bulletin was
shown instead). `header` (the command) goes first, as code. An `answer`
can be sent into the chat from its menu (without the header). Kept
responses come back when the chat is opened again. UI thread."""
from org.telegram.messenger import MessageObject
from org.telegram.tgnet import ConnectionsManager
state = _state()
body = {
"account": int(account),
"dialog_id": int(dialog_id),
"reply": reply,
"thread": thread,
"text": text,
"entities": list(entities or ()),
}
if header:
shift = utf16_len(header) + 1
entities = [{"type": "code", "offset": 0, "length": utf16_len(header)}] + [
dict(e, offset=int(e["offset"]) + shift) for e in entities or ()
]
text = f"{header}\n{text}" if text else header
state["counter"][0] += 1
entry = {
"account": int(account),
"dialog_id": int(dialog_id),
"topic_id": int(topic_id or 0),
"id": int(MessageObject.ephemeralMessageIdPack(state["counter"][0])),
"date": int(ConnectionsManager.getInstance(account).getCurrentTime()),
"reply": reply,
"text": text,
"entities": list(entities or ()),
}
state["shown"][(entry["account"], entry["id"])] = body if answer else None
if keep:
history = state["history"]
history.append(entry)
del history[:-HISTORY]
fragment = open_chat(account, dialog_id, topic_id)
if fragment is None:
from hooks.notify import bulletin
bulletin(text[:300])
return None
_insert(fragment, [entry])
return entry["id"]
def _insert(fragment, entries):
from java import jboolean
objects = [
_build(
e["account"], e["dialog_id"], e["topic_id"], e["reply"], e["text"],
tl_entities(e["entities"]), e["id"], e["date"],
)
for e in entries
]
method(CHAT, "processNewMessages", "java.util.ArrayList", jboolean_type()).invoke(
fragment, array_list(objects), jboolean(False)
)
def restore(fragment):
"""Shows a just opened chat's kept responses it doesn't show yet."""
state = _state()
account, dialog_id = int(fragment.getCurrentAccount()), int(fragment.getDialogId())
topic_id = int(fragment.getTopicId())
dicts = field(fragment, "messagesDict")
present = dicts[0] if dicts is not None else None
missing = [
e
for e in state["history"]
if e["account"] == account
and e["dialog_id"] == dialog_id
and e["topic_id"] == topic_id
and (present is None or present.get(e["id"]) is None)
]
if missing:
_insert(fragment, missing)
def remove(account, dialog_id, message_id):
"""Takes a response out of the chat showing it, and out of the kept
ones. UI thread."""
state = _state()
state["shown"].pop((int(account), int(message_id)), None)
state["history"][:] = [
e for e in state["history"] if (e["account"], e["id"]) != (int(account), int(message_id))
]
fragment = open_chat(account, dialog_id)
if fragment is None:
return
from java import jboolean, jint, jlong
from java.lang import Long
from org.telegram.messenger import ChatObject
chat = fragment.getCurrentChat()
channel_id = int(chat.id) if chat is not None and ChatObject.isChannel(chat) else 0
method(CHAT, "processDeletedMessages", "java.util.ArrayList", Long.TYPE, jboolean_type()).invoke(
fragment, array_list([jint(int(message_id))]), jlong(channel_id), jboolean(False)
)
def _label_only_you(cell):
"""The cell's "Only visible to @…" label, as "Only visible to you"."""
from android.text import Layout, SpannableStringBuilder, StaticLayout
from org.telegram.messenger import AndroidUtilities, LocaleController, R
from org.telegram.ui.ActionBar import Theme
layout = field(cell, "ephemeralLayout")
if layout is None:
return
label = SpannableStringBuilder(layout.getText())
you = LocaleController.getString(R.string.EphemeralMessageVisibleToYou)
if str(label)[2:] == str(you):
return
label.replace(2, label.length(), you) # after the icon and a space
relabeled = StaticLayout(
label, Theme.chat_ephemeralPaint, AndroidUtilities.dp(300), Layout.Alignment.ALIGN_NORMAL, 0.0, 0.0, False
)
set_field(cell, "ephemeralLayout", relabeled)
if relabeled.getLineCount() > 0:
from java import jint
set_field(cell, "ephemeralWidth", jint(int(relabeled.getLineWidth(0)) + AndroidUtilities.dp(10)))
class Responses:
"""Keeps deleting and replying to responses on the device."""
def __init__(self, plugin):
self.plugin = plugin
self.handles = []
# Chats (class guids) already given their kept responses; a chat
# restored before a reload of this plugin is skipped by its ids.
self._restored = set()
def install(self):
class LocalDelete(MethodHook):
# MessagesController.deleteEphemeralMessage(long dialogId, int topicId, MessageObject)
def before_hooked_method(self, param):
try:
message = param.args[2]
if is_local(message):
remove(int(message.currentAccount), int(param.args[0]), int(message.getId()))
param.setResult(None)
except Exception as exc:
diag.error("local delete", exc)
class NoReply(MethodHook):
# ChatActivity.showFieldPanelForReply(MessageObject)
def before_hooked_method(self, param):
try:
if param.args and is_local(param.args[0]):
param.setResult(None)
except Exception as exc:
diag.error("local reply", exc)
self.handles += hook_methods(
self.plugin, "org.telegram.messenger.MessagesController", "deleteEphemeralMessage", LocalDelete()
)
class OnlyYou(MethodHook):
# ChatMessageCell.setMessageObjectInternal(MessageObject)
def after_hooked_method(self, param):
try:
if is_local(param.args[0]):
_label_only_you(param.thisObject)
except Exception as exc:
diag.error("local label", exc)
restored = self._restored
class Visible(MethodHook):
# ChatActivity.updateMessagesVisiblePart: its messages are shown
def after_hooked_method(self, param):
try:
chat = param.thisObject
guid = int(chat.getClassGuid())
if guid in restored:
return
messages = field(chat, "messages")
if messages is None or messages.isEmpty():
return
restored.add(guid)
restore(chat)
except Exception as exc:
diag.error("restore responses", exc)
self.handles += hook_methods(self.plugin, CHAT, "updateMessagesVisiblePart", Visible())
self.handles += hook_methods(self.plugin, CHAT, "showFieldPanelForReply", NoReply(), arity=1)
self.handles += hook_methods(
self.plugin, "org.telegram.ui.Cells.ChatMessageCell", "setMessageObjectInternal", OnlyYou(), arity=1
)
def uninstall(self):
unhook_all(self.plugin, self.handles)
+386
View File
@@ -0,0 +1,386 @@
"""Commands in Telegram's own suggestion list above the input.
Typing `/` lists the account's commands as bot commands are listed (the
account's avatar, `/name`, then its parameters and summary), before the
chat's bot commands. Once a command is followed by a space, the list shows
its parameters Discord-style instead: the one at the cursor highlighted,
missing required ones in red, given ones with their value, and whether it
needs a reply.
MentionsAdapter.searchUsernameOrHashtag runs on every change of the field;
afterwards its command lists are filled in. Tapping a row is handled here
(the chat's item listeners are wrapped after ChatActivity.createView):
a command without required parameters runs, like a bot command is sent;
any other is put into the field to complete; a parameter row adds `name:`.
"""
from base_plugin import MethodHook
import diag
from commands.parse import analyze, command_name_at, split_command
from config import SHOW_PARAMS
from jutil import array_list, field, hook_methods, set_field, unhook_all
from hooks.composer import reply_of
from hooks.notify import bulletin
ADAPTER = "org.telegram.ui.Adapters.MentionsAdapter"
CHAT = "org.telegram.ui.ChatActivity"
_CLEARED = (
"searchResultUsernames",
"searchResultUsernamesMap",
"searchResultHashtags",
"searchResultSuggestions",
"searchResultBotContext",
"stickers",
"hintHashtag",
)
_COMMAND_LISTS = (
"searchResultCommands",
"searchResultCommandsHelp",
"searchResultCommandsUsers",
"searchResultCommandsEphemeral",
)
def _identity(obj):
from java.lang import System
return int(System.identityHashCode(obj))
class Row:
__slots__ = ("kind", "command", "param", "label", "help", "style")
def __init__(self, kind, command, label, help, param=None, style=None):
self.kind = kind # command | header | param | reply
self.command = command
self.param = param
self.label = label
self.help = help
self.style = style # current | missing | filled | None
def param_rows(command, analysis, replying):
"""The rows describing `command`'s parameters while it is typed."""
rows = [Row("header", command, "/" + command.name, command.summary or command.usage())]
for param in command.params:
value = analysis.values.get(param.name)
need = "required" if param.required else "optional"
about = f"{param.type_label()} · {need}"
if param.description:
about += f" · {param.description}"
if value is not None:
about = f"✓ {value} · {about}"
if param.name == analysis.current:
style = "current"
elif value is not None:
style = "filled"
else:
style = "missing" if param.required else None
rows.append(Row("param", command, param.name, about, param, style))
if command.reply != "none":
if replying:
rows.append(Row("reply", command, "↩ reply", "✓ replying to a message", style="filled"))
else:
need = "required" if command.reply == "required" else "optional"
style = "missing" if command.reply == "required" else None
rows.append(Row("reply", command, "↩ reply", f"{need} · swipe a message to reply to it", style=style))
return rows
class Suggest:
def __init__(self, plugin, catalog, composer):
self.plugin = plugin
self.catalog = catalog
self.composer = composer
self.handles = []
self._states = {} # adapter identity -> ([Row] first in its list, that list's identity)
self._proxies = {}
def install(self):
suggest = self
class Search(MethodHook):
def before_hooked_method(self, param):
try:
if suggest.before_search(param.thisObject, param.args):
param.setResult(None)
except Exception as exc:
diag.error("suggest search", exc)
def after_hooked_method(self, param):
try:
suggest.after_search(param.thisObject, param.args)
except Exception as exc:
diag.error("suggest search", exc)
class Bind(MethodHook):
def after_hooked_method(self, param):
try:
suggest.on_bind(param.thisObject, param.args[0], int(param.args[1]))
except Exception as exc:
diag.error("suggest bind", exc)
class Created(MethodHook):
def after_hooked_method(self, param):
try:
suggest.wrap_listeners(param.thisObject)
except Exception as exc:
diag.error("suggest listeners", exc)
self.handles += hook_methods(self.plugin, ADAPTER, "searchUsernameOrHashtag", Search(), arity=5)
self.handles += hook_methods(self.plugin, ADAPTER, "onBindViewHolder", Bind(), arity=2)
self.handles += hook_methods(self.plugin, CHAT, "createView", Created(), arity=1)
def uninstall(self):
unhook_all(self.plugin, self.handles)
self._states.clear()
# filling the list
def _typed(self, adapter, args):
"""(chat, text, cursor, commands) when a command may be being typed."""
chat = getattr(adapter, "parentFragment", None)
if chat is None or args[3] or args[4] or args[0] is None:
return None
text = str(args[0])
if not text.startswith("/") or int(chat.getChatMode()) != 0:
return None
commands = self.catalog.commands_for(int(chat.getCurrentAccount()))
return (chat, text, int(args[1]), commands) if commands else None
def before_search(self, adapter, args):
"""Parameters of a known command: shown instead of the app's search
(whose `:` emoji search would replace them); True to skip it."""
self._states.pop(_identity(adapter), None)
typed = self._typed(adapter, args)
if typed is None:
return False
chat, text, cursor, commands = typed
for name in _COMMAND_LISTS: # left from an earlier search
set_field(adapter, name, None)
split = split_command(text)
if split is None or not self.plugin.get_setting(SHOW_PARAMS, True):
return False
command = next((c for c in commands if split[0] in c.names), None)
if command is None or (not command.params and command.reply == "none"):
return False
reply = reply_of(chat)
analysis = analyze(command, text, cursor, reply is not None)
self._fill(adapter, chat, param_rows(command, analysis, reply is not None), keep_bots=False)
self._text_changed(chat)
return True
def after_search(self, adapter, args):
"""Commands matching a bare `/prefix`, before the chat's bot commands."""
typed = self._typed(adapter, args)
chat = getattr(adapter, "parentFragment", None)
if chat is not None:
self._text_changed(chat)
if typed is None:
return
chat, text, cursor, commands = typed
prefix = command_name_at(text) if cursor >= len(text) else None
if prefix is None:
return
found = [c for c in commands if any(n.startswith(prefix) for n in c.names)]
if found:
rows = [Row("command", c, "/" + c.name, c.hint()) for c in found]
self._fill(adapter, chat, rows, keep_bots=True)
def _text_changed(self, chat):
enter = chat.getChatActivityEnterView()
if enter is not None:
self.composer.text_changed(enter)
def _fill(self, adapter, chat, rows, keep_bots):
from org.telegram.messenger import MessagesController, UserConfig
account = int(chat.getCurrentAccount())
me = MessagesController.getInstance(account).getUser(UserConfig.getInstance(account).getClientUserId())
names, helps, users, ephemeral = [], [], [], []
# Quick replies go too: the app binds command rows as if they were
# first in the list.
for row in rows:
names.append(row.label)
helps.append(row.help)
users.append(me if row.kind in ("command", "header") else None)
ephemeral.append(False)
if keep_bots:
bots = field(adapter, "searchResultCommands")
if bots is not None:
bot_help = field(adapter, "searchResultCommandsHelp")
bot_users = field(adapter, "searchResultCommandsUsers")
bot_ephemeral = field(adapter, "searchResultCommandsEphemeral")
for i in range(bots.size()):
names.append(bots.get(i))
helps.append(bot_help.get(i) if bot_help is not None and i < bot_help.size() else "")
users.append(bot_users.get(i) if bot_users is not None and i < bot_users.size() else None)
ephemeral.append(
bool(bot_ephemeral.get(i)) if bot_ephemeral is not None and i < bot_ephemeral.size() else False
)
for name in _CLEARED + ("quickReplies",):
set_field(adapter, name, None)
shown = array_list(names)
set_field(adapter, "searchResultCommands", shown)
set_field(adapter, "searchResultCommandsHelp", array_list(helps))
set_field(adapter, "searchResultCommandsUsers", array_list(users))
set_field(adapter, "searchResultCommandsEphemeral", array_list(ephemeral))
self._states[_identity(adapter)] = (rows, _identity(shown))
self.wrap_listeners(chat)
adapter.notifyDataSetChanged()
# Its diff compares the labels only: rows whose help or style
# changed have to be rebound too.
adapter.notifyItemRangeChanged(0, len(rows))
delegate = field(adapter, "delegate")
if delegate is not None:
delegate.needChangePanelVisibility(True)
def _rows(self, adapter):
"""Our rows while the adapter still shows them (an async search of
the app may have replaced them since)."""
state = self._states.get(_identity(adapter))
if state is None:
return ()
shown = field(adapter, "searchResultCommands")
if shown is None or _identity(shown) != state[1] or field(adapter, "searchResultSuggestions") is not None:
return ()
return state[0]
# styling rows
def on_bind(self, adapter, holder, position):
"""Colors our rows' names; a recycled cell gets its own color back.
The original color is kept in the name view's tag, which outlives
this plugin being reloaded."""
cell = holder.itemView
if cell.getClass().getSimpleName() != "MentionCell":
return
name_view = field(cell, "nameTextView")
if name_view is None:
return
from java import jint
rows = self._rows(adapter)
row = rows[position] if 0 <= position < len(rows) else None
original = name_view.getTag()
if row is None or row.style is None:
if original is not None:
name_view.setTextColor(jint(int(original)))
name_view.setTag(None)
return
from org.telegram.ui.ActionBar import Theme
if original is None:
name_view.setTag(int(name_view.getCurrentTextColor()))
color = {
"current": Theme.key_windowBackgroundWhiteBlueText,
"missing": Theme.key_text_RedRegular,
"filled": Theme.key_windowBackgroundWhiteGreenText,
}[row.style]
name_view.setTextColor(jint(Theme.getColor(color)))
# tapping rows
def wrap_listeners(self, chat):
"""Routes taps on the chat's suggestion list through `tapped`/`held`
(once per list and load of this plugin). The app's own listeners
handle every other row: the click one is ChatActivity's field, the
long-click one is kept across reloads (the list then holds ours)."""
from library import shared
container = field(chat, "mentionContainer")
if container is None:
return
list_view = container.getListView()
key = _identity(list_view)
mine = self._proxies.get(key)
current = field(list_view, "onItemClickListener")
if mine is not None and current is not None and _identity(current) == _identity(mine[0]):
return
click = field(chat, "mentionsOnItemClickListener")
long_clicks = shared().setdefault("long_clicks", {})
long_click = long_clicks.get(key) or field(list_view, "onItemLongClickListener")
long_clicks[key] = long_click
from java import dynamic_proxy
from org.telegram.ui.Components import RecyclerListView
suggest = self
class Click(dynamic_proxy(RecyclerListView.OnItemClickListener)):
def onItemClick(self, view, position):
handled = False
try:
handled = suggest.tapped(chat, container, position)
except Exception as exc:
diag.error("suggest tap", exc)
if not handled and click is not None:
click.onItemClick(view, position)
class LongClick(dynamic_proxy(RecyclerListView.OnItemLongClickListener)):
def onItemClick(self, view, position):
try:
if suggest.tapped(chat, container, position):
return True
except Exception as exc:
diag.error("suggest hold", exc)
return bool(long_click.onItemClick(view, position)) if long_click is not None else False
proxies = (Click(), LongClick())
self._proxies[key] = proxies # referenced as long as the list lives
if len(self._proxies) > 32:
self._proxies.pop(next(iter(self._proxies)))
list_view.setOnItemClickListener(proxies[0])
if long_click is not None:
list_view.setOnItemLongClickListener(proxies[1])
def row_at(self, container, position):
"""Our row at a list position (the list's first item is padding)."""
rows = self._rows(container.getAdapter())
index = position - 1
return rows[index] if 0 <= index < len(rows) else None
def _our_command_at(self, chat, container, position):
"""One of our commands at a list position though our rows are gone
(a stale list): its row is never left to the app, which would send
it."""
item = container.getAdapter().getItem(position - 1)
if not isinstance(item, str) or not item.startswith("/"):
return None
name = item[1:].split("@", 1)[0]
return self.catalog.find(int(chat.getCurrentAccount()), name)
def tapped(self, chat, container, position):
"""A tap (or hold) on the list: True when it was one of our rows.
Rows only ever fill the field; sending is the send button's job."""
row = self.row_at(container, position)
if row is None:
command = self._our_command_at(chat, container, position)
if command is None:
return False
row = Row("command", command, "/" + command.name, "")
enter = chat.getChatActivityEnterView()
if enter is None:
return True
command = row.command
if row.kind in ("command", "header"):
takes = command.params or command.reply != "none"
text = str(enter.getFieldText() or "")
if row.kind == "command" or split_command(text) is None:
self.composer.set_text(enter, f"/{command.name} " if takes else f"/{command.name}")
elif command.description:
bulletin(command.description)
elif row.kind == "param":
text = str(enter.getFieldText() or "")
if row.param.name not in (self._current_values(chat, text) or {}):
gap = "" if not text or text.endswith(" ") else " "
self.composer.set_text(enter, f"{text}{gap}{row.param.name}:")
elif row.kind == "reply":
bulletin("Swipe a message to the left to reply to it")
return True
def _current_values(self, chat, text):
typed = self.composer.read(chat.getChatActivityEnterView())
return typed.analysis.values if typed is not None else None
+150
View File
@@ -0,0 +1,150 @@
"""Chaquopy/Java helpers shared by the hooks (after messageLogger's jutil)."""
import threading
import diag
def java_list(items):
"""A java.util.List as a Python list (Java lists aren't iterable here)."""
if items is None:
return []
return [items.get(i) for i in range(items.size())]
def array_list(items):
from java.util import ArrayList
out = ArrayList()
for item in items:
out.add(item)
return out
def java_class(name):
"""The real java.lang.Class: find_class returns a Chaquopy wrapper whose
getClass() is the Class."""
from hook_utils import find_class
wrapper = find_class(name)
if wrapper is None:
return None
try:
return wrapper.getClass()
except Exception:
return wrapper
def hook_methods(plugin, class_name, name, hook, arity=None):
"""Hooks every overload of `class_name.name` (or those with `arity`
parameters); returns the unhook handles."""
cls = java_class(class_name)
if cls is None:
diag.log(f"class not found: {class_name}")
return []
handles = []
for method in cls.getDeclaredMethods():
if method.getName() != name:
continue
if arity is not None and len(method.getParameterTypes()) != arity:
continue
try:
handle = plugin.hook_method(method, hook)
if handle is not None:
handles.append(handle)
except Exception as exc:
diag.error(f"hook {class_name}.{name}", exc)
if not handles:
diag.log(f"nothing hooked for {class_name}.{name}")
return handles
def unhook_all(plugin, handles):
for handle in handles:
try:
plugin.unhook_method(handle)
except Exception:
pass
del handles[:]
def field(obj, name):
"""A (possibly private) field of a Java object."""
value = getattr(obj, name, None)
if value is not None:
return value
try:
from hook_utils import get_private_field
return get_private_field(obj, name)
except Exception:
return None
def set_field(obj, name, value):
from hook_utils import set_private_field
set_private_field(obj, name, value)
_methods = {}
def method(class_name, name, *parameter_types):
"""A declared (possibly private) method, made accessible; cached.
`parameter_types` are java.lang.Class objects or class names."""
key = (class_name, name, tuple(str(t) for t in parameter_types))
if key not in _methods:
types = [java_class(t) if isinstance(t, str) else t for t in parameter_types]
found = java_class(class_name).getDeclaredMethod(name, *types)
found.setAccessible(True)
_methods[key] = found
return _methods[key]
def on_ui(fn, delay_ms=0):
from android_utils import run_on_ui_thread
def run():
try:
fn()
except Exception as exc:
diag.error("ui task", exc)
run_on_ui_thread(run, delay_ms)
def in_background(fn):
"""Runs `fn` off the UI thread; exceptions are logged, not lost."""
def run():
try:
fn()
except Exception as exc:
diag.error("background task", exc)
threading.Thread(target=run, daemon=True).start()
_Runnable = []
def runnable(fn):
"""A java.lang.Runnable calling `fn` (exceptions logged)."""
if not _Runnable:
from java import dynamic_proxy
from java.lang import Runnable
class PyRunnable(dynamic_proxy(Runnable)):
def __init__(self, fn):
super().__init__()
self.fn = fn
def run(self):
try:
self.fn()
except Exception as exc:
diag.error("runnable", exc)
_Runnable.append(PyRunnable)
return _Runnable[0](fn)
+173
View File
@@ -0,0 +1,173 @@
"""The plugin as a library: other plugins register their own commands.
Published as the module `client_commands` (in `sys.modules`), so another
plugin can do:
import sys, types
def setup(api):
api.register(
"roll",
roll,
summary="Rolls a die",
params=[api.Param("sides", "integer", required=False, default=6)],
owner=__id__,
)
def roll(ctx, sides=6):
import random
return f"🎲 <b>{random.randint(1, sides)}</b>"
# in on_plugin_load: works whichever plugin loads first
api = sys.modules.setdefault("client_commands", types.ModuleType("client_commands"))
if hasattr(api, "register"):
setup(api)
else:
api.__dict__.setdefault("pending", []).append(setup)
# in on_plugin_unload
sys.modules["client_commands"].unregister_owner(__id__)
A handler gets a `CommandContext` and the parameters as keyword arguments,
runs off the UI thread, and returns the response as Telegram-style HTML (or
None). `raise api.CommandError("...")` shows a user-facing error.
The registry outlives the module object (the plugin engine drops modules a
plugin added when it unloads): it hangs off `builtins`, so registrations
survive this plugin being updated or turned off and on, and an `api` kept
from an earlier load still registers into it.
"""
import sys
import types
from commands.markup import escape
from commands.model import Command, Param
from commands.parse import CommandError
MODULE = "client_commands"
VERSION = 1
_STATE = "__client_commands_state__"
def shared():
"""State kept across loads of this plugin: {"registry": {name: Command},
...}; the hooks keep what they must not lose on a reload here too."""
import builtins
state = getattr(builtins, _STATE, None)
if state is None:
state = {"registry": {}}
setattr(builtins, _STATE, state)
return state
class CommandContext:
"""Where a command runs. `reply_to` is the replied MessageObject (or
None), `values` the parsed parameters, `send_to_chat` whether the
response goes into the chat (as the account) instead of only to you."""
def __init__(self, account, dialog_id, topic_id, reply_to, text, command, values, send_to_chat):
self.account = account
self.dialog_id = dialog_id
self.topic_id = topic_id
self.reply_to = reply_to
self.text = text
self.command = command
self.values = values
self.send_to_chat = send_to_chat
@property
def client(self):
"""The SDK's AccountClient of the account (send_text, send_request...)."""
from client_utils import get_client
return get_client(self.account)
class Library:
def __init__(self):
self.module = None
@property
def _registry(self):
return shared()["registry"]
def install(self):
"""Publishes the API (keeping earlier registrations) and runs the
setups other plugins queued before this plugin loaded."""
module = sys.modules.get(MODULE)
if module is None:
module = types.ModuleType(MODULE)
sys.modules[MODULE] = module
self.module = module
module.__doc__ = __doc__
module.VERSION = VERSION
module.Param = Param
module.CommandError = CommandError
module.CommandContext = CommandContext
module.escape = escape
module.register = self.register
module.unregister = self.unregister
module.unregister_owner = self.unregister_owner
module.commands = self.commands
module.available = True
module._registry = self._registry
pending = module.__dict__.pop("pending", [])
for setup in pending:
try:
setup(module)
except Exception as exc:
import diag
diag.error("library pending setup", exc)
def uninstall(self):
if self.module is not None:
self.module.available = False
def register(
self,
name,
handler,
*,
params=(),
summary="",
description="",
aliases=(),
reply="none",
owner=None,
):
"""Adds (or replaces) a command run by `handler(ctx, **values)`.
`params` are `Param`s or dicts like the panel lists them."""
if not callable(handler):
raise TypeError("handler must be callable")
params = [p if isinstance(p, Param) else Param.from_json(p) for p in params]
command = Command(
name,
params,
summary,
description,
aliases,
reply,
source=str(owner or "plugin"),
handler=handler,
)
self._registry[command.name] = command
return command
def unregister(self, name):
return self._registry.pop(str(name).lstrip("/").lower(), None) is not None
def unregister_owner(self, owner):
names = [n for n, c in self._registry.items() if c.source == str(owner)]
for name in names:
self._registry.pop(name)
return len(names)
def commands(self):
"""Every command plugins registered, as dicts."""
return [dict(c.to_json(), owner=c.source) for c in self.local_commands()]
def local_commands(self):
return sorted(self._registry.values(), key=lambda c: c.name)
+192 -38
View File
@@ -1,50 +1,204 @@
from base_plugin import BasePlugin, HookResult, HookStrategy
from android_utils import log
from ui.settings import Header, Input, Text
from greeting import render, TemplateError
from ui.settings import Divider, Header, Input, Switch, Text
DEFAULT_TEMPLATE = "Hello, {name}!"
import diag
from api import Api, ApiError
from catalog import Catalog
from commands.parse import split_command
from config import API_TOKENS, DEFAULTS, SEND_TO_CHAT, SERVER_COMMANDS, SERVER_URL, SHOW_PARAMS
from jutil import in_background, on_ui
from library import Library
from runner import Invocation, Runner
from hooks.composer import Composer
from hooks.menu import ResponseMenu
from hooks.notify import bulletin
from hooks.responses import Responses
from hooks.suggest import Suggest
class HelloPlugin(BasePlugin):
class ClientCommandsPlugin(BasePlugin):
def on_plugin_load(self):
log("hello_bundle loaded")
self.library = Library()
self.library.install()
self.api = Api(self)
self.catalog = Catalog(self, self.api, self.library)
self.runner = Runner(self, self.api, self.catalog, self.library)
self.responses = Responses(self)
self.composer = Composer(self, self.catalog, self.runner)
self.suggest = Suggest(self, self.catalog, self.composer)
self.menu = ResponseMenu(self, self.runner)
self.catalog.on_change(self.composer.forget)
for part in (self.responses, self.composer, self.suggest, self.menu):
try:
part.install()
except Exception as exc:
diag.error(f"install {type(part).__name__}", exc)
self.add_on_send_message_hook()
self.catalog.start()
diag.log("loaded")
def on_plugin_unload(self):
log("hello_bundle unloaded")
def create_settings(self):
return [
Header(text="Hello Bundle"),
Input(
key="template",
text="Greeting template",
default=DEFAULT_TEMPLATE,
subtext="Use {name} as placeholder.",
icon="msg_edit",
),
Text(
text="Example",
subtext=".hello alice smith -> Hello, Alice Smith!",
icon="msg_info",
),
]
for part in ("menu", "suggest", "composer", "responses"):
if hasattr(self, part):
getattr(self, part).uninstall()
if hasattr(self, "catalog"):
self.catalog.stop()
if hasattr(self, "library"):
self.library.uninstall()
diag.log("unloaded")
def on_send_message_hook(self, account, params):
message = getattr(params, "message", None)
if not isinstance(message, str):
"""Commands sent some other way than the field (the field's own
sending is handled by the composer): they run when complete, else go
back into the field. Never sent as text, unless "Send as message"."""
try:
return self._sent(int(account), params)
except Exception as exc:
diag.error("send hook", exc)
return HookResult()
stripped = message.strip()
if not stripped.startswith(".hello"):
def _sent(self, account, params):
text = getattr(params, "message", None)
if not isinstance(text, str) or not text.startswith("/"):
return HookResult()
parts = stripped.split(" ", 1)
name = parts[1] if len(parts) > 1 else ""
if not name.strip():
params.message = "Usage: .hello <name>"
else:
try:
params.message = render(self.get_setting("template", DEFAULT_TEMPLATE), name)
except TemplateError as exc:
params.message = "Bad template: " + str(exc)
return HookResult(strategy=HookStrategy.MODIFY, params=params)
if self.runner.bypassed(text, consume=True) or int(getattr(params, "scheduleDate", 0) or 0):
return HookResult()
split = split_command(text)
command = self.catalog.find(account, split[0]) if split else None
if command is None:
return HookResult()
from commands.parse import analyze
reply = getattr(params, "replyToMsg", None)
thread = getattr(params, "replyToTopMsg", None)
if reply is not None and thread is not None and int(reply.getId()) == int(thread.getId()):
reply = None # the topic itself, not a message in it
analysis = analyze(command, text, None, reply is not None)
if not analysis.complete:
on_ui(lambda: self._refill(account, int(params.peer), text, analysis.error), 80)
return HookResult(strategy=HookStrategy.CANCEL)
topic = int(thread.getId()) if thread is not None else 0
inv = Invocation(
account,
int(params.peer),
topic,
reply,
thread,
text,
command,
self.get_setting(SEND_TO_CHAT, False),
)
on_ui(lambda: self.runner.run(inv))
return HookResult(strategy=HookStrategy.CANCEL)
def _refill(self, account, dialog_id, text, reason):
"""Puts an incomplete command back into the open chat's field (after
the app cleared it for sending)."""
from hooks.responses import open_chat
chat = open_chat(account, dialog_id)
enter = chat.getChatActivityEnterView() if chat is not None else None
if enter is not None:
self.composer.set_text(enter, text if text.endswith(" ") else text + " ")
bulletin(reason, error=True)
# settings
def _tokens_changed(self, _value=None):
self.catalog.refresh(reset=True)
def _test_connection(self, _view=None):
def run():
if not self.api.configured:
bulletin("Client Commands: set the panel URL and API tokens", error=True)
return
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}")
bulletin("Accounts: " + "; ".join(lines), error=failed)
self.catalog.refresh()
in_background(run)
def _refresh(self, _view=None):
self.catalog.refresh(reset=True)
bulletin("Client Commands: asking the panel for commands")
def _command_rows(self):
from client_utils import get_selected_account
account = get_selected_account()
commands = self.catalog.commands_for(account)
if not commands:
return [Text(text="No commands yet", subtext="Set up the panel above, or install a plugin that adds some")]
rows = []
for command in commands:
source = "panel" if not command.local else command.source
about = command.summary or command.description
rows.append(
Text(
text=command.usage(),
subtext=f"{about} · {source}" if about else source,
icon="msg_bot",
)
)
return rows
def create_settings(self):
rows = [
Header(text="Panel"),
Input(
key=SERVER_URL,
text="telegram-panel URL",
default=DEFAULTS[SERVER_URL],
subtext="e.g. https://panel.example.com",
icon="msg_link",
on_change=self._tokens_changed,
),
Input(
key=API_TOKENS,
text="API tokens",
default=DEFAULTS[API_TOKENS],
subtext="Client commands tokens, one per account, separated by spaces "
"(panel: Settings → API Tokens). Not the message logger ones.",
icon="msg_permissions",
on_change=self._tokens_changed,
),
Text(text="Test connection", icon="msg_info", on_click=self._test_connection),
Text(text="Refresh commands", icon="msg_retry", on_click=self._refresh),
Header(text="Commands"),
Switch(
key=SERVER_COMMANDS,
text="Panel commands",
default=DEFAULTS[SERVER_COMMANDS],
subtext="Run the panel's commands from here instead of sending them",
icon="msg_bot",
),
Switch(
key=SEND_TO_CHAT,
text="Send response in chat",
default=DEFAULTS[SEND_TO_CHAT],
subtext="Responses go into the chat as you, not only to you. Also in the menu of a held send button",
icon="msg_discussion",
),
Switch(
key=SHOW_PARAMS,
text="Parameter hints",
default=DEFAULTS[SHOW_PARAMS],
subtext="List a command's parameters while you type it",
icon="msg_help",
),
Text(
text="Available commands",
icon="msg_bots",
create_sub_fragment=self._command_rows,
),
Divider(text="Hold send on a command to send it as a plain message instead."),
]
return rows
+22
View File
@@ -0,0 +1,22 @@
"""Telegram Android dialog ids <-> the panel's marked (Telethon) chat ids.
Android: users are positive, basic groups and channels both `-id`.
Telethon: users positive, basic groups `-id`, channels `-(10**12 + id)`.
"""
CHANNEL_OFFSET = 10**12
def marked_chat_id(account, dialog_id):
"""The panel's chat id for a dialog, or None (secret chats)."""
from org.telegram.messenger import ChatObject, DialogObject, MessagesController
dialog_id = int(dialog_id)
if DialogObject.isEncryptedDialog(dialog_id):
return None
if dialog_id > 0:
return dialog_id
chat = MessagesController.getInstance(account).getChat(-dialog_id)
if chat is not None and ChatObject.isChannel(chat):
return -(CHANNEL_OFFSET + -dialog_id)
return dialog_id
+190
View File
@@ -0,0 +1,190 @@
"""Running a command: on the panel, or on the device for plugins' commands.
While it runs, a "Running…" response stands in the chat; then the response
(or the error) replaces it, shown only to you, or the response goes into the
chat as the account when "Send response in chat" is on.
"""
import threading
import time
import diag
import peers
from api import ApiError
from commands import markup
from commands.parse import parse
from jutil import array_list, in_background, on_ui
from hooks import responses
_RUNNING = "⏳ Running…"
_DONE = "✓ Done"
class Invocation:
"""A command the composer is about to run (built on the UI thread)."""
def __init__(self, account, dialog_id, topic_id, reply, thread, text, command, send_to_chat):
self.account = int(account)
self.dialog_id = int(dialog_id)
self.topic_id = int(topic_id or 0)
self.reply = reply # the replied MessageObject, or None
self.thread = thread # the topic's MessageObject, or None
self.text = text.strip()
self.command = command
self.send_to_chat = bool(send_to_chat)
self.chat_id = peers.marked_chat_id(account, dialog_id)
class Runner:
def __init__(self, plugin, api, catalog, library):
self.plugin = plugin
self.api = api
self.catalog = catalog
self.library = library
# Texts this plugin sends itself: the send hook lets them through.
self._bypass = {}
self._lock = threading.Lock()
# sending as plain messages
def bypass(self, text):
with self._lock:
self._bypass[text.strip()] = time.monotonic() + 30
def bypassed(self, text, consume=False):
key = (text or "").strip()
with self._lock:
until = self._bypass.get(key, 0)
if until < time.monotonic():
self._bypass.pop(key, None)
return False
if consume:
self._bypass.pop(key, None)
return True
# running
def run(self, inv):
"""Starts `inv` (UI thread)."""
pending = responses.show(
inv.account, inv.dialog_id, inv.topic_id, inv.reply, _RUNNING, header=inv.text, keep=False
)
def work():
try:
result = self._local(inv) if inv.command.local else self._server(inv)
except Exception as exc:
diag.error(f"/{inv.command.name}", exc)
result = {"ok": False, "error": f"/{inv.command.name} failed: {exc}"}
on_ui(lambda: self._finish(inv, pending, result))
in_background(work)
def _server(self, inv):
if inv.chat_id is None:
return {"ok": False, "error": "Panel commands don't work in secret chats"}
token = self.catalog.token_for(inv.account)
if token is None:
return {
"ok": False,
"error": "No API token for this account: create a Client commands token "
"for it in the panel (Settings → API Tokens) and add it in the plugin settings",
}
from catalog import own_user_id
reply_id = int(inv.reply.getId()) if inv.reply is not None else None
try:
return self.api.execute(
token,
inv.text,
inv.chat_id,
reply_id,
inv.topic_id or None,
inv.send_to_chat,
own_user_id(inv.account),
)
except ApiError as exc:
if exc.refused:
self.catalog.refresh(reset=True)
return {"ok": False, "error": str(exc)}
def _local(self, inv):
from library import CommandContext
try:
values = parse(inv.command, inv.text)
ctx = CommandContext(
inv.account,
inv.dialog_id,
inv.topic_id,
inv.reply,
inv.text,
inv.command.name,
values,
inv.send_to_chat,
)
html = inv.command.handler(ctx, **values)
except Exception as exc:
if type(exc).__name__ == "CommandError":
return {"ok": False, "error": str(exc)}
raise
html = "" if html is None else str(html)
text, entities = markup.parse(html)
return {"ok": True, "html": html, "text": text, "entities": entities}
def _finish(self, inv, pending, result):
if pending is not None:
responses.remove(inv.account, inv.dialog_id, pending)
if not result.get("ok"):
error = result.get("error") or "Failed"
responses.show(inv.account, inv.dialog_id, inv.topic_id, inv.reply, f"❌ {error}", header=inv.text)
return
text = result.get("text") or ""
if inv.send_to_chat:
if inv.command.local and text.strip():
self.send(inv.account, inv.dialog_id, inv.reply, inv.thread, text, result.get("entities") or ())
return
if not text.strip():
responses.show(inv.account, inv.dialog_id, inv.topic_id, inv.reply, _DONE, header=inv.text)
return
responses.show(
inv.account,
inv.dialog_id,
inv.topic_id,
inv.reply,
text,
result.get("entities") or (),
header=inv.text,
thread=inv.thread,
answer=True,
)
def send(self, account, dialog_id, reply, thread, text, entities):
"""Sends a response into the chat as the account: an ordinary text
message with formatting entities (not a rich or ephemeral one; a reply
to an ephemeral message would make it one). UI thread."""
from org.telegram.messenger import SendMessagesHelper
if reply is not None and reply.isEphemeral():
reply = None
self.bypass(text)
params = SendMessagesHelper.SendMessageParams.of(
text,
dialog_id,
reply,
thread,
None,
False,
responses.tl_entities(entities) if entities else array_list([]),
None,
None,
True,
0,
0,
None,
False,
)
params.richMessage = None
if hasattr(params, "ephemeralReceiverBotId"):
params.ephemeralReceiverBotId = 0
SendMessagesHelper.getInstance(account).sendMessage(params)
-2
View File
@@ -1,2 +0,0 @@
def normalize(name: str) -> str:
return " ".join(w[:1].upper() + w[1:] for w in name.split())