From 362b358d8d2b4ecba65d398c22e089db10a4f951 Mon Sep 17 00:00:00 2001 From: lyuksovannyy Date: Sat, 10 Oct 2026 23:12:33 +0200 Subject: [PATCH] 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 --- README.md | 18 +- plugin.json | 2 +- src/api.py | 67 +++++ src/config.py | 2 + src/hooks/avatars.py | 356 ++++++++++++++++++++++++++ src/hooks/chat.py | 144 +++++++---- src/hooks/delete_dialog.py | 29 ++- src/hooks/hidden.py | 202 +++++++++++++++ src/hooks/menu.py | 50 ++-- src/hooks/removed_chats.py | 505 +++++++++++++++++++++++++++++++++++++ src/main.py | 52 +++- src/store.py | 190 ++++++++++++-- src/views/comments.py | 43 +++- src/views/removed_chat.py | 266 +++++++++++++++++++ tools/verify_bundle.py | 20 +- 15 files changed, 1821 insertions(+), 125 deletions(-) create mode 100644 src/hooks/avatars.py create mode 100644 src/hooks/hidden.py create mode 100644 src/hooks/removed_chats.py create mode 100644 src/views/removed_chat.py diff --git a/README.md b/README.md index 2cf0394..7ac0ccb 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,19 @@ the device, so a chat opened again, even offline, shows them at once. was closed are confirmed with Telegram and restored from the logger copy. - **Keep your own deletions.** The delete dialog gets a *Keep message for myself* checkbox: ticked, the deletion goes to Telegram but the message stays on this device, marked deleted. - Unticked (the default), it is deleted as usual and never restored from the logger here. - Deleting a message that is already deleted shows a plain dialog that removes it from view - on this device only. + Unticked (the default), it is deleted as usual and hidden, so it isn't restored from the + logger. Deleting a message that is already deleted shows a plain dialog that hides it. +- **Hidden messages.** Hidden deleted messages are marked on the server, so every client + skips them. *Reveal hidden messages* in the chat's menu (Plugins) brings them back until + tapped again; a revealed one has *Unhide* in its menu, and selecting several shows an + Unhide button in the selection bar. +- **Removed chats.** Chats an account lost (left, kicked, deleted by you or the other side) + stay in the chat list with their logged messages, read-only and all marked deleted, until + the chat is back. Chats you delete yourself aren't listed; deleting a listed one hides it. + *Show removed chats* in the settings turns this off. +- **Old chat photos.** Chat photo changes are posted into the chat's log topic. A removed chat + shows its latest logged photo, and a chat's photos also list the logged ones it no longer + has: translucent, with a trash icon, in the photo viewer's previews. - **Edit history.** *Edit history* in the message menu (right above Forward, for edited messages) opens every version as a mock chat, also for the logger groups' own posts, with an *Open topic* button for the history topic in the activity group. @@ -36,7 +46,7 @@ the device, so a chat opened again, even offline, shows them at once. going back returns to your account. - **Reset marks.** *Reset deleted/edited marks* in the chat's menu (Plugins) forgets that chat's cached marks and checks it with the server again; the same row in the plugin - settings does it for every chat. Messages you removed from view stay hidden. + settings does it for every chat. Hidden messages stay hidden. - **Cache lifetime.** Cached answers are kept for 1, 7, 30, 90 or 365 days, or forever (30 by default). diff --git a/plugin.json b/plugin.json index 2173ba2..d9334b5 100644 --- a/plugin.json +++ b/plugin.json @@ -3,7 +3,7 @@ "name": "Message Logger", "description": "Shows deleted and edited messages and their edit history from your telegram-panel message logger.", "author": "@lyuksovannyy", - "version": "0.1.2", + "version": "0.1.3", "icon": "exteraGramPluginIcons/4", "app_version": ">=12.5.1", "sdk_version": ">=1.4.4.3", diff --git a/src/api.py b/src/api.py index d21e704..8eb46e8 100644 --- a/src/api.py +++ b/src/api.py @@ -152,3 +152,70 @@ class Api: for _token, result in self._each("POST", "/deletions", json=body): marked += int((result or {}).get("marked", 0)) return marked + + def hidden(self, account_id, chat_id, message_ids, hidden): + """Hide messages from the chat for every client (or show them again); + True once every master took it.""" + ids = sorted(set(int(i) for i in message_ids)) + for start in range(0, len(ids), 500): + body = { + "account_id": account_id, + "chat_id": chat_id, + "message_ids": ids[start : start + 500], + "hidden": bool(hidden), + } + for _token, _result in self._each("POST", "/hidden", json=body): + pass + return True + + # removed chats + + def chat_removed(self, account_id, chat_id, own): + body = {"account_id": account_id, "chat_id": chat_id, "own": bool(own)} + known = False + for _token, result in self._each("POST", "/chats/removed", json=body): + known = known or bool((result or {}).get("known")) + return known + + def chat_hidden(self, account_id, chat_id, hidden): + body = {"account_id": account_id, "chat_id": chat_id, "hidden": bool(hidden)} + for _token, _result in self._each("POST", "/chats/hidden", json=body): + pass + + def removed_chats(self, account_id): + """The account's removed chats, each master's answer merged (the + first one listing a chat wins), or None when no master answered.""" + chats, any_result = {}, False + for _token, result in self._each( + "GET", "/chats/removed", params={"account_id": account_id} + ): + if result is None: + continue + any_result = True + for chat in result.get("chats") or []: + chat["master_tg_id"] = result.get("master_tg_id") + chats.setdefault(int(chat["chat_id"]), chat) + return list(chats.values()) if any_result else None + + def history(self, account_id, chat_id, before=0, limit=100): + """Logged messages of a chat older than `before`, newest first: the + first master that has any.""" + params = {"account_id": account_id, "before": int(before), "limit": int(limit)} + for _token, result in self._each( + "GET", f"/chats/{chat_id}/history", params=params + ): + if result and result.get("messages"): + return result + return None + + def avatars(self, account_id, chat_id): + """Chat photos logged for a chat, newest first, every master's.""" + out = [] + for _token, result in self._each( + "GET", f"/chats/{chat_id}/avatars", params={"account_id": account_id} + ): + for avatar in (result or {}).get("avatars") or []: + avatar["master_tg_id"] = (result or {}).get("master_tg_id") + out.append(avatar) + out.sort(key=lambda a: a.get("date") or "", reverse=True) + return out diff --git a/src/config.py b/src/config.py index 2b280d6..02b5e5e 100644 --- a/src/config.py +++ b/src/config.py @@ -7,6 +7,7 @@ RESTORE_MISSING = "restore_missing" REPORT_DELETIONS = "report_deletions" DELETED_ALPHA = "deleted_alpha" CACHE_DAYS = "cache_days" +SHOW_REMOVED_CHATS = "show_removed_chats" DEFAULTS = { SERVER_URL: "", @@ -16,6 +17,7 @@ DEFAULTS = { REPORT_DELETIONS: True, DELETED_ALPHA: 0, # index into ALPHA_CHOICES CACHE_DAYS: 2, # index into CACHE_DAYS_CHOICES + SHOW_REMOVED_CHATS: True, } # Opacity of deleted messages: well visible, marked by the trash icon. diff --git a/src/hooks/avatars.py b/src/hooks/avatars.py new file mode 100644 index 0000000..847c808 --- /dev/null +++ b/src/hooks/avatars.py @@ -0,0 +1,356 @@ +"""Chat photos the logger posted (service posts in the chat's log topic, see +telegram-panel `avatars`), read from those posts. + +- A removed chat shows its latest logged photo (hooks/removed_chats.py). +- A chat's photos (MessagesController.getDialogPhotos: DialogPhotos `photos`, + shown by the profile's gallery and the photo viewer, both redrawn on + NotificationCenter.dialogPhotosUpdate) get the logged photos the chat no + longer has, after its own. The photo viewer's thumbnails of those + (GroupedPhotosListView.onDraw) are translucent like deleted messages, with + a red trash icon. + +A post is read and its photo downloaded through an account that can read +the log group; the app's file cache is shared by its accounts, so the photo +then shows for any of them. +""" + +import threading +import time + +from base_plugin import MethodHook + +import accounts +import diag +import peers +from api import ApiError +from jutil import field, hook_methods, in_background, java_list, on_ui +from views.history import _epoch + +CONTROLLER = "org.telegram.messenger.MessagesController" +THUMBS = "org.telegram.ui.Components.GroupedPhotosListView" +NOTIFICATIONS = "org.telegram.messenger.NotificationCenter" +_DOWNLOAD_SECONDS = 20 +# A logged photo whose id isn't Telegram's (logged by a bot) is the chat's +# own when one of the chat's photos was set this close to its post. +_SAME_PHOTO_SECONDS = 10 + + +def _photo_of(copy): + """The photo of a logger post (a photo message, or the first photo of a + rich message).""" + if copy is None: + return None + photo = getattr(getattr(copy, "media", None), "photo", None) + if photo is not None and getattr(photo, "sizes", None) is not None: + return photo + rich = getattr(copy, "rich_message", None) + photos = getattr(rich, "photos", None) if rich is not None else None + if photos is not None and photos.size(): + return photos.get(0) + return None + + +def logged_photo(account, avatar): + """(photo, reader slot, post) of a logged chat photo, else None (blocking).""" + from views.removed_chat import _fetch + + logged = avatar.get("logged") or {} + if not avatar.get("has_photo") or not logged.get("chat_id"): + return None + channel_id = peers.channel_id_from_marked(logged["chat_id"]) + readers = accounts.with_access( + -channel_id, prefer=(account, accounts.slot_of(avatar.get("master_tg_id"))) + ) + if not readers: + return None + message_id = int(logged["message_id"]) + copy = _fetch(readers[0], -channel_id, [message_id]).get(message_id) + photo = _photo_of(copy) + return (photo, readers[0], copy) if photo is not None else None + + +def _size(photo, side): + """The smallest downloadable size at least `side` px wide (else the + largest): inline thumbnails have no file.""" + sizes = [ + size + for size in java_list(photo.sizes) + if getattr(size, "location", None) is not None and int(size.w or 0) > 0 + ] + if not sizes: + return None + bigger = [size for size in sizes if max(int(size.w), int(size.h)) >= side] + if bigger: + return min(bigger, key=lambda size: int(size.w)) + return max(sizes, key=lambda size: int(size.w)) + + +def download(reader, photo, copy, side): + """Downloads the photo's size closest to `side` px through `reader` into + the shared cache; its path, or None (blocking).""" + from java import jint + from org.telegram.messenger import FileLoader, ImageLocation, MessageObject + + size = _size(photo, side) + if size is None: + return None + loader = FileLoader.getInstance(reader) + path = loader.getPathToAttach(size, True) + if not path.exists(): + parent = MessageObject(reader, copy, False, False) + loader.loadFile( + ImageLocation.getForPhoto(size, photo), + parent, + "jpg", + jint(FileLoader.PRIORITY_HIGH), + jint(1), + ) + deadline = time.monotonic() + _DOWNLOAD_SECONDS + while not path.exists() and time.monotonic() < deadline: + time.sleep(0.25) + return str(path.getAbsolutePath()) if path.exists() else None + + +class Avatars: + def __init__(self, plugin, api): + self.plugin = plugin + self.api = api + self.handles = [] + self._lock = threading.Lock() + self._recorded = {} # (account, dialog) -> [(photo, original id, date)] + self._ids = set() # ids of those photos: drawn with the trash icon + self._loading = set() + self._updates = {} # (account, dialog) -> args of its last dialogPhotosUpdate + self._icon = None + + def install(self): + avatars = self + + class AfterPhotos(MethodHook): + def after_hooked_method(self, param): + try: + avatars.photos_wanted(param.thisObject, param.args[0], param.getResult()) + except Exception as exc: + diag.error("avatars.photos", exc) + + class AfterThumbs(MethodHook): + def after_hooked_method(self, param): + try: + avatars.draw_thumbs(param.thisObject, param.args[0]) + except Exception as exc: + diag.error("avatars.thumbs", exc) + + class BeforeNotify(MethodHook): + def before_hooked_method(self, param): + try: + avatars.photos_updated(param) + except Exception as exc: + diag.error("avatars.update", exc) + + self.handles += hook_methods(self.plugin, CONTROLLER, "getDialogPhotos", AfterPhotos()) + self.handles += hook_methods(self.plugin, THUMBS, "onDraw", AfterThumbs()) + self.handles += hook_methods( + self.plugin, NOTIFICATIONS, "postNotificationName", BeforeNotify() + ) + + def uninstall(self): + for handle in self.handles: + try: + self.plugin.unhook_method(handle) + except Exception: + pass + self.handles = [] + + # loading (background) + + def photos_wanted(self, controller, dialog_id, holder): + """getDialogPhotos: the chat's logged photos are loaded once, then + put into its photos (again after the app reloaded them).""" + if not self.api.configured or holder is None: + return + account = int(field(controller, "currentAccount") or 0) + key = (account, int(dialog_id)) + with self._lock: + if key in self._recorded: + self._add_to(*key, holder) + return + if key in self._loading: + return + self._loading.add(key) + in_background(lambda: self._load(*key)) + + def _load(self, account, dialog_id): + try: + chat_id = peers.marked_chat_id(account, dialog_id) + if chat_id is None: + return + try: + logged = self.api.avatars(peers.own_user_id(account), chat_id) + except ApiError as exc: + diag.log(f"avatars {chat_id}: {exc}") + return + recorded = [] + for avatar in logged: + found = logged_photo(account, avatar) + if found is None: + continue + photo, reader, copy = found + download(reader, photo, copy, 160) + if download(reader, photo, copy, 800) is None: + continue + original = avatar.get("photo_id") + recorded.append( + (photo, int(original) if original else None, _epoch(avatar.get("date"))) + ) + with self._lock: + self._recorded[(account, dialog_id)] = recorded + self._ids.update(int(p.id) for p, _o, _d in recorded) + if recorded: + diag.log(f"avatars {chat_id}: {len(recorded)} logged photos") + on_ui(lambda: self._append(account, dialog_id)) + finally: + with self._lock: + self._loading.discard((account, dialog_id)) + + # the gallery (UI thread) + + def _gone(self, photos, original, date): + """Whether a logged photo is not one the chat still has.""" + for photo in photos: + if photo is None or int(photo.id) in self._ids: + continue + if original is not None and int(photo.id) == original: + return False + if original is None and abs(int(photo.date) - date) <= _SAME_PHOTO_SECONDS: + return False + return True + + def _add_to(self, account, dialog_id, holder): + """Appends the logged photos the chat no longer has; whether any.""" + photos = field(holder, "photos") + if photos is None: + return False + current = java_list(photos) + shown = {int(p.id) for p in current if p is not None} + added = False + for photo, original, date in self._recorded.get((account, dialog_id), []): + if int(photo.id) in shown or not self._gone(current, original, date): + continue + photos.add(photo) + shown.add(int(photo.id)) + added = True + return added + + def _append(self, account, dialog_id): + from org.telegram.messenger import MessagesController, NotificationCenter + + holder = MessagesController.getInstance(account).getDialogPhotos(dialog_id) + if holder is None or not self._add_to(account, dialog_id, holder): + return + args = self._updates.get((account, dialog_id)) + if args is None: + from java import jarray + from java.lang import Object + + args = jarray(Object)([holder]) + NotificationCenter.getInstance(account).postNotificationName( + NotificationCenter.dialogPhotosUpdate, args + ) + + def photos_updated(self, param): + """dialogPhotosUpdate: the logged photos are put back into the + (reloaded) list before the gallery redraws.""" + from org.telegram.messenger import NotificationCenter + + if int(param.args[0]) != int(NotificationCenter.dialogPhotosUpdate): + return + extra = param.args[1] + holder = next( + (a for a in (extra or []) if a is not None and hasattr(a, "photos")), None + ) + if holder is None: + return + account = int(field(param.thisObject, "currentAccount") or 0) + dialog_id = int(field(holder, "dialogId") or 0) + self._updates[(account, dialog_id)] = extra + if (account, dialog_id) in self._recorded: + self._add_to(account, dialog_id, holder) + + # the trash icon + + def _logged_at(self, strip, index): + """Whether the strip's item `index` is a logged photo.""" + for name in ("currentObjects", "currentPhotos"): + items = field(strip, name) + if items is None or not 0 <= index < items.size(): + continue + item = items.get(index) + photo_id = getattr(item, "photoId", None) if name == "currentPhotos" else None + if photo_id is None: + photo_id = getattr(item, "id", None) + if photo_id is not None and int(photo_id) in self._ids: + return True + return False + + def _alpha(self): + from config import ALPHA_CHOICES, DEFAULTS, DELETED_ALPHA + + index = int(self.plugin.get_setting(DELETED_ALPHA, DEFAULTS[DELETED_ALPHA]) or 0) + return ALPHA_CHOICES[max(0, min(index, len(ALPHA_CHOICES) - 1))][1] + + def draw_thumbs(self, strip, canvas): + """The photo viewer's thumbnails of logged photos: translucent, with a + small trash icon (GroupedPhotosListView: `imagesToDraw`, each + receiver's param its index).""" + if not self._ids: + return + receivers = field(strip, "imagesToDraw") + if receivers is None: + return + from org.telegram.messenger import AndroidUtilities + + from android.graphics import Paint + from java import jint + + icon, paint = self._trash() + # The strip draws its thumbnails opaque (it sets their alpha itself) + # over the viewer's black: black at the remaining opacity on top + # looks the same as the deleted message opacity. + shade = Paint() + shade.setColor(jint(int((1.0 - self._alpha()) * 255) << 24)) + dp = AndroidUtilities.dp + size = dp(14) + for receiver in java_list(receivers): + if receiver is None or not self._logged_at(strip, int(receiver.getParam())): + continue + canvas.drawRect( + float(receiver.getImageX()), + float(receiver.getImageY()), + float(receiver.getImageX2()), + float(receiver.getImageY2()), + shade, + ) + right = int(receiver.getImageX2()) - dp(2) + bottom = int(receiver.getImageY2()) - dp(2) + paint.setAlpha(0x99) + canvas.drawCircle(right - size / 2.0, bottom - size / 2.0, size * 0.8, paint) + icon.setAlpha(255) + icon.setBounds(right - size, bottom - size, right, bottom) + icon.draw(canvas) + + def _trash(self): + if self._icon is None: + from android.graphics import Paint, PorterDuff, PorterDuffColorFilter + from java import jint + from org.telegram.messenger import R + from org.telegram.ui.ActionBar import Theme + + from hooks.render import _trash_drawable + + icon = _trash_drawable(R.drawable.msg_delete) + red = jint(Theme.getColor(Theme.key_text_RedBold)) + icon.setColorFilter(PorterDuffColorFilter(red, PorterDuff.Mode.SRC_IN)) + paint = Paint(Paint.ANTI_ALIAS_FLAG) + paint.setColor(jint(0x99000000 - (1 << 32))) # ARGB as a signed int + self._icon = (icon, paint) + return self._icon diff --git a/src/hooks/chat.py b/src/hooks/chat.py index 073bf64..207a2cb 100644 --- a/src/hooks/chat.py +++ b/src/hooks/chat.py @@ -167,6 +167,59 @@ def _rich_plain_text(rich): return "\n".join(_rich_text(b.text) for b in blocks) +def build_from_copy(account, dialog_id, message_id, state, copy, strip): + """A deleted message rebuilt from its logged copy (`strip`: the copy + starts with the logger's header) with the server's `state`.""" + from java.util import ArrayList + from org.telegram.messenger import MessageObject, MessagesController + from org.telegram.tgnet import TLRPC + + controller = MessagesController.getInstance(account) + msg = TLRPC.TL_message() + msg.id = message_id + msg.dialog_id = dialog_id + msg.peer_id = controller.getPeer(dialog_id) + sender = state.get("sender_id") + msg.from_id = ( + controller.getPeer(_android_peer_id(sender)) if sender else msg.peer_id + ) + msg.flags |= 256 + msg.date = _epoch(state.get("source_date")) or int(copy.date) + if int(state.get("edit_count") or 0) > 0: + msg.edit_date = _epoch(state.get("edit_date")) or msg.date + msg.flags |= 32768 # edited: the app draws its pencil + text = copy.message or "" + entities = [(int(e.offset), int(e.length), e) for e in java_list(copy.entities)] + if strip: + text, entities = header.strip(text, entities) + msg.message = text + msg.entities = ArrayList() + for offset, length, entity in entities: + entity.offset = offset + entity.length = length + msg.entities.add(entity) + media = getattr(copy, "media", None) + if media is not None and not isinstance(media, TLRPC.TL_messageMediaEmpty): + msg.media = media + msg.flags |= 512 + rich = getattr(copy, "rich_message", None) + if rich is not None: + if strip: + _strip_rich_header(rich) + plain = _rich_plain_text(rich) + if plain: # never a blank bubble: else it stays rich + # Text only: a normal bubble, as the original was. + msg.message = plain + msg.entities = ArrayList() + else: + msg.rich_message = rich + msg.flags2 |= 1 << 12 + own = peers.own_user_id(account) + msg.out = sender is not None and int(sender) == own + msg.unread = False + return MessageObject(account, msg, True, True) + + class ChatSync: def __init__(self, plugin, api): self.plugin = plugin @@ -227,6 +280,8 @@ class ChatSync: if chat_id is None: return key = (account, dialog_id) + if store.is_removed_dialog(account, dialog_id): + return # its history brings its state (hooks/removed_chats.py) self._fragments[key] = fragment if key in self._inflight: return @@ -247,9 +302,10 @@ class ChatSync: # cached answers (they were never in the app's own database). if self.plugin.get_setting(RESTORE_MISSING, True): present = set(ids) + revealed = store.is_revealed(account, dialog_id) gone = [ s - for s in store.cached_deleted(account, dialog_id, low, high) + for s in store.cached_deleted(account, dialog_id, low, high, revealed) if int(s["message_id"]) not in present ] if gone: @@ -355,10 +411,11 @@ class ChatSync: # restoring (background -> UI) def _restore(self, fragment, account, dialog_id, chat_id, me, states): + revealed = store.is_revealed(account, dialog_id) for state in states: message_id = int(state["message_id"]) - if store.is_hidden(account, dialog_id, message_id): - continue # the user removed it from view + if not revealed and store.is_hidden(account, dialog_id, message_id): + continue # removed from view key = (account, dialog_id, message_id) if key in self._restoring: continue @@ -396,56 +453,6 @@ class ChatSync: tg.get_messages(account, -channel_id, [copy_id], done) - def _build(self, account, dialog_id, message_id, state, copy, strip): - from java.util import ArrayList - from org.telegram.messenger import MessageObject, MessagesController - from org.telegram.tgnet import TLRPC - - controller = MessagesController.getInstance(account) - msg = TLRPC.TL_message() - msg.id = message_id - msg.dialog_id = dialog_id - msg.peer_id = controller.getPeer(dialog_id) - sender = state.get("sender_id") - msg.from_id = ( - controller.getPeer(_android_peer_id(sender)) if sender else msg.peer_id - ) - msg.flags |= 256 - msg.date = _epoch(state.get("source_date")) or int(copy.date) - if int(state.get("edit_count") or 0) > 0: - msg.edit_date = _epoch(state.get("edit_date")) or msg.date - msg.flags |= 32768 # edited: the app draws its pencil - text = copy.message or "" - entities = [(int(e.offset), int(e.length), e) for e in java_list(copy.entities)] - if strip: - text, entities = header.strip(text, entities) - msg.message = text - msg.entities = ArrayList() - for offset, length, entity in entities: - entity.offset = offset - entity.length = length - msg.entities.add(entity) - media = getattr(copy, "media", None) - if media is not None and not isinstance(media, TLRPC.TL_messageMediaEmpty): - msg.media = media - msg.flags |= 512 - rich = getattr(copy, "rich_message", None) - if rich is not None: - if strip: - _strip_rich_header(rich) - plain = _rich_plain_text(rich) - if plain: # never a blank bubble: else it stays rich - # Text only: a normal bubble, as the original was. - msg.message = plain - msg.entities = ArrayList() - else: - msg.rich_message = rich - msg.flags2 |= 1 << 12 - own = peers.own_user_id(account) - msg.out = sender is not None and int(sender) == own - msg.unread = False - return MessageObject(account, msg, True, True) - def _insert(self, fragment, account, dialog_id, message_id, state, copy, strip): """Hands the rebuilt message to the chat the way new messages arrive (ChatActivity.processNewMessages), so the app places it and keeps its @@ -461,7 +468,7 @@ class ChatSync: from java import jboolean from java.util import ArrayList - obj = self._build(account, dialog_id, message_id, state, copy, strip) + obj = build_from_copy(account, dialog_id, message_id, state, copy, strip) store.mark_deleted(account, dialog_id, [message_id]) batch = ArrayList() batch.add(obj) @@ -552,6 +559,35 @@ class ChatSync: on_ui(redraw) return removed + def restore_hidden(self, fragment): + """The chat reveals its hidden messages: those deleted in the loaded + range come back from the logger (UI thread). Returns how many.""" + account = int(fragment.getCurrentAccount()) + dialog_id = int(fragment.getDialogId()) + chat_id = peers.marked_chat_id(account, dialog_id) + ids = [ + int(m.getId()) + for m in java_list(field(fragment, "messages")) + if m is not None and int(m.getId()) > 0 + ] + if chat_id is None or not ids: + return 0 + low = 1 if _end_reached(fragment, "endReached") else min(ids) + high = _MAX_ID if _end_reached(fragment, "forwardEndReached") else max(ids) + present = set(ids) + hidden = set(store.hidden_deleted(account, dialog_id)) + gone = [ + s + for s in store.cached_deleted(account, dialog_id, low, high, True) + if int(s["message_id"]) in hidden and int(s["message_id"]) not in present + ] + if gone: + me = peers.own_user_id(account) + in_background( + lambda: self._restore(fragment, account, dialog_id, chat_id, me, gone) + ) + return len(gone) + def drop_from_view(self, account, dialog_id, ids): """Takes messages out of the open chat's list (UI thread).""" fragment = self._open_chat(account, dialog_id) diff --git a/src/hooks/delete_dialog.py b/src/hooks/delete_dialog.py index 30f93f7..9ae03f1 100644 --- a/src/hooks/delete_dialog.py +++ b/src/hooks/delete_dialog.py @@ -5,10 +5,10 @@ filtered out of MessagesStorage.markMessagesAsDeleted and the messagesDeleted notification; the server's echo then counts as someone else's deletion, see hooks/deletes.py). Unticked (default), it is deleted - as usual and remembered as hidden, so it is never restored from the - logger here; + as usual and hidden (on the server, for every client), so it is not + restored from the logger; - already deleted (kept) messages get a plain dialog without checkboxes - whose button removes them from view (locally only) and hides them. + whose button removes them from this device's view and hides them. AlertsCreator.createDeleteMessagesAlert(fragment, user, chat, encryptedChat, chatInfo, mergeDialogId, selectedMessage, selectedMessages[2], group, @@ -53,9 +53,10 @@ def _deleted(message): class DeleteDialog: - def __init__(self, plugin, chats): + def __init__(self, plugin, chats, hidden): self.plugin = plugin self.chats = chats # hooks/chat.ChatSync + self.hidden = hidden # hooks/hidden.Hidden self.handles = [] self._choice = None # [checked] of the open dialog self._keeping = {} # (account, id) -> monotonic time to keep until @@ -169,12 +170,14 @@ class DeleteDialog: count = len(targets) builder = AlertDialogBuilder(fragment.getParentActivity()) - builder.set_title("Remove deleted message" + ("s" if count > 1 else "")) + builder.set_title("Hide deleted message" + ("s" if count > 1 else "")) builder.set_message( ("These messages were" if count > 1 else "This message was") - + " already deleted. Remove " + + " already deleted. Hide " + ("them" if count > 1 else "it") - + " from view on this device?" + + " from this chat? The logger keeps " + + ("them" if count > 1 else "it") + + ": the chat menu reveals hidden messages." ) def remove(_builder, _which): @@ -188,14 +191,15 @@ class DeleteDialog: if hide_dim is not None: hide_dim.run() - builder.set_positive_button("Remove", remove) + builder.set_positive_button("Hide", remove) builder.set_negative_button("Back") builder.make_button_red(AlertDialogBuilder.BUTTON_POSITIVE) builder.set_on_dismiss_listener(dismissed) builder.show() def _remove_from_view(self, targets): - """Removes already deleted messages from this device only.""" + """Hides already deleted messages (on the server) and takes them out + of this device's chat.""" from java import jboolean, jint, jlong from java.lang import Object from java.util import ArrayList @@ -207,8 +211,9 @@ class DeleteDialog: account, dialog_id, message_id = _key(message) by_chat.setdefault((account, dialog_id), []).append(message_id) for (account, dialog_id), ids in by_chat.items(): - store.hide(account, dialog_id, ids) - store.forget_deleted(account, dialog_id, ids) + # Its cached state stays: revealing the chat's hidden messages + # restores it from there. + self.hidden.hide(account, dialog_id, ids) channel_id = 0 if dialog_id < 0: chat = MessagesController.getInstance(account).getChat(-dialog_id) @@ -249,7 +254,7 @@ class DeleteDialog: store.mark_deleted(account, dialog_id, ids) diag.log(f"keeping own deletion {dialog_id}: {ids}") return list(ids) - store.hide(account, dialog_id, ids) + self.hidden.hide(account, dialog_id, ids) return [] def filter_storage(self, param): diff --git a/src/hooks/hidden.py b/src/hooks/hidden.py new file mode 100644 index 0000000..783b17e --- /dev/null +++ b/src/hooks/hidden.py @@ -0,0 +1,202 @@ +"""Deleted messages removed from view ("hidden"), marked on the server so +every client skips them; a chat can reveal its hidden messages again. + +- `flush` sends the marks waiting in the store to the server; +- the chat menu toggles revealing (per chat, until the app restarts): the + hidden deleted messages come back from the logger, then go again; +- a revealed hidden message has "Unhide" in its menu (hooks/menu.py), and + while revealing, the selection bar gets an Unhide button for those + selected (ChatActivity.createActionMode adds the bar's items, + updateActionModeTitle runs on every selection change; probed on 12.10.6). +""" + +import threading + +from base_plugin import MethodHook + +import diag +import peers +import store +from api import ApiError +from jutil import field, hook_methods, in_background, on_ui + +CHAT = "org.telegram.ui.ChatActivity" +_UNHIDE_ID = 0x4D4C01 # the selection bar item + + +def _bulletin(text, error=False): + def show(): + from ui.bulletin import BulletinHelper + + (BulletinHelper.show_error if error else BulletinHelper.show_info)(text) + + on_ui(show) + + +def _selected(chat): + """MessageObjects selected in the chat (both halves of a merged chat).""" + out = [] + for array in field(chat, "selectedMessagesIds") or []: + if array is None: + continue + for index in range(array.size()): + message = array.valueAt(index) + if message is not None: + out.append(message) + return out + + +def is_revealed_hidden(message): + """A hidden deleted message shown because its chat reveals them.""" + if message is None or int(message.getId()) <= 0: + return False + account, dialog_id = int(message.currentAccount), int(message.getDialogId()) + message_id = int(message.getId()) + return ( + store.is_revealed(account, dialog_id) + and store.is_deleted(account, dialog_id, message_id) + and store.is_hidden(account, dialog_id, message_id) + ) + + +class Hidden: + def __init__(self, plugin, api, chats): + self.plugin = plugin + self.api = api + self.chats = chats # hooks/chat.ChatSync + self.handles = [] + self._flushing = threading.Lock() + self._items = {} # ChatActivity classGuid -> the bar's Unhide item + + def install(self): + hidden = self + + class AfterActionMode(MethodHook): + def after_hooked_method(self, param): + try: + hidden.add_bar_item(param.thisObject) + except Exception as exc: + diag.error("hidden.bar", exc) + + class AfterTitle(MethodHook): + def after_hooked_method(self, param): + try: + hidden.update_bar_item(param.thisObject) + except Exception as exc: + diag.error("hidden.title", exc) + + self.handles += hook_methods(self.plugin, CHAT, "createActionMode", AfterActionMode()) + self.handles += hook_methods(self.plugin, CHAT, "updateActionModeTitle", AfterTitle()) + in_background(self.flush) + + def uninstall(self): + for handle in self.handles: + try: + self.plugin.unhook_method(handle) + except Exception: + pass + self.handles = [] + + # the server + + def flush(self): + """Sends hidden marks waiting in the store (background).""" + if not self.api.configured or not self._flushing.acquire(blocking=False): + return + try: + for (account, dialog_id, hidden), ids in store.pending_hidden().items(): + me = peers.own_user_id(account) + chat_id = peers.marked_chat_id(account, dialog_id) + if not me or chat_id is None: + continue + try: + self.api.hidden(me, chat_id, ids, hidden) + except ApiError as exc: + diag.log(f"hidden {chat_id}: {exc}") + continue + store.hidden_sent(account, dialog_id, ids, hidden) + diag.log(f"{'hid' if hidden else 'unhid'} {chat_id}: {ids}") + finally: + self._flushing.release() + + def hide(self, account, dialog_id, ids, hidden=True): + store.set_hidden(account, dialog_id, ids, hidden) + in_background(self.flush) + + # revealing (chat menu) + + def toggle_reveal(self, fragment): + account = int(fragment.getCurrentAccount()) + dialog_id = int(fragment.getDialogId()) + on = not store.is_revealed(account, dialog_id) + store.reveal(account, dialog_id, on) + if on: + count = self.chats.restore_hidden(fragment) + # And the server's answer, for those not cached. + store.expire_coverage(account, dialog_id) + self.chats.schedule(fragment) + _bulletin( + f"Message Logger: revealing hidden deleted messages ({count} loaded)" + if count + else "Message Logger: revealing hidden deleted messages" + ) + else: + ids = store.hidden_deleted(account, dialog_id) + self.chats.drop_from_view(account, dialog_id, ids) + _bulletin("Message Logger: hidden deleted messages are hidden again") + + # unhiding + + def unhide(self, messages): + by_chat = {} + for message in messages: + if is_revealed_hidden(message): + key = (int(message.currentAccount), int(message.getDialogId())) + by_chat.setdefault(key, []).append(int(message.getId())) + for (account, dialog_id), ids in by_chat.items(): + self.hide(account, dialog_id, ids, hidden=False) + self.chats.refresh_dialog(account, dialog_id, ids) + count = sum(len(ids) for ids in by_chat.values()) + if count: + _bulletin(f"Message Logger: {count} message{'s' if count > 1 else ''} unhidden") + + # the selection bar + + def add_bar_item(self, chat): + """The bar's Unhide item (also for a chat opened before the plugin + loaded: its bar exists already).""" + bar = chat.getActionBar() + menu = bar.createActionMode() if bar is not None else None + if menu is None: + return None + from android.view import View + from android_utils import OnClickListener + from org.telegram.messenger import AndroidUtilities, R + + item = menu.getItem(_UNHIDE_ID) + if item is None: + item = menu.addItemWithWidth( + _UNHIDE_ID, R.drawable.msg_views, AndroidUtilities.dp(54) + ) + item.setContentDescription("Unhide") + item.setVisibility(View.GONE) + + def chosen(_view): + self.unhide(_selected(chat)) + chat.clearSelectionMode() + + item.setOnClickListener(OnClickListener(chosen)) + self._items[int(chat.getClassGuid())] = item + return item + + def update_bar_item(self, chat): + item = self._items.get(int(chat.getClassGuid())) or self.add_bar_item(chat) + if item is None: + return + from android.view import View + + account, dialog_id = int(chat.getCurrentAccount()), int(chat.getDialogId()) + show = store.is_revealed(account, dialog_id) and any( + is_revealed_hidden(m) for m in _selected(chat) + ) + item.setVisibility(View.VISIBLE if show else View.GONE) diff --git a/src/hooks/menu.py b/src/hooks/menu.py index 9481296..9146af4 100644 --- a/src/hooks/menu.py +++ b/src/hooks/menu.py @@ -1,4 +1,5 @@ -""""Edit history" in the message menu itself, right above Forward. +""""Edit history" (and "Unhide" for a revealed hidden message, see +hooks/hidden.py) in the message menu itself, right above Forward. Plugin menu items (MenuItemData) only go into the menu's Plugins folder, and exteraGram drops option ids it doesn't know from fillMessageMenu's lists, so @@ -12,10 +13,12 @@ from base_plugin import MethodHook import diag import store +from hooks.hidden import is_revealed_hidden from jutil import field, hook_methods, java_class CHAT = "org.telegram.ui.ChatActivity" -_LABEL = "Edit history" +_HISTORY = "Edit history" +_UNHIDE = "Unhide" def _worth_it(message): @@ -58,9 +61,10 @@ def _find_item(view, text): class Menu: - def __init__(self, plugin, open_history): + def __init__(self, plugin, open_history, unhide): self.plugin = plugin self.open_history = open_history + self.unhide = unhide # hooks/hidden.Hidden.unhide self.handles = [] self._close = None @@ -87,39 +91,49 @@ class Menu: def add_item(self, chat): message = field(chat, "selectedObject") popup = field(chat, "scrimPopupWindow") - if popup is None or not _worth_it(message): + if popup is None or message is None: return - # The createMenu overloads call one another: add the row once. - if _find_item(popup.getContentView(), _LABEL) is not None: - return - from org.telegram.messenger import LocaleController, R + from org.telegram.messenger import R + rows = [] + if _worth_it(message): + rows.append((_HISTORY, R.drawable.msg_edit, lambda: self.open_history(message))) + if is_revealed_hidden(message): + rows.append((_UNHIDE, R.drawable.msg_views, lambda: self.unhide([message]))) + # The createMenu overloads call one another: add the rows once. content = popup.getContentView() + if not rows or _find_item(content, rows[0][0]) is not None: + return + from org.telegram.messenger import LocaleController + # Forward is a row in some chats and an icon in the bottom action bar - # in others (groups): then the row goes first in the list. + # in others (groups): then the rows go first in the list. anchor = _find_item( content, str(LocaleController.getString(R.string.Forward)) ) or _first_item(content) if anchor is None: - diag.log("menu: no row to place Edit history by") + diag.log("menu: no row to place the rows by") return - forward = anchor + for label, icon, action in rows: + self._add_row(chat, anchor, label, icon, action) + + def _add_row(self, chat, anchor, label, icon, action): from android_utils import OnClickListener from org.telegram.ui.ActionBar import ActionBarMenuSubItem - parent = forward.getParent() - item = ActionBarMenuSubItem(forward.getContext(), False, False) - item.setTextAndIcon(_LABEL, R.drawable.msg_edit) - color = forward.getTextView().getCurrentTextColor() + parent = anchor.getParent() + item = ActionBarMenuSubItem(anchor.getContext(), False, False) + item.setTextAndIcon(label, icon) + color = anchor.getTextView().getCurrentTextColor() item.setColors(color, color) - item.setMinimumWidth(forward.getMinimumWidth()) + item.setMinimumWidth(anchor.getMinimumWidth()) def chosen(_view): self._close_menu(chat) - self.open_history(message) + action() item.setOnClickListener(OnClickListener(chosen)) - parent.addView(item, parent.indexOfChild(forward), forward.getLayoutParams()) + parent.addView(item, parent.indexOfChild(anchor), anchor.getLayoutParams()) def _close_menu(self, chat): if self._close is None: diff --git a/src/hooks/removed_chats.py b/src/hooks/removed_chats.py new file mode 100644 index 0000000..48e5674 --- /dev/null +++ b/src/hooks/removed_chats.py @@ -0,0 +1,505 @@ +"""Chats an account lost (left, kicked, deleted by it or the other side), kept +by the logger: reported to the server, listed among the account's chats +again, and opened read-only with their logged messages, all deleted. + +- A chat leaves the list through MessagesController.deleteDialog(long did, + int onlyHistory[, boolean revoke]) (the user deleting it, or the app when + the account left or was kicked); onlyHistory 1 only clears the history. + The removal is reported, `own` when the call came from the app's screens + (the user deleted it: it is not listed again). Deleting a listed removed + chat hides it. +- The server's list (`/chats/removed`) is put into MessagesController + (dialogs_dict, allDialogs, dialogMessage) before every sortDialogs, with + the user/chat it needs (the app's own when it still has it, else a + stand-in) and the chat's latest logged photo as its avatar + (ImageReceiver.setForUserOrChat). +- Opening one: its requests never reach Telegram (sendRequestInternal): + getHistory and channels.getMessages are answered from the logged history + (views/removed_chat.py), messages.search with nothing, the rest dropped; + the input bar becomes a notice (ChatActivity.updateBottomOverlay). +""" + +import json +import threading +import time + +from base_plugin import MethodHook + +import accounts +import diag +import peers +import store +from api import ApiError +from config import DEFAULTS, SHOW_REMOVED_CHATS +from hooks import avatars +from jutil import field, hook_methods, in_background, java_list, on_ui +from views import removed_chat +from views.comments import make_read_only +from views.history import _epoch + +CONTROLLER = "org.telegram.messenger.MessagesController" +CONNECTIONS = "org.telegram.tgnet.ConnectionsManager" +CHAT = "org.telegram.ui.ChatActivity" +IMAGES = "org.telegram.messenger.ImageReceiver" +STORAGE = "org.telegram.messenger.MessagesStorage" +_NOTICE = "Removed chat · kept by the logger" +_REFRESH_SECONDS = 900 +_REPORT_AGAIN_SECONDS = 30 +_ANSWERED = ( + "TLRPC$TL_messages_getHistory", + "TLRPC$TL_channels_getMessages", + "TLRPC$TL_messages_search", +) + + +def _from_screens(): + """Whether the running call came from the app's screens (a user action).""" + from java.lang import Thread + + return any( + str(frame.getClassName()).startswith("org.telegram.ui.") + for frame in Thread.currentThread().getStackTrace() + ) + + +def _dialog_id(chat_id): + from hooks.chat import _android_peer_id + + return _android_peer_id(int(chat_id)) + + +def _target(request): + """The dialog a request is about (its peer/channel/user), else None.""" + for name in ("peer", "channel", "user_id", "id"): + value = getattr(request, name, None) + if value is None or isinstance(value, (bool, int, float, str)): + continue + kind = type(value).__name__ + if "nputPeerChannel" in kind or "nputChannel" in kind: + return -int(value.channel_id) + if "nputPeerChat" in kind: + return -int(value.chat_id) + if "nputPeerUser" in kind or kind.endswith("TL_inputUser"): + return int(value.user_id) + return None + + +class RemovedChats: + def __init__(self, plugin, api): + self.plugin = plugin + self.api = api + self.handles = [] + self._lists = {} # account -> {dialog_id: server item} + self._peers = {} # (account, dialog_id) -> User/Chat (cached or stand-in) + # account -> {dialog_id: (Dialog, [MessageObject], its top message id)} + self._injected = {} + self._histories = {} # (account, dialog_id) -> views.removed_chat.History + self._avatar_paths = {} # (account, dialog_id) -> local photo path + self._reported = {} # (account, dialog_id) -> monotonic time + self._lock = threading.Lock() + self._timer = None + + def _enabled(self): + return bool(self.plugin.get_setting(SHOW_REMOVED_CHATS, DEFAULTS[SHOW_REMOVED_CHATS])) + + def install(self): + chats = self + + def hook(class_name, name, before=None, after=None, arity=None): + class Hook(MethodHook): + def before_hooked_method(self, param): + if before is not None: + try: + before(param) + except Exception as exc: + diag.error(f"removed_chats.{name}", exc) + + def after_hooked_method(self, param): + if after is not None: + try: + after(param) + except Exception as exc: + diag.error(f"removed_chats.{name}", exc) + + chats.handles += hook_methods(chats.plugin, class_name, name, Hook(), arity) + + hook(CONTROLLER, "deleteDialog", before=self.before_delete, arity=2) + hook(CONTROLLER, "deleteDialog", before=self.before_delete, arity=3) + hook(CONTROLLER, "sortDialogs", before=self.before_sort) + hook(CONNECTIONS, "sendRequestInternal", before=self.before_request) + hook(CHAT, "updateBottomOverlay", after=self.after_overlay) + hook(STORAGE, "putMessages", before=self.before_store) + hook(IMAGES, "setForUserOrChat", before=self.before_avatar, arity=6) + in_background(self._start) + + def _start(self): + """Lists from the last run at once (offline too), then the server's.""" + for account, _user in accounts.signed_in(): + cached = store.get_meta(f"removed_chats:{account}") + if cached: + self._set_list(account, json.loads(cached)) + on_ui(self.inject_all) + self._schedule(5) + + def uninstall(self): + if self._timer is not None: + self._timer.cancel() + for handle in self.handles: + try: + self.plugin.unhook_method(handle) + except Exception: + pass + self.handles = [] + for account in list(self._injected): + self._lists[account] = {} + try: + self.inject(account) + except Exception as exc: + diag.error("removed_chats.uninstall", exc) + + # the server's list (background) + + def _schedule(self, seconds): + self._timer = threading.Timer(seconds, self._tick) + self._timer.daemon = True + self._timer.start() + + def _tick(self): + try: + self.refresh_all() + finally: + self._schedule(_REFRESH_SECONDS) + + def refresh_all(self): + if not self.api.configured or not self._enabled(): + return + for account, _user in accounts.signed_in(): + self.refresh(account) + + def refresh(self, account): + me = peers.own_user_id(account) + try: + chats = self.api.removed_chats(me) + except ApiError as exc: + diag.log(f"removed chats: {exc}") + return + if chats is None: + return + store.put_meta(f"removed_chats:{account}", json.dumps(chats)) + self._set_list(account, chats) + with self._lock: + for key in [k for k in self._histories if k[0] == account]: + del self._histories[key] # read again on next open + on_ui(lambda: self.inject(account)) + for dialog_id, item in self._lists.get(account, {}).items(): + avatar = item.get("avatar") or {} + if avatar.get("has_photo") and (account, dialog_id) not in self._avatar_paths: + in_background(lambda d=dialog_id, a=avatar: self._load_avatar(account, d, a)) + + def _set_list(self, account, chats): + lists = {} + for item in chats: + dialog_id = _dialog_id(item["chat_id"]) + lists[dialog_id] = item + if (account, dialog_id) not in self._peers: + self._peers[(account, dialog_id)] = self._peer(account, dialog_id, item) + self._lists[account] = lists + + def _peer(self, account, dialog_id, item): + """The app's user/chat (cache or storage), else a stand-in.""" + from org.telegram.messenger import MessagesController, MessagesStorage + + import tg + + controller = MessagesController.getInstance(account) + if dialog_id > 0: + user = controller.getUser(dialog_id) + if user is None: + try: + user = MessagesStorage.getInstance(account).getUserSync(dialog_id) + except Exception: + user = None + return user or removed_chat.stand_in_user(controller, dialog_id, item.get("title")) + chat = tg.channel_chat(account, -dialog_id) + return chat or removed_chat.stand_in_chat( + controller, -dialog_id, item.get("title"), item.get("kind") + ) + + def _load_avatar(self, account, dialog_id, avatar): + found = avatars.logged_photo(account, avatar) + if found is None: + return + photo, reader, copy = found + path = avatars.download(reader, photo, copy, 160) + if path is None: + return + self._avatar_paths[(account, dialog_id)] = path + on_ui(lambda: self._redraw(account)) + + # the chat list (UI thread) + + def inject_all(self): + for account in list(self._lists): + self.inject(account) + + def inject(self, account): + """Puts the account's removed chats into the app's chat list (and + takes out those no longer removed).""" + from org.telegram.messenger import MessagesController + + controller = MessagesController.getInstance(account) + wanted = {} + if self._enabled(): + wanted = { + d: item + for d, item in self._lists.get(account, {}).items() + if not item.get("hidden_at") and item.get("top_message") + } + current = self._injected.setdefault(account, {}) + for dialog_id in list(current): + if dialog_id not in wanted and self.is_injected(account, dialog_id): + self._take_out(controller, dialog_id, current.pop(dialog_id)[0]) + store.set_removed_dialog(account, dialog_id, False) + for dialog_id, item in wanted.items(): + if dialog_id in current or controller.dialogs_dict.get(dialog_id) is not None: + continue # listed, or the app has the chat (it is back) + current[dialog_id] = self._dialog(controller, account, dialog_id, item) + store.set_removed_dialog(account, dialog_id, True) + self._put(controller, account) + controller.sortDialogs(None) + self._redraw(account) + + def _dialog(self, controller, account, dialog_id, item): + from org.telegram.messenger import MessageObject + from org.telegram.tgnet import TLRPC + + peer = self._peers.get((account, dialog_id)) + if dialog_id > 0: + controller.putUser(peer, True) + else: + controller.putChat(peer, True) + top = item["top_message"] + dialog = TLRPC.TL_dialog() + dialog.id = dialog_id + dialog.peer = controller.getPeer(dialog_id) + dialog.top_message = int(top["message_id"]) + dialog.read_inbox_max_id = dialog.top_message + dialog.read_outbox_max_id = dialog.top_message + dialog.last_message_date = _epoch(top.get("source_date")) + from java.util import ArrayList + + message = removed_chat.from_text(account, dialog_id, top) + store.put_server_states(account, dialog_id, [top]) + # dialogMessage holds the dialog's preview messages (an album's parts). + preview = ArrayList() + preview.add(MessageObject(account, message, False, False)) + return dialog, preview, int(dialog.top_message) + + def _put(self, controller, account): + """Our dialogs in the controller's lists (they rebuild them).""" + injected = self._injected.get(account) or {} + dialogs = controller.dialogs_dict + everything = controller.allDialogs + messages = controller.dialogMessage + for dialog_id, (dialog, message, _top) in list(injected.items()): + if not self.is_injected(account, dialog_id): + continue # back: the app's dialog now + existing = dialogs.get(dialog_id) + if existing is not None and not existing.equals(dialog): + del injected[dialog_id] # the app has the chat again + everything.remove(dialog) + continue + if existing is None: + dialogs.put(dialog_id, dialog) + if not everything.contains(dialog): + everything.add(dialog) + if messages.get(dialog_id) is None: + messages.put(dialog_id, message) + + def _take_out(self, controller, dialog_id, dialog): + if controller.dialogs_dict.get(dialog_id) is not None and controller.dialogs_dict.get( + dialog_id + ).equals(dialog): + controller.dialogs_dict.remove(dialog_id) + controller.dialogMessage.remove(dialog_id) + controller.allDialogs.remove(dialog) + + def _redraw(self, account): + from java import jarray + from java.lang import Object + from org.telegram.messenger import NotificationCenter + + NotificationCenter.getInstance(account).postNotificationName( + NotificationCenter.dialogsNeedReload, jarray(Object)([]) + ) + + def before_sort(self, param): + account = int(field(param.thisObject, "currentAccount") or 0) + if self._injected.get(account): + self._put(param.thisObject, account) + + def is_injected(self, account, dialog_id): + """Whether the dialog is a removed chat listed here. One that got a + new message is back: the app keeps the dialog, it is no longer ours.""" + account, dialog_id = int(account), int(dialog_id) + entry = (self._injected.get(account) or {}).get(dialog_id) + if entry is None: + return False + if int(entry[0].top_message) == entry[2]: + return True + with self._lock: + (self._injected.get(account) or {}).pop(dialog_id, None) + self._lists.get(account, {}).pop(dialog_id, None) + self._histories.pop((account, dialog_id), None) + store.set_removed_dialog(account, dialog_id, False) + diag.log(f"removed chat {dialog_id} is back") + return False + + # removals + + def before_delete(self, param): + dialog_id, only_history = int(param.args[0]), int(param.args[1]) + account = int(field(param.thisObject, "currentAccount") or 0) + if self.is_injected(account, dialog_id): + param.setResult(None) # not a chat on Telegram + if only_history != 1: + self._hide(account, dialog_id) + return + if only_history == 1 or not self.api.configured: + return + key = (account, dialog_id) + now = time.monotonic() + if now - self._reported.get(key, -_REPORT_AGAIN_SECONDS) < _REPORT_AGAIN_SECONDS: + return # the overloads call one another + self._reported[key] = now + chat_id = peers.marked_chat_id(account, dialog_id) + if chat_id is None: + return + own = _from_screens() + in_background(lambda: self._report(account, chat_id, own)) + + def _report(self, account, chat_id, own): + try: + known = self.api.chat_removed(peers.own_user_id(account), chat_id, own) + except ApiError as exc: + diag.log(f"chat removal {chat_id}: {exc}") + return + diag.log(f"chat {chat_id} removed{' by the user' if own else ''}, known={known}") + if known: + time.sleep(2) + self.refresh(account) + + def _hide(self, account, dialog_id): + item = self._lists.get(account, {}).get(dialog_id) + if item is None: + return + item["hidden_at"] = "now" + on_ui(lambda: self.inject(account)) + chat_id = item["chat_id"] + + def send(): + try: + self.api.chat_hidden(peers.own_user_id(account), chat_id, True) + except ApiError as exc: + diag.log(f"chat hide {chat_id}: {exc}") + + in_background(send) + + # an opened removed chat + + def after_overlay(self, param): + chat = param.thisObject + if self.is_injected(chat.getCurrentAccount(), chat.getDialogId()): + make_read_only(chat, _NOTICE) + # Again once the screen settled (a channel's bar comes back). + on_ui(lambda: make_read_only(chat, _NOTICE), 600) + + def before_store(self, param): + """putMessages(messages_Messages, dialogId, ...): a removed chat's + logged messages are not stored as the app's own (they would make it + a chat of the app's, read from its cache from then on).""" + args = param.args + if len(args) < 2 or not hasattr(args[0], "messages"): + return + account = int(field(param.thisObject, "currentAccount") or 0) + if not self.is_injected(account, int(args[1])): + return + param.setResult(None) + done = args[-1] + if done is not None and hasattr(done, "run"): + done.run() + + def _history(self, account, dialog_id): + with self._lock: + history = self._histories.get((account, dialog_id)) + if history is None: + item = self._lists.get(account, {}).get(dialog_id) or {} + history = removed_chat.History( + self.api, account, dialog_id, int(item.get("chat_id") or dialog_id) + ) + self._histories[(account, dialog_id)] = history + history.load() + return history + + def before_request(self, param): + request = param.args[0] + if request is None or not any(self._injected.values()): + return + account = int(field(param.thisObject, "currentAccount") or 0) + dialog_id = _target(request) + if dialog_id is None or not self.is_injected(account, dialog_id): + return + name = type(request).__name__ + delegate, timestamped = param.args[1], param.args[2] + if param.method.getReturnType().getName() == "void": + param.setResult(None) + else: + from java import jint + + param.setResult(jint(0)) + if name not in _ANSWERED: + return # Telegram has nothing for this chat + + def deliver(): + history = self._history(account, dialog_id) + if name == "TLRPC$TL_messages_getHistory": + picked = history.page( + int(request.offset_id), int(request.add_offset), int(request.limit) + ) + elif name == "TLRPC$TL_channels_getMessages": + picked = history.by_ids(int(m.id) for m in java_list(request.id)) + else: + picked = [] + response = history.response(picked) + if delegate is not None: + delegate.run(response, None) + elif timestamped is not None: + timestamped.run(response, None, 0) + + in_background(deliver) + + def before_avatar(self, param): + """The latest logged photo of a removed chat as its avatar.""" + from org.telegram.tgnet import TLRPC + + target = param.args[0] + if target is None or not self._avatar_paths: + return + if isinstance(target, TLRPC.User): + dialog_id = int(target.id) + elif isinstance(target, TLRPC.Chat): + dialog_id = -int(target.id) + else: + return + receiver = param.thisObject + account = int(field(receiver, "currentAccount") or 0) + path = self._avatar_paths.get((account, dialog_id)) + if path is None or not self.is_injected(account, dialog_id): + return + from java import jint + from org.telegram.messenger import ImageLocation + + receiver.setImage( + ImageLocation.getForPath(path), "50_50", param.args[1], None, target, jint(0) + ) + param.setResult(None) diff --git a/src/main.py b/src/main.py index 99ac1c1..d793fab 100644 --- a/src/main.py +++ b/src/main.py @@ -15,13 +15,17 @@ from config import ( REPORT_DELETIONS, RESTORE_MISSING, SERVER_URL, + SHOW_REMOVED_CHATS, ) +from hooks.avatars import Avatars from hooks.chat import EDIT_UPDATES, ChatSync from hooks.comments import CommentsButton from hooks.delete_dialog import DeleteDialog from hooks.deletes import UPDATES, Deletes +from hooks.hidden import Hidden from hooks.links import Links from hooks.menu import Menu +from hooks.removed_chats import RemovedChats from hooks.render import Render from jutil import in_background, on_ui from views.comments import Comments @@ -43,19 +47,25 @@ class MessageLoggerPlugin(BasePlugin): self.history = History(self, self.api) self.render = Render(self) self.chats = ChatSync(self, self.api) - self.delete_dialog = DeleteDialog(self, self.chats) + self.hidden = Hidden(self, self.api, self.chats) + self.delete_dialog = DeleteDialog(self, self.chats, self.hidden) self.deletes = Deletes(self, self.api, self.chats, self.delete_dialog) self.links = Links(self) - self.menu = Menu(self, self.history.open) + self.menu = Menu(self, self.history.open, self.hidden.unhide) self.comments = CommentsButton(self, Comments(self, self.api).open) + self.avatars = Avatars(self, self.api) + self.removed_chats = RemovedChats(self, self.api) parts = ( self.render, self.chats, + self.hidden, self.deletes, self.delete_dialog, self.links, self.menu, self.comments, + self.avatars, + self.removed_chats, ) for part in parts: try: @@ -65,11 +75,21 @@ class MessageLoggerPlugin(BasePlugin): self.add_menu_item( MenuItemData( menu_type=MenuItemType.CHAT_ACTION_MENU, + item_id="message_logger_reset", text="Reset deleted/edited marks", icon="msg_clear", on_click=self._reset_chat, ) ) + self.add_menu_item( + MenuItemData( + menu_type=MenuItemType.CHAT_ACTION_MENU, + item_id="message_logger_reveal", + text="Reveal hidden messages", + icon="msg_views", + on_click=self._toggle_reveal, + ) + ) in_background(accounts.refresh) in_background(self.purge_cache) diag.log("loaded") @@ -89,6 +109,19 @@ class MessageLoggerPlugin(BasePlugin): except Exception as exc: diag.error("reset chat", exc) + def _toggle_reveal(self, context): + """Chat menu: show the chat's hidden deleted messages (or hide them + again).""" + try: + fragment = context.get("fragment") + if fragment is None: + from client_utils import get_last_fragment + + fragment = get_last_fragment() + self.hidden.toggle_reveal(fragment) + except Exception as exc: + diag.error("reveal", exc) + def _reset_all(self, _view=None): """Settings: forget every chat's cached marks.""" def run(): @@ -97,6 +130,10 @@ class MessageLoggerPlugin(BasePlugin): in_background(run) + def _removed_chats_changed(self): + on_ui(self.removed_chats.inject_all) + in_background(self.removed_chats.refresh_all) + def purge_cache(self, force=False): """Removes cached answers older than the configured lifetime; at most once a day (the day turns at local midnight) unless `force`.""" @@ -110,11 +147,14 @@ class MessageLoggerPlugin(BasePlugin): for part in ( "render", "chats", + "hidden", "deletes", "delete_dialog", "links", "menu", "comments", + "avatars", + "removed_chats", ): if hasattr(self, part): getattr(self, part).uninstall() @@ -192,6 +232,14 @@ class MessageLoggerPlugin(BasePlugin): subtext="Tell the server about deletions this client sees", icon="msg_report", ), + Switch( + key=SHOW_REMOVED_CHATS, + text="Show removed chats", + default=DEFAULTS[SHOW_REMOVED_CHATS], + subtext="Chats you left or lost stay in the chat list, read-only", + icon="msg_archive", + on_change=lambda _value: self._removed_chats_changed(), + ), Text( text="Reset deleted/edited marks", subtext="Forget all cached marks; chats are checked with the server again", diff --git a/src/store.py b/src/store.py index 861c20b..9427a09 100644 --- a/src/store.py +++ b/src/store.py @@ -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): diff --git a/src/views/comments.py b/src/views/comments.py index dfecef1..1bd5695 100644 --- a/src/views/comments.py +++ b/src/views/comments.py @@ -33,9 +33,9 @@ read_only = set() _lock = threading.Lock() -def make_read_only(chat): - """Swaps a comment screen's input bar for a read-only notice (the app's - own bottom overlay, as in chats one can't write to). UI thread.""" +def make_read_only(chat, notice="Viewing deleted comments"): + """Swaps a screen's input bar for a read-only notice (the app's own + bottom overlay, as in chats one can't write to). UI thread.""" from android.view import View from jutil import field @@ -47,7 +47,12 @@ def make_read_only(chat): text = field(chat, "bottomOverlayText") if overlay is None or text is None: return - for name in ("bottomOverlayChatText", "bottomOverlayStartButton", "bottomOverlayProgress"): + for name in ( + "bottomOverlayChat", # a channel's bar (search, mute/join) + "bottomOverlayChatText", + "bottomOverlayStartButton", + "bottomOverlayProgress", + ): other = field(chat, name) if other is not None: other.setVisibility(View.GONE) @@ -56,12 +61,40 @@ def make_read_only(chat): animation = field(chat, "bottomOverlayAnimation") if animation is not None: animation.cancel() - text.setText("Viewing deleted comments") + text.setText(notice) text.setVisibility(View.VISIBLE) overlay.setAlpha(1.0) overlay.setVisibility(View.VISIBLE) +def answer(account, request): + """The response for a getReplies of a thread opened here, else None.""" + with _lock: + thread = _threads.get(int(request.msg_id)) + if thread is None or thread[0] != int(account): + return None + _account, _discussion, messages, users, chats = thread + from java.util import ArrayList + from org.telegram.tgnet import TLRPC + + offset_id = int(request.offset_id or 0) + add_offset = int(request.add_offset or 0) + picked = [ + m for m in messages if not offset_id or add_offset < 0 or int(m.id) < offset_id + ] + response = TLRPC.TL_messages_messages() + response.messages = ArrayList() + for message in sorted(picked, key=lambda m: -int(m.id)): + response.messages.add(message) + response.users = ArrayList() + for user in users: + response.users.add(user) + response.chats = ArrayList() + for chat in chats: + response.chats.add(chat) + return response + + def _peer(controller, marked): """TLRPC peer of a Telethon sender id.""" from hooks.chat import _android_peer_id diff --git a/src/views/removed_chat.py b/src/views/removed_chat.py new file mode 100644 index 0000000..98e933c --- /dev/null +++ b/src/views/removed_chat.py @@ -0,0 +1,266 @@ +"""The history of a removed chat, rebuilt from the logger: the logged messages +(`/chats/{id}/history`), each from its copy in a log group when an account +here can read it (header stripped, media included), else from the server's +text with its media marked unavailable. All of them are deleted for the +account; they are cached as such (store), so they draw like any deleted +message. + +`History` holds one chat's messages, newest first, and answers the app's +getHistory/getMessages for it (hooks/removed_chats.py). +""" + +import threading + +import accounts +import diag +import peers +import store +import tg +from api import ApiError +from views.history import _MEDIA_NAMES, _entities, _epoch, _utf16 + +_PAGES = 3 # history pages (of 100) loaded when the chat opens +_COPIES_PER_REQUEST = 100 + + +def stand_in_user(controller, peer_id, name): + from org.telegram.tgnet import TLRPC + + user = controller.getUser(int(peer_id)) + if user is None: + user = TLRPC.TL_user() + user.id = int(peer_id) + user.first_name = name or "Deleted" + user.flags |= 2 + return user + + +def stand_in_chat(controller, chat_id, title, kind): + """The app's chat (cached or stored), else a stand-in of the right kind.""" + from org.telegram.tgnet import TLRPC + + chat = controller.getChat(int(chat_id)) + if chat is not None: + return chat + if kind == "group": + chat = TLRPC.TL_chat() + else: + chat = TLRPC.TL_channel() + chat.broadcast = kind == "channel" + chat.megagroup = kind != "channel" + chat.id = int(chat_id) + chat.title = title or "Chat" + return chat + + +class History: + def __init__(self, api, account, dialog_id, chat_id): + self.api = api + self.account = account + self.dialog_id = dialog_id + self.chat_id = chat_id + self.messages = [] # TL messages, newest first + self.users = {} + self.chats = {} + self.loaded = False + self._lock = threading.Lock() + + # loading (background) + + def load(self): + """Fetches and builds the chat's history once; later calls wait.""" + with self._lock: + if self.loaded: + return + try: + self._load() + except Exception as exc: + diag.error("removed_chat.load", exc) + self.loaded = True + + def _load(self): + me = peers.own_user_id(self.account) + items, before, master = [], 0, None + for _page in range(_PAGES): + try: + result = self.api.history(me, self.chat_id, before) + except ApiError as exc: + diag.log(f"removed chat {self.chat_id}: {exc}") + break + page = (result or {}).get("messages") or [] + if not page: + break + master = result.get("master_tg_id") + items += page + before = min(int(m["message_id"]) for m in page) + if len(page) < 100: + break + revealed = store.is_revealed(self.account, self.dialog_id) + items = [i for i in items if revealed or not i.get("hidden_at")] + store.put_server_states(self.account, self.dialog_id, items) + copies = self._copies(items, master) + built = [] + for item in items: + try: + built.append(self._message(item, copies.get(self._pointer(item)))) + except Exception as exc: + diag.error("removed_chat.message", exc) + self.messages = sorted(built, key=lambda m: -int(m.id)) + diag.log(f"removed chat {self.chat_id}: {len(self.messages)} messages") + + @staticmethod + def _pointer(item): + logged = item.get("logged") or {} + if not logged.get("chat_id") or not logged.get("message_id"): + return None + return int(logged["chat_id"]), int(logged["message_id"]) + + def _copies(self, items, master): + """Logged copies by (log chat, message), read through an account here + that can, per log group.""" + by_group = {} + for item in items: + pointer = self._pointer(item) + if pointer is not None: + by_group.setdefault(pointer[0], set()).add(pointer[1]) + found = {} + for log_chat, ids in by_group.items(): + channel_id = peers.channel_id_from_marked(log_chat) + readers = accounts.with_access( + -channel_id, prefer=(self.account, accounts.slot_of(master)) + ) + if not readers: + continue + ordered = sorted(ids) + for start in range(0, len(ordered), _COPIES_PER_REQUEST): + chunk = ordered[start : start + _COPIES_PER_REQUEST] + for message_id, copy in _fetch(readers[0], -channel_id, chunk).items(): + found[(log_chat, message_id)] = copy + return found + + # building + + def _message(self, item, copy): + from org.telegram.messenger import MessagesController + from org.telegram.tgnet import TLRPC + + from hooks.chat import _android_peer_id, build_from_copy + + controller = MessagesController.getInstance(self.account) + message_id = int(item["message_id"]) + if copy is not None and not isinstance(copy, TLRPC.TL_messageEmpty): + msg = build_from_copy( + self.account, self.dialog_id, message_id, item, copy, True + ).messageOwner + else: + msg = from_text(self.account, self.dialog_id, item) + sender = item.get("sender_id") + if sender: + peer_id = _android_peer_id(int(sender)) + if peer_id > 0: + user = stand_in_user(controller, peer_id, item.get("sender_name")) + self.users[int(user.id)] = user + else: + chat = stand_in_chat(controller, -peer_id, item.get("sender_name"), "channel") + self.chats[int(chat.id)] = chat + if item.get("reply_to"): + reply = TLRPC.TL_messageReplyHeader() + reply.reply_to_msg_id = int(item["reply_to"]) + reply.flags |= 16 + msg.reply_to = reply + msg.flags |= 8 + return msg + + # answering the app's requests + + def response(self, picked): + from java.util import ArrayList + from org.telegram.tgnet import TLRPC + + response = TLRPC.TL_messages_messages() + response.messages = ArrayList() + for message in picked: + response.messages.add(message) + response.users = ArrayList() + for user in self.users.values(): + response.users.add(user) + response.chats = ArrayList() + for chat in self.chats.values(): + response.chats.add(chat) + return response + + def page(self, offset_id, add_offset, limit): + """getHistory's slice: newest first, from `offset_id` (exclusive; 0: + the newest) moved by `add_offset` (negative: newer ones too).""" + messages = self.messages + if offset_id: + start = next( + (i for i, m in enumerate(messages) if int(m.id) < offset_id), + len(messages), + ) + else: + start = 0 + start = max(0, start + add_offset) + return messages[start : start + max(1, limit)] + + def by_ids(self, ids): + wanted = set(int(i) for i in ids) + return [m for m in self.messages if int(m.id) in wanted] + + +def from_text(account, dialog_id, item): + """A logged message from the server's text; each media is marked + unavailable after it.""" + from org.telegram.messenger import MessagesController + from org.telegram.tgnet import TLRPC + + from hooks.chat import _android_peer_id + + controller = MessagesController.getInstance(account) + msg = TLRPC.TL_message() + msg.id = int(item["message_id"]) + msg.dialog_id = dialog_id + msg.peer_id = controller.getPeer(dialog_id) + sender = item.get("sender_id") + msg.from_id = ( + controller.getPeer(_android_peer_id(int(sender))) if sender else msg.peer_id + ) + msg.flags |= 256 + msg.date = _epoch(item.get("source_date")) + if int(item.get("edit_count") or 0) > 0: + msg.edit_date = _epoch(item.get("edit_date")) or msg.date + msg.flags |= 32768 + text = item.get("text") or "" + entities = _entities(item.get("entities") or []) + notes = [ + f"🚫 {_MEDIA_NAMES.get(kind, 'Media')} unavailable" + for kind in item.get("media") or [] + ] + if notes: + note = "\n".join(notes) + start = _utf16(text) + (2 if text else 0) + text = (text + "\n\n" if text else "") + note + italic = TLRPC.TL_messageEntityItalic() + italic.offset = start + italic.length = _utf16(note) + entities.add(italic) + msg.message = text or "(empty)" + msg.entities = entities + msg.out = sender is not None and int(sender) == peers.own_user_id(account) + return msg + + +def _fetch(reader, dialog_id, ids): + """Messages `ids` of a log group through `reader` (blocking).""" + done = threading.Event() + box = {} + + def callback(found, error): + if found is None: + diag.log(f"removed chat copies unreadable: {error}") + box["found"] = found or {} + done.set() + + tg.get_messages(reader, dialog_id, ids, callback) + done.wait(20) + return box.get("found") or {} diff --git a/tools/verify_bundle.py b/tools/verify_bundle.py index 7f3eb4d..d789460 100755 --- a/tools/verify_bundle.py +++ b/tools/verify_bundle.py @@ -314,7 +314,7 @@ def main(argv=None) -> int: store.put_server_states(0, -7, [{"message_id": 1, "edit_count": 1}]) store.put_server_states(0, -8, [{"message_id": 1, "edit_count": 1}]) store.mark_deleted(0, -7, [2]) - store.hide(0, -7, [3]) + store.set_hidden(0, -7, [3]) check( "reset forgets one chat's marks, keeps others and hidden ones", store.reset(0, -7) == 2 @@ -322,6 +322,24 @@ def main(argv=None) -> int: and store.get(0, -8, 1) is not None and store.is_hidden(0, -7, 3), ) + store.put_server_states( + 0, -9, [{"message_id": 4, "deleted_at": "x", "logged": {"chat_id": 1}}] + ) + shown = len(store.cached_deleted(0, -9, 1, 10)) == 1 + store.set_hidden(0, -9, [4]) + waiting = store.pending_hidden().get((0, -9, True)) + skipped = store.cached_deleted(0, -9, 1, 10) == [] + store.hidden_sent(0, -9, [4], True) + check( + "hidden marks wait for the server, then are its state", + shown + and waiting == [4] + and skipped + and (0, -9, True) not in store.pending_hidden() + and store.is_hidden(0, -9, 4) + and len(store.cached_deleted(0, -9, 1, 10, True)) == 1, + f"waiting={waiting} skipped={skipped}", + ) for name in ("cached_deleted", "coverage", "set_coverage", "expire_coverage", "replace_window"): check(f"store.{name} exists", callable(getattr(store, name, None)))