Files
messageLogger/src/store.py
T
lyuksovannyyandClaude Opus 5.5 362b358d8d
Publish plugin / publish (push) Successful in 6s
Message Logger: hidden messages on the server, removed chats, old chat photos
- Hidden deleted messages are marked on the server (local marks migrated);
  "Reveal hidden messages" in the chat menu, Unhide in the message menu and
  the selection bar.
- Chats an account lost are reported, listed among its chats again and
  opened read-only with their logged messages, all deleted.
- Logged chat photos: a removed chat's avatar, and in a chat's photos the
  ones it no longer has, translucent with a trash icon in the viewer's
  previews.
- Fix: the deleted-post comments thread is answered locally again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:12:33 +02:00

554 lines
19 KiB
Python

"""What the plugin knows per message: server state (edits, deletion, copy
pointers, hidden marks) 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`).
Messages removed from view (hidden) are marked on the server, so every
client skips them; a change waits in `hidden_pending` until the server took
it (and overrides the server's mark meanwhile).
"""
import json
import os
import sqlite3
import threading
import time
import diag
class State:
__slots__ = (
"edit_count",
"deleted",
"logged",
"history",
"source_date",
"sender_id",
"hidden",
"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.hidden = False # removed from view (kept on the server)
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.hidden = data.get("hidden_at") is not None
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 hidden_pending ("
"account INTEGER, dialog_id INTEGER, message_id INTEGER, hidden INTEGER, "
"at REAL, PRIMARY KEY (account, dialog_id, message_id))"
)
# Before hidden marks were kept on the server: sent there once.
if _db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='hidden'"
).fetchone():
_db.execute(
"INSERT OR IGNORE INTO hidden_pending "
"SELECT account, dialog_id, message_id, 1, at FROM hidden"
)
_db.execute("DROP TABLE hidden")
_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, hidden=False):
"""Cached server answers of deleted messages with a logged copy in
[low, high] (what a restore needs, without asking the server again);
hidden ones only when `hidden`."""
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 (hidden or not is_hidden(account, dialog_id, message_id))
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 reset(account=None, dialog_id=None):
"""Forgets cached marks, local deletion records and checked ranges of a
chat (or of every chat when `dialog_id` is None); the next look asks the
server again. Messages the user removed from view stay hidden.
Returns the number of marks forgotten."""
where, args = "", ()
if dialog_id is not None:
where, args = " WHERE account=? AND dialog_id=?", (int(account), int(dialog_id))
with _lock:
removed = 0
try:
db = _conn()
for table in ("states", "deleted", "coverage"):
count = db.execute(f"DELETE FROM {table}{where}", args).rowcount
if table != "coverage":
removed += count
db.commit()
except Exception as exc:
diag.error("store.reset", exc)
if dialog_id is None:
_states.clear()
_local.clear()
_coverage.clear()
_loaded.clear()
else:
key = (int(account), int(dialog_id))
for cache in (_states, _local, _coverage):
cache.pop(key, None)
_loaded.discard(key)
return removed
_pending = None # (account, dialog_id, message_id) -> hidden, not yet on the server
_revealed = set() # (account, dialog_id) showing their hidden messages
def _pending_marks():
global _pending
if _pending is None:
found = {}
try:
for account, dialog_id, message_id, hidden in _conn().execute(
"SELECT account, dialog_id, message_id, hidden FROM hidden_pending"
).fetchall():
found[(account, dialog_id, message_id)] = bool(hidden)
except Exception as exc:
diag.error("store.hidden_pending", exc)
_pending = found
return _pending
def set_hidden(account, dialog_id, message_ids, hidden=True):
"""Removes messages from view (or shows them again) here at once; the
change waits for the server in `hidden_pending` (see `pending_hidden`)."""
ids = [int(i) for i in message_ids]
with _lock:
pending = _pending_marks()
states = _states.get((account, dialog_id), {})
for i in ids:
pending[(account, dialog_id, i)] = bool(hidden)
state = states.get(i)
if state is not None:
state.hidden = bool(hidden)
try:
db = _conn()
now = time.time()
db.executemany(
"INSERT OR REPLACE INTO hidden_pending VALUES (?, ?, ?, ?, ?)",
[(account, dialog_id, i, int(bool(hidden)), now) for i in ids],
)
db.commit()
except Exception as exc:
diag.error("store.set_hidden", exc)
def pending_hidden():
"""Hidden marks not on the server yet: {(account, dialog_id, hidden): [ids]}."""
with _lock:
out = {}
for (account, dialog_id, message_id), hidden in _pending_marks().items():
out.setdefault((account, dialog_id, hidden), []).append(message_id)
return out
def hidden_sent(account, dialog_id, message_ids, hidden):
"""The server took these marks: they are its state now."""
ids = [int(i) for i in message_ids]
with _lock:
pending = _pending_marks()
sent = [i for i in ids if pending.get((account, dialog_id, i)) == bool(hidden)]
for i in sent:
del pending[(account, dialog_id, i)]
states = _states.get((account, dialog_id), {})
changed = []
for i in sent:
state = states.get(i)
if state is not None and state.raw is not None:
state.raw["hidden_at"] = (
state.raw.get("hidden_at") or _now_iso() if hidden else None
)
state.hidden = bool(hidden)
changed.append((account, dialog_id, i, json.dumps(state.raw)))
try:
db = _conn()
db.executemany(
"DELETE FROM hidden_pending WHERE account=? AND dialog_id=? AND message_id=?",
[(account, dialog_id, i) for i in sent],
)
db.executemany(
"UPDATE states SET data=? WHERE account=? AND dialog_id=? AND message_id=?",
[(data, a, d, i) for a, d, i, data in changed],
)
db.commit()
except Exception as exc:
diag.error("store.hidden_sent", exc)
def _now_iso():
import datetime
return datetime.datetime.now(datetime.timezone.utc).isoformat()
def is_hidden(account, dialog_id, message_id):
with _lock:
pending = _pending_marks().get((account, dialog_id, int(message_id)))
if pending is not None:
return pending
state = _states.get((account, dialog_id), {}).get(int(message_id))
return state is not None and state.hidden
def hidden_deleted(account, dialog_id):
"""Ids of the chat's deleted messages that are hidden (cached)."""
with _lock:
_local_ids(account, dialog_id)
return [
i
for i, state in _states.get((account, dialog_id), {}).items()
if state.deleted and is_hidden(account, dialog_id, i)
]
def reveal(account, dialog_id, on):
with _lock:
if on:
_revealed.add((account, dialog_id))
else:
_revealed.discard((account, dialog_id))
def is_revealed(account, dialog_id):
return (account, dialog_id) in _revealed
_removed_dialogs = set() # (account, dialog_id) of removed chats listed
def set_removed_dialog(account, dialog_id, listed):
"""A removed chat listed among the chats (hooks/removed_chats.py): its
state comes with its history, the chat is not synced."""
with _lock:
if listed:
_removed_dialogs.add((int(account), int(dialog_id)))
else:
_removed_dialogs.discard((int(account), int(dialog_id)))
def is_removed_dialog(account, dialog_id):
return (int(account), int(dialog_id)) in _removed_dialogs
def get_meta(key):
try:
with _lock:
row = _conn().execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone()
return row[0] if row else None
except Exception as exc:
diag.error("store.get_meta", exc)
return None
def put_meta(key, value):
try:
with _lock:
db = _conn()
db.execute("INSERT OR REPLACE INTO meta VALUES (?, ?)", (key, value))
db.commit()
except Exception as exc:
diag.error("store.put_meta", 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