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
+339
@@ -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
|
||||
Reference in new issue
Block a user