generated from exteraGramPlugins/pluginBase
Publish plugin / publish (push) Successful in 4s
- Deleted messages within the loaded part of a long chat are placed by the app among the others (not only at the chat's end), without counting as unread; restores no longer repeat every second. - Logger copies and their media are read through a member of the log group signed in here (this account, else the master); the shared file cache lets the current account show them. - Logged chat photos: asked for once per 10 minutes, all sizes downloaded (no waits on inline thumbnails), added once the photo viewer closed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
234 lines
8.6 KiB
Python
234 lines
8.6 KiB
Python
"""telegram-panel client API (`/api/v1/client/message-logger`).
|
|
|
|
Each token reads one master's logs (the account owning the logger groups);
|
|
the plugin holds one per master. A chat's state merges every master's answer;
|
|
a single message is looked up master by master.
|
|
|
|
Calls are blocking: run them off the UI thread (`jutil.in_background`).
|
|
"""
|
|
|
|
import re
|
|
import time
|
|
|
|
import diag
|
|
from config import API_TOKEN, HTTP_TIMEOUT_SECONDS, SERVER_URL
|
|
|
|
# A token the server refuses (403: unbound, no permission) is skipped this long.
|
|
_REFUSED_SECONDS = 600
|
|
|
|
|
|
class ApiError(Exception):
|
|
pass
|
|
|
|
|
|
class Api:
|
|
def __init__(self, plugin):
|
|
self.plugin = plugin
|
|
self._refused = {} # token -> monotonic time to retry at
|
|
|
|
@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/message-logger" if url else ""
|
|
|
|
def tokens(self):
|
|
raw = str(self.plugin.get_setting(API_TOKEN, "") or "")
|
|
return [t for t in re.split(r"[\s,;]+", raw) if t]
|
|
|
|
def _call(self, token, method, path, **kwargs):
|
|
import requests
|
|
|
|
if not self._base():
|
|
raise ApiError("Server URL is not set")
|
|
try:
|
|
response = requests.request(
|
|
method,
|
|
self._base() + path,
|
|
headers={"Authorization": "Bearer " + token},
|
|
timeout=HTTP_TIMEOUT_SECONDS,
|
|
**kwargs,
|
|
)
|
|
except requests.RequestException as exc:
|
|
raise ApiError(f"Server unreachable: {exc}") from exc
|
|
if response.status_code == 404:
|
|
try:
|
|
detail = response.json().get("detail")
|
|
except Exception:
|
|
detail = None
|
|
if detail == "Not Found": # the route itself: an older server
|
|
raise ApiError("the server doesn't support this yet (update telegram-panel)")
|
|
return None
|
|
if response.status_code >= 400:
|
|
try:
|
|
detail = response.json().get("detail")
|
|
except Exception:
|
|
detail = response.text[:200]
|
|
raise ApiError(f"{response.status_code}: {detail}")
|
|
return response.json()
|
|
|
|
def _each(self, method, path, **kwargs):
|
|
"""(token, result) for every usable token; a refused one is skipped."""
|
|
for token in self.tokens():
|
|
if time.monotonic() < self._refused.get(token, 0):
|
|
continue
|
|
try:
|
|
yield token, self._call(token, method, path, **kwargs)
|
|
except ApiError as exc:
|
|
if str(exc).startswith(("401", "403")):
|
|
diag.log(f"token {token[:10]}… refused: {exc}")
|
|
self._refused[token] = time.monotonic() + _REFUSED_SECONDS
|
|
continue
|
|
raise
|
|
|
|
def _first(self, method, path, **kwargs):
|
|
for _token, result in self._each(method, path, **kwargs):
|
|
if result is not None:
|
|
return result
|
|
return None
|
|
|
|
def me(self):
|
|
"""[(token prefix, /me or the error)] for every token."""
|
|
out = []
|
|
for token in self.tokens():
|
|
try:
|
|
out.append((token[:10], self._call(token, "GET", "/me")))
|
|
except ApiError as exc:
|
|
out.append((token[:10], exc))
|
|
return out
|
|
|
|
def masters(self):
|
|
"""Telegram ids of the tokens' masters (from /me), cached 10 minutes."""
|
|
cached = getattr(self, "_masters", None)
|
|
if cached is not None and time.monotonic() - cached[0] < 600:
|
|
return cached[1]
|
|
found = []
|
|
for _prefix, me in self.me():
|
|
if isinstance(me, dict) and me.get("master_tg_id"):
|
|
found.append(int(me["master_tg_id"]))
|
|
self._masters = (time.monotonic(), found)
|
|
return found
|
|
|
|
def sync(self, account_id, chat_id, min_id, max_id, present_ids):
|
|
body = {
|
|
"account_id": account_id,
|
|
"chat_id": chat_id,
|
|
"min_id": min_id,
|
|
"max_id": max_id,
|
|
"present_ids": list(present_ids)[:1000],
|
|
}
|
|
states, missing, any_result = {}, set(), False
|
|
for _token, result in self._each("POST", "/sync", json=body):
|
|
if result is None:
|
|
continue
|
|
any_result = True
|
|
for state in result.get("states") or []:
|
|
states.setdefault(int(state["message_id"]), state)
|
|
missing.update(int(i) for i in result.get("missing") or [])
|
|
if not any_result:
|
|
return None
|
|
return {"states": list(states.values()), "missing": sorted(missing)}
|
|
|
|
def message(self, account_id, chat_id, message_id):
|
|
return self._first(
|
|
"GET",
|
|
f"/messages/{chat_id}/{message_id}",
|
|
params={"account_id": account_id},
|
|
)
|
|
|
|
def versions(self, account_id, chat_id, message_id):
|
|
return self._first(
|
|
"GET",
|
|
f"/messages/{chat_id}/{message_id}/versions",
|
|
params={"account_id": account_id},
|
|
)
|
|
|
|
def comments(self, account_id, chat_id, message_id):
|
|
return self._first(
|
|
"GET",
|
|
f"/messages/{chat_id}/{message_id}/comments",
|
|
params={"account_id": account_id},
|
|
)
|
|
|
|
def deletions(self, account_id, chat_id, message_ids):
|
|
ids = sorted(set(int(i) for i in message_ids))
|
|
marked = 0
|
|
for start in range(0, len(ids), 500):
|
|
body = {
|
|
"account_id": account_id,
|
|
"chat_id": chat_id,
|
|
"message_ids": ids[start : start + 500],
|
|
}
|
|
for _token, result in self._each("POST", "/deletions", json=body):
|
|
marked += int((result or {}).get("marked", 0))
|
|
return marked
|
|
|
|
def hidden(self, account_id, chat_id, message_ids, hidden):
|
|
"""Hide messages from the chat for every client (or show them again);
|
|
True once every master took it."""
|
|
ids = sorted(set(int(i) for i in message_ids))
|
|
for start in range(0, len(ids), 500):
|
|
body = {
|
|
"account_id": account_id,
|
|
"chat_id": chat_id,
|
|
"message_ids": ids[start : start + 500],
|
|
"hidden": bool(hidden),
|
|
}
|
|
for _token, _result in self._each("POST", "/hidden", json=body):
|
|
pass
|
|
return True
|
|
|
|
# removed chats
|
|
|
|
def chat_removed(self, account_id, chat_id, own):
|
|
body = {"account_id": account_id, "chat_id": chat_id, "own": bool(own)}
|
|
known = False
|
|
for _token, result in self._each("POST", "/chats/removed", json=body):
|
|
known = known or bool((result or {}).get("known"))
|
|
return known
|
|
|
|
def chat_hidden(self, account_id, chat_id, hidden):
|
|
body = {"account_id": account_id, "chat_id": chat_id, "hidden": bool(hidden)}
|
|
for _token, _result in self._each("POST", "/chats/hidden", json=body):
|
|
pass
|
|
|
|
def removed_chats(self, account_id):
|
|
"""The account's removed chats, each master's answer merged (the
|
|
first one listing a chat wins), or None when no master answered."""
|
|
chats, any_result = {}, False
|
|
for _token, result in self._each(
|
|
"GET", "/chats/removed", params={"account_id": account_id}
|
|
):
|
|
if result is None:
|
|
continue
|
|
any_result = True
|
|
for chat in result.get("chats") or []:
|
|
chat["master_tg_id"] = result.get("master_tg_id")
|
|
chats.setdefault(int(chat["chat_id"]), chat)
|
|
return list(chats.values()) if any_result else None
|
|
|
|
def history(self, account_id, chat_id, before=0, limit=100):
|
|
"""Logged messages of a chat older than `before`, newest first: the
|
|
first master that has any."""
|
|
params = {"account_id": account_id, "before": int(before), "limit": int(limit)}
|
|
for _token, result in self._each(
|
|
"GET", f"/chats/{chat_id}/history", params=params
|
|
):
|
|
if result and result.get("messages"):
|
|
return result
|
|
return None
|
|
|
|
def avatars(self, account_id, chat_id):
|
|
"""Chat photos logged for a chat, newest first, every master's."""
|
|
out = []
|
|
for _token, result in self._each(
|
|
"GET", f"/chats/{chat_id}/avatars", params={"account_id": account_id}
|
|
):
|
|
for avatar in (result or {}).get("avatars") or []:
|
|
avatar["master_tg_id"] = (result or {}).get("master_tg_id")
|
|
out.append(avatar)
|
|
out.sort(key=lambda a: a.get("date") or "", reverse=True)
|
|
return out
|