generated from exteraGramPlugins/pluginBase
Message Logger: hidden messages on the server, removed chats, old chat photos
Publish plugin / publish (push) Successful in 6s
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:
1 parent
0176f7099d
commit
362b358d8d
15 files changed
+1821
-125
No files matched your search
+162
-28
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user