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

+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;")