Message Logger: show logged edits and deletions from telegram-panel [no bump]
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:
lyuksovannyyandClaude Opus 5.5 committed 2026-10-08 23:55:23 +02:00
1 parent 80f1d5e55c
commit 9b2f26a845
26 files changed
+2928 -112

No files matched your search

+339
View File
@@ -0,0 +1,339 @@
"""What the plugin knows per message: server state (edits, deletion, copy
pointers) and deletions this client saw itself.
Both are cached in SQLite and loaded per chat on first use: a chat opened
again shows its marks at once, before (or without) the server, and a
message the plugin kept on screen still looks deleted after a restart.
Per chat the range of message ids already checked with the server (and
when) is kept too (`coverage`): see hooks/chat.py for when it is extended,
renewed or expired. An expired chat keeps its cache (shown meanwhile) until
the next successful check replaces it. Independently, cached answers older
than the configured number of days are removed, checked once a day
(`purge_old`).
"""
import json
import os
import sqlite3
import threading
import time
import diag
class State:
__slots__ = (
"edit_count",
"deleted",
"logged",
"history",
"source_date",
"sender_id",
"raw",
)
def __init__(self):
self.edit_count = 0
self.deleted = False
self.logged = None # {"chat_id", "message_id", "topic_id"}
self.history = None
self.source_date = None # ISO string
self.sender_id = None
self.raw = None # the server's answer for it
@classmethod
def from_server(cls, data):
state = cls()
state.edit_count = int(data.get("edit_count") or 0)
state.deleted = data.get("deleted_at") is not None
state.logged = data.get("logged")
state.history = data.get("history")
state.source_date = data.get("source_date")
state.sender_id = data.get("sender_id")
state.raw = data
return state
_lock = threading.RLock()
# (account, dialog_id) -> {message_id: State}
_states = {}
# (account, dialog_id) -> set of message ids deleted while we watched
_local = {}
_loaded = set()
# (account, message_id) -> monotonic time the user deleted it here
_own = {}
# (account, dialog_id) -> (low, high, checked_at)
_coverage = {}
_purged_on = None # local date of the last age purge
_db = None
_OWN_TTL = 120
def _conn():
global _db
if _db is None:
from file_utils import get_files_dir
directory = os.path.join(get_files_dir(), "message_logger")
os.makedirs(directory, exist_ok=True)
_db = sqlite3.connect(
os.path.join(directory, "state.db"), check_same_thread=False
)
_db.execute(
"CREATE TABLE IF NOT EXISTS deleted ("
"account INTEGER, dialog_id INTEGER, message_id INTEGER, at REAL, "
"PRIMARY KEY (account, dialog_id, message_id))"
)
_db.execute(
"CREATE TABLE IF NOT EXISTS states ("
"account INTEGER, dialog_id INTEGER, message_id INTEGER, data TEXT, "
"at REAL, PRIMARY KEY (account, dialog_id, message_id))"
)
_db.execute("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)")
_db.execute(
"CREATE TABLE IF NOT EXISTS coverage ("
"account INTEGER, dialog_id INTEGER, low INTEGER, high INTEGER, at REAL, "
"PRIMARY KEY (account, dialog_id))"
)
_db.commit()
return _db
def _local_ids(account, dialog_id):
"""Local deletions of a chat; its cached states are loaded alongside."""
key = (account, dialog_id)
if key not in _loaded:
_loaded.add(key)
try:
db = _conn()
rows = db.execute(
"SELECT message_id FROM deleted WHERE account=? AND dialog_id=?",
key,
).fetchall()
_local.setdefault(key, set()).update(r[0] for r in rows)
states = _states.setdefault(key, {})
for message_id, data in db.execute(
"SELECT message_id, data FROM states WHERE account=? AND dialog_id=?",
key,
).fetchall():
states.setdefault(message_id, State.from_server(json.loads(data)))
row = db.execute(
"SELECT low, high, at FROM coverage WHERE account=? AND dialog_id=?",
key,
).fetchone()
if row is not None:
_coverage.setdefault(key, tuple(row))
except Exception as exc:
diag.error("store.load", exc)
return _local.setdefault(key, set())
def get(account, dialog_id, message_id):
with _lock:
local = _local_ids(account, dialog_id) # loads the chat's cache
state = _states.get((account, dialog_id), {}).get(message_id)
if message_id in local:
if state is None:
state = State()
_states.setdefault((account, dialog_id), {})[message_id] = state
state.deleted = True
return state
def is_deleted(account, dialog_id, message_id):
state = get(account, dialog_id, message_id)
return state is not None and state.deleted
def is_edited(account, dialog_id, message_id):
state = get(account, dialog_id, message_id)
return state is not None and state.edit_count > 0
def put_server_states(account, dialog_id, items):
"""Server state of messages (sync, lookups), kept and cached."""
if not items:
return
with _lock:
_local_ids(account, dialog_id) # load the chat's cache first
states = _states.setdefault((account, dialog_id), {})
for data in items:
states[int(data["message_id"])] = State.from_server(data)
now = time.time()
try:
db = _conn()
db.executemany(
"INSERT OR REPLACE INTO states VALUES (?, ?, ?, ?, ?)",
[
(account, dialog_id, int(d["message_id"]), json.dumps(d), now)
for d in items
],
)
db.commit()
except Exception as exc:
diag.error("store.put_server_states", exc)
def cached_deleted(account, dialog_id, low, high):
"""Cached server answers of deleted messages with a logged copy in
[low, high] (what a restore needs, without asking the server again)."""
with _lock:
_local_ids(account, dialog_id)
return [
state.raw
for message_id, state in _states.get((account, dialog_id), {}).items()
if low <= message_id <= high
and state.raw is not None
and state.raw.get("deleted_at")
and state.raw.get("logged")
]
def coverage(account, dialog_id):
"""(low, high, checked_at) of the ids already checked, else None."""
with _lock:
_local_ids(account, dialog_id)
return _coverage.get((account, dialog_id))
def set_coverage(account, dialog_id, low, high, at):
with _lock:
_coverage[(account, dialog_id)] = (int(low), int(high), float(at))
try:
db = _conn()
db.execute(
"INSERT OR REPLACE INTO coverage VALUES (?, ?, ?, ?, ?)",
(account, dialog_id, int(low), int(high), float(at)),
)
db.commit()
except Exception as exc:
diag.error("store.set_coverage", exc)
def expire_coverage(account, dialog_id):
"""An edit/delete happened in the chat: check it again on next look."""
current = coverage(account, dialog_id)
if current is not None:
set_coverage(account, dialog_id, current[0], current[1], 0.0)
def replace_window(account, dialog_id, low, high, items, capped, full=False):
"""A sync's answer for messages [low, high]: cached marks it no longer
reports there are dropped (and returned) — all of the chat's when `full`
(a fresh check replaces the old cache). A capped answer (the newest marks
only) is complete from its oldest mark up."""
if capped and items:
low = max(low, min(int(d["message_id"]) for d in items))
keep = {int(d["message_id"]) for d in items}
with _lock:
_local_ids(account, dialog_id)
states = _states.setdefault((account, dialog_id), {})
stale = [
i for i in states if (full or low <= i <= high) and i not in keep
]
for i in stale:
del states[i]
try:
db = _conn()
db.executemany(
"DELETE FROM states WHERE account=? AND dialog_id=? AND message_id=?",
[(account, dialog_id, i) for i in stale],
)
db.commit()
except Exception as exc:
diag.error("store.replace_window", exc)
put_server_states(account, dialog_id, items)
return stale
def mark_deleted(account, dialog_id, message_ids):
"""Deletions this client saw (kept on screen or confirmed missing)."""
ids = [int(i) for i in message_ids]
with _lock:
_local_ids(account, dialog_id).update(ids)
now = time.time()
try:
db = _conn()
db.executemany(
"INSERT OR IGNORE INTO deleted VALUES (?, ?, ?, ?)",
[(account, dialog_id, i, now) for i in ids],
)
db.commit()
except Exception as exc:
diag.error("store.mark_deleted", exc)
def forget_deleted(account, dialog_id, message_ids):
"""The user deleted kept messages themselves: stop tracking them."""
ids = [int(i) for i in message_ids]
with _lock:
_local_ids(account, dialog_id).difference_update(ids)
states = _states.get((account, dialog_id), {})
for i in ids:
states.pop(i, None)
try:
db = _conn()
for table in ("deleted", "states"):
db.executemany(
f"DELETE FROM {table} WHERE account=? AND dialog_id=? AND message_id=?",
[(account, dialog_id, i) for i in ids],
)
db.commit()
except Exception as exc:
diag.error("store.forget_deleted", exc)
def note_own_deletion(account, message_ids):
now = time.monotonic()
with _lock:
for key in [k for k, at in _own.items() if now - at > _OWN_TTL]:
del _own[key]
for i in message_ids:
_own[(account, int(i))] = now
def is_own_deletion(account, message_id):
with _lock:
return (account, int(message_id)) in _own
def purge_old(days, force=False):
"""Removes cached server answers (marks, checked ranges) older than
`days` (None: never), at most once per local calendar day unless
`force`. Local deletion records are kept: they are what this client saw,
not a copy of the server."""
import datetime
global _purged_on
today = datetime.date.today().isoformat()
if not force and _purged_on == today:
return 0
with _lock:
try:
db = _conn()
row = db.execute("SELECT value FROM meta WHERE key='purged_on'").fetchone()
if not force and row is not None and row[0] == today:
_purged_on = today
return 0
removed = 0
if days is not None:
cutoff = time.time() - days * 86400
removed = db.execute("DELETE FROM states WHERE at < ?", (cutoff,)).rowcount
db.execute("DELETE FROM coverage WHERE at < ?", (cutoff,))
db.execute(
"INSERT OR REPLACE INTO meta VALUES ('purged_on', ?)", (today,)
)
db.commit()
_purged_on = today
except Exception as exc:
diag.error("store.purge_old", exc)
return 0
if removed:
# Reload chats from the database on next use.
_states.clear()
_coverage.clear()
_loaded.clear()
_local.clear()
diag.log(f"cache purge (older than {days} days): {removed} marks removed")
return removed