Files
messageLogger/src/api.py
T
lyuksovannyyandClaude Opus 5.5 dba0c4f833
Publish plugin / publish (push) Successful in 4s
Message Logger: restore deep in long chats, media through the master, photo fixes
- 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>
2026-10-11 01:12:23 +02:00

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