Message Logger: hidden messages on the server, removed chats, old chat photos
Publish plugin / publish (push) Successful in 6s

- 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>
This commit is contained in:
lyuksovannyyandClaude Opus 5.5 committed 2026-10-10 23:12:33 +02:00
1 parent 0176f7099d
commit 362b358d8d
15 files changed
+1821 -125

No files matched your search

+162 -28
View File
@@ -1,5 +1,5 @@
"""What the plugin knows per message: server state (edits, deletion, copy
pointers) and deletions this client saw itself.
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
@@ -11,6 +11,9 @@ 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
@@ -30,6 +33,7 @@ class State:
"history",
"source_date",
"sender_id",
"hidden",
"raw",
)
@@ -40,6 +44,7 @@ class State:
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
@@ -51,6 +56,7 @@ class State:
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
@@ -92,10 +98,19 @@ def _conn():
)
_db.execute("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)")
_db.execute(
"CREATE TABLE IF NOT EXISTS hidden ("
"account INTEGER, dialog_id INTEGER, message_id INTEGER, at REAL, "
"PRIMARY KEY (account, dialog_id, message_id))"
"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, "
@@ -180,17 +195,17 @@ def put_server_states(account, dialog_id, items):
diag.error("store.put_server_states", exc)
def cached_deleted(account, dialog_id, low, high):
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)."""
[low, high] (what a restore needs, without asking the server again);
hidden ones only when `hidden`."""
with _lock:
_local_ids(account, dialog_id)
hidden = _hidden_ids(account, dialog_id)
return [
state.raw
for message_id, state in _states.get((account, dialog_id), {}).items()
if low <= message_id <= high
and message_id not in hidden
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")
@@ -323,45 +338,164 @@ def reset(account=None, dialog_id=None):
return removed
_hidden = {} # (account, dialog_id) -> set of ids the user removed from view
_pending = None # (account, dialog_id, message_id) -> hidden, not yet on the server
_revealed = set() # (account, dialog_id) showing their hidden messages
def _hidden_ids(account, dialog_id):
key = (account, dialog_id)
if key not in _hidden:
found = set()
def _pending_marks():
global _pending
if _pending is None:
found = {}
try:
rows = _conn().execute(
"SELECT message_id FROM hidden WHERE account=? AND dialog_id=?", key
).fetchall()
found = {r[0] for r in rows}
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", exc)
_hidden[key] = found
return _hidden[key]
diag.error("store.hidden_pending", exc)
_pending = found
return _pending
def hide(account, dialog_id, message_ids):
"""Messages the user deleted (or removed from view): never shown or
restored here again."""
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:
_hidden_ids(account, dialog_id).update(ids)
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 IGNORE INTO hidden VALUES (?, ?, ?, ?)",
[(account, dialog_id, i, now) for i in ids],
"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.hide", 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:
return int(message_id) in _hidden_ids(account, dialog_id)
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):