generated from exteraGramPlugins/pluginBase
Message Logger: show logged edits and deletions from telegram-panel [no bump]
Publish plugin / publish (push) Successful in 4s
Publish plugin / publish (push) Successful in 4s
Keeps deleted messages (75% opacity, red icon-pack trash icon), restores logged messages deleted while away, reports deletions, and shows edit history as a mock chat (Edit history in the message menu, Open topic). Message links and history open as any signed-in account with access. Per-master API tokens; chat state synced per checked range with an hour's validity, cached in SQLite with a configurable lifetime. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
80f1d5e55c
commit
9b2f26a845
26 files changed
+2928
-112
No files matched your search
+141
@@ -0,0 +1,141 @@
|
||||
"""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:
|
||||
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 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 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
|
||||
Reference in new issue
Block a user