From dba0c4f833a51f3ace2e2ba3239486b23caef81f Mon Sep 17 00:00:00 2001 From: lyuksovannyy Date: Sun, 11 Oct 2026 01:12:23 +0200 Subject: [PATCH] Message Logger: restore deep in long chats, media through the master, photo fixes - Deleted messages within the loaded part of a long chat are placed by the app among the others (not only at the chat's end), without counting as unread; restores no longer repeat every second. - Logger copies and their media are read through a member of the log group signed in here (this account, else the master); the shared file cache lets the current account show them. - Logged chat photos: asked for once per 10 minutes, all sizes downloaded (no waits on inline thumbnails), added once the photo viewer closed. Co-Authored-By: Claude Opus 5.5 --- plugin.json | 2 +- src/api.py | 12 ++++ src/hooks/avatars.py | 136 +++++++++++++++++++------------------ src/hooks/chat.py | 72 ++++++++++++++++++-- src/hooks/removed_chats.py | 5 +- src/media_share.py | 135 ++++++++++++++++++++++++++++++++++++ src/views/removed_chat.py | 13 ++-- 7 files changed, 297 insertions(+), 78 deletions(-) create mode 100644 src/media_share.py diff --git a/plugin.json b/plugin.json index d9334b5..ef08dc3 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.3", + "version": "0.1.4", "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 8eb46e8..b8f57ba 100644 --- a/src/api.py +++ b/src/api.py @@ -99,6 +99,18 @@ class Api: out.append((token[:10], exc)) return out + def masters(self): + """Telegram ids of the tokens' masters (from /me), cached 10 minutes.""" + cached = getattr(self, "_masters", None) + if cached is not None and time.monotonic() - cached[0] < 600: + return cached[1] + found = [] + for _prefix, me in self.me(): + if isinstance(me, dict) and me.get("master_tg_id"): + found.append(int(me["master_tg_id"])) + self._masters = (time.monotonic(), found) + return found + def sync(self, account_id, chat_id, min_id, max_id, present_ids): body = { "account_id": account_id, diff --git a/src/hooks/avatars.py b/src/hooks/avatars.py index 847c808..cd37fb0 100644 --- a/src/hooks/avatars.py +++ b/src/hooks/avatars.py @@ -19,20 +19,23 @@ import time from base_plugin import MethodHook -import accounts import diag +import media_share import peers from api import ApiError -from jutil import field, hook_methods, in_background, java_list, on_ui +from jutil import field, hook_methods, in_background, java_class, java_list, on_ui from views.history import _epoch CONTROLLER = "org.telegram.messenger.MessagesController" THUMBS = "org.telegram.ui.Components.GroupedPhotosListView" +VIEWER = "org.telegram.ui.PhotoViewer" 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 +# A chat's logged photos are asked for again after this long. +_FRESH_SECONDS = 600 +_TELL_SECONDS = 3 def _photo_of(copy): @@ -50,65 +53,24 @@ def _photo_of(copy): return None -def logged_photo(account, avatar): - """(photo, reader slot, post) of a logged chat photo, else None (blocking).""" +def logged_photo(account, avatar, masters=()): + """(photo, reader slot, post) of a logged chat photo, read through a + member of its log group signed in here; 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"))) + reader = media_share.reader_for( + account, logged["chat_id"], [avatar.get("master_tg_id"), *masters] ) - if not readers: + if reader is None: return None + channel_id = peers.channel_id_from_marked(logged["chat_id"]) 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 + copy = _fetch(reader, -channel_id, [message_id]).get(message_id) + found = _photo_of(copy) + return (found, reader, copy) if found is not None else None class Avatars: @@ -120,8 +82,12 @@ class Avatars: self._recorded = {} # (account, dialog) -> [(photo, original id, date)] self._ids = set() # ids of those photos: drawn with the trash icon self._loading = set() + self._loaded_at = {} # (account, dialog) -> monotonic time of the answer + self._told = {} # (account, dialog) -> monotonic time the gallery was told + self._appending = False self._updates = {} # (account, dialog) -> args of its last dialogPhotosUpdate self._icon = None + self._shown = None # PhotoViewer.isVisible() def install(self): avatars = self @@ -166,14 +132,16 @@ class Avatars: 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: + if self._appending or 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 + # Added (and the gallery told) once the app is done with it. + on_ui(lambda: self._append(*key)) + if time.monotonic() - self._loaded_at.get(key, 0) < _FRESH_SECONDS: + return if key in self._loading: return self._loading.add(key) @@ -190,20 +158,22 @@ class Avatars: diag.log(f"avatars {chat_id}: {exc}") return recorded = [] + masters = self.api.masters() for avatar in logged: - found = logged_photo(account, avatar) + found = logged_photo(account, avatar, masters) if found is None: continue photo, reader, copy = found - download(reader, photo, copy, 160) - if download(reader, photo, copy, 800) is None: - continue + if not media_share.photo_sizes(reader, photo, copy): + diag.log(f"avatars {chat_id}: logged photo unavailable") + continue # it can't be shown 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._loaded_at[(account, dialog_id)] = time.monotonic() self._ids.update(int(p.id) for p, _o, _d in recorded) if recorded: diag.log(f"avatars {chat_id}: {len(recorded)} logged photos") @@ -241,13 +211,45 @@ class Avatars: added = True return added + def _viewing(self, dialog_id): + """Whether the photo viewer shows the chat's photos: its list is + built when it opens and a change meanwhile desyncs it from its + thumbnails (no swiping to the new ones).""" + from org.telegram.ui import PhotoViewer + + if not PhotoViewer.hasInstance(): + return False + viewer = PhotoViewer.getInstance() + if self._shown is None: + # The method (not the field, which stays set once it closed). + self._shown = java_class(VIEWER).getMethod("isVisible") + return bool(self._shown.invoke(viewer)) and int( + field(viewer, "avatarsDialogId") or 0 + ) == int(dialog_id) + def _append(self, account, dialog_id): + """Puts the logged photos into the chat's photos and tells the + gallery, at most every few seconds (it reads the photos again).""" 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): + if not self.handles: + return # unloaded + if self._viewing(dialog_id): + on_ui(lambda: self._append(account, dialog_id), 1000) # once it closed return - args = self._updates.get((account, dialog_id)) + self._appending = True # getDialogPhotos calls back into photos_wanted + try: + holder = MessagesController.getInstance(account).getDialogPhotos(dialog_id) + finally: + self._appending = False + if holder is None: + return + key = (account, dialog_id) + added = self._add_to(account, dialog_id, holder) + if not added and time.monotonic() - self._told.get(key, 0) < _TELL_SECONDS: + return + self._told[key] = time.monotonic() + args = self._updates.get(key) if args is None: from java import jarray from java.lang import Object @@ -273,7 +275,11 @@ class Avatars: 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: + if (account, dialog_id) not in self._recorded: + return + if self._viewing(dialog_id): + on_ui(lambda: self._append(account, dialog_id), 1000) + else: self._add_to(account, dialog_id, holder) # the trash icon diff --git a/src/hooks/chat.py b/src/hooks/chat.py index 207a2cb..55d506b 100644 --- a/src/hooks/chat.py +++ b/src/hooks/chat.py @@ -27,6 +27,7 @@ from base_plugin import MethodHook import diag import header +import media_share import peers import store import tg @@ -229,6 +230,8 @@ class ChatSync: self._inflight = set() # (account, dialog) with a sync running self._exists = {} # (account, dialog) -> ids Telegram still has self._restoring = set() # (account, dialog, id) in flight + self._copies = {} # (account, dialog, id) -> (copy, strip) read, not placed + self._placing = False # a restored message is being placed self._fragments = {} # (account, dialog) -> last ChatActivity seen def install(self): @@ -247,6 +250,18 @@ class ChatSync: self.plugin, CHAT, "updateMessagesVisiblePart", AfterVisible() ) + class BeforeBadge(MethodHook): + def before_hooked_method(self, param): + if sync._placing: + param.setResult(None) # a restored message is no arrival + + self.handles += hook_methods( + self.plugin, + "org.telegram.ui.Components.chat.layouts.ChatActivitySideControlsButtonsLayout", + "setButtonCount", + BeforeBadge(), + ) + def uninstall(self): for name in EDIT_UPDATES: try: @@ -419,6 +434,14 @@ class ChatSync: key = (account, dialog_id, message_id) if key in self._restoring: continue + fetched = self._copies.get(key) + if fetched is not None: # read before; not placed yet + on_ui( + lambda f=fetched, m=message_id, st=state: self._insert( + fragment, account, dialog_id, m, st, *f + ) + ) + continue pointer, strip = self._source(me, chat_id, message_id, state) if pointer is None: continue @@ -440,8 +463,24 @@ class ChatSync: return state.get("logged"), True def _fetch_copy(self, fragment, account, dialog_id, message_id, state, pointer, strip): + """Reads the copy through a member of its log group signed in here + (this account, else the master); another account's download of its + media lets this one show it (media_share).""" channel_id = peers.channel_id_from_marked(pointer["chat_id"]) copy_id = int(pointer["message_id"]) + reader = media_share.reader_for(account, pointer["chat_id"], self.api.masters()) + if reader is None: + self._restoring.discard((account, dialog_id, message_id)) + diag.log(f"copy {pointer}: no account here is in its log group") + return + + def insert(copy): + if reader != account: + ready, total = media_share.copy_media(reader, copy) + if total: + diag.log(f"copy {pointer}: {ready}/{total} media through account {reader}") + self._copies[(account, dialog_id, message_id)] = (copy, strip) + on_ui(lambda: self._insert(fragment, account, dialog_id, message_id, state, copy, strip)) def done(found, error): copy = (found or {}).get(copy_id) @@ -449,22 +488,29 @@ class ChatSync: self._restoring.discard((account, dialog_id, message_id)) diag.log(f"copy {pointer} unavailable: {error}") return - on_ui(lambda: self._insert(fragment, account, dialog_id, message_id, state, copy, strip)) + in_background(lambda: insert(copy)) - tg.get_messages(account, -channel_id, [copy_id], done) + tg.get_messages(reader, -channel_id, [copy_id], done) 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 own dicts and adapter consistent.""" - self._restoring.discard((account, dialog_id, message_id)) + key = (account, dialog_id, message_id) + self._restoring.discard(key) messages = field(fragment, "messages") if messages is None: return if _loaded(fragment, message_id) or any( m is not None and int(m.getId()) == message_id for m in java_list(messages) ): + self._copies.pop(key, None) return + ids = [int(m.getId()) for m in java_list(messages) if m is not None and int(m.getId()) > 0] + at_end = _end_reached(fragment, "forwardEndReached") + if not at_end and (not ids or not min(ids) < message_id < max(ids)): + return # outside what is loaded: placed when that part is + self._copies.pop(key, None) from java import jboolean from java.util import ArrayList @@ -472,7 +518,25 @@ class ChatSync: store.mark_deleted(account, dialog_id, [message_id]) batch = ArrayList() batch.add(obj) - _process_new_messages().invoke(fragment, batch, jboolean(False)) + # Away from the newest messages the app only counts arrivals (the + # scroll-down badge); with the end "loaded" it places the message + # by its id among the loaded ones. + # It is no arrival: the unread count (scroll-down badge) stays. + flags = field(fragment, "forwardEndReached") + unread = int(field(fragment, "newUnreadMessageCount") or 0) + if not at_end: + flags[0] = True + self._placing = True + try: + _process_new_messages().invoke(fragment, batch, jboolean(False)) + finally: + self._placing = False + if not at_end: + flags[0] = False + from hook_utils import set_private_field + from java import jint + + set_private_field(fragment, "newUnreadMessageCount", jint(unread)) diag.log(f"restored {dialog_id}/{message_id}") # cells (UI thread) diff --git a/src/hooks/removed_chats.py b/src/hooks/removed_chats.py index 48e5674..ec31aa6 100644 --- a/src/hooks/removed_chats.py +++ b/src/hooks/removed_chats.py @@ -27,6 +27,7 @@ from base_plugin import MethodHook import accounts import diag +import media_share import peers import store from api import ApiError @@ -226,11 +227,11 @@ class RemovedChats: ) def _load_avatar(self, account, dialog_id, avatar): - found = avatars.logged_photo(account, avatar) + found = avatars.logged_photo(account, avatar, self.api.masters()) if found is None: return photo, reader, copy = found - path = avatars.download(reader, photo, copy, 160) + path = media_share.photo(reader, photo, copy, 160) if path is None: return self._avatar_paths[(account, dialog_id)] = path diff --git a/src/media_share.py b/src/media_share.py new file mode 100644 index 0000000..4ea8f64 --- /dev/null +++ b/src/media_share.py @@ -0,0 +1,135 @@ +"""Logger copies read through an account that can, their media shown by any. + +The log groups' members (the master, accounts added to them) can read the +copies; others can't, not even download a copy's media with its file ids. +So a copy is read through a member signed in here (`reader`: the current +account when it is one, else the master), and its media downloaded through +that member into the app's file directories, which all its accounts share: +the current account then finds the files there. +""" + +import time + +import accounts +from jutil import java_list + +_WAIT_SECONDS = 20 +# Larger documents are left to a tap (and fail for a non-member). +_MAX_DOCUMENT_BYTES = 20 * 1024 * 1024 + + +def reader_for(account, log_chat_id, masters=()): + """A signed-in member of a log group (marked id): `account` when it is + one, else a master, else any; None when none is signed in.""" + import peers + + channel_id = peers.channel_id_from_marked(log_chat_id) + prefer = [account] + [accounts.slot_of(m) for m in masters if m] + readers = accounts.with_access(-channel_id, prefer=prefer) + return readers[0] if readers else None + + +def _wait(path): + deadline = time.monotonic() + _WAIT_SECONDS + while not path.exists() and time.monotonic() < deadline: + time.sleep(0.25) + return path.exists() + + +def _files(photo): + """The photo's sizes that are files (inline thumbnails are not).""" + return [ + size + for size in java_list(photo.sizes) + if getattr(size, "location", None) is not None + and int(size.w or 0) > 0 + and not any( + kind in type(size).__name__ for kind in ("Stripped", "PathSize", "Cached") + ) + ] + + +def photo_size(photo, side): + """The smallest downloadable size at least `side` px (else the largest): + inline thumbnails have no file.""" + sizes = _files(photo) + 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 photo(reader, photo, copy, side): + """Downloads the photo's size closest to `side` px through `reader` into + the shared cache; its path, or None (blocking).""" + size = photo_size(photo, side) + return _photo_size(reader, photo, size, copy) if size is not None else None + + +def photo_sizes(reader, photo, copy): + """Downloads every size of the photo (thumbnails, the viewer's large one) + through `reader`; whether the largest is there (blocking).""" + sizes = _files(photo) + sizes.sort(key=lambda size: int(size.w)) + paths = [_photo_size(reader, photo, size, copy) for size in sizes] + return bool(paths) and paths[-1] is not None + + +def _photo_size(reader, photo, size, copy): + from java import jint + from org.telegram.messenger import FileLoader, ImageLocation, MessageObject + + 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), + ) + return str(path.getAbsolutePath()) if _wait(path) else None + + +def document(reader, document, copy): + """Downloads a document (up to 20 MB) through `reader` where the app + keeps its kind of file; whether it is there (blocking).""" + from java import jint + from org.telegram.messenger import FileLoader, MessageObject + + if int(getattr(document, "size", 0) or 0) > _MAX_DOCUMENT_BYTES: + return False + loader = FileLoader.getInstance(reader) + path = loader.getPathToAttach(document) + if path.exists(): + return True + parent = MessageObject(reader, copy, False, False) + loader.loadFile(document, parent, jint(FileLoader.PRIORITY_HIGH), jint(0)) + return _wait(path) + + +def copy_media(reader, copy): + """Downloads a copy's media (photo, document, a rich message's) through + `reader` before another account shows it (blocking). Returns (ready, + total).""" + media = getattr(copy, "media", None) + photos, documents = [], [] + if getattr(media, "photo", None) is not None: + photos.append(media.photo) + if getattr(media, "document", None) is not None: + documents.append(media.document) + rich = getattr(copy, "rich_message", None) + if rich is not None: + photos += java_list(rich.photos) + documents += java_list(rich.documents) + ready = 0 + for item in photos: + if getattr(item, "sizes", None) is not None: + ready += bool(photo_sizes(reader, item, copy)) + for item in documents: + ready += bool(document(reader, item, copy)) + return ready, len(photos) + len(documents) diff --git a/src/views/removed_chat.py b/src/views/removed_chat.py index 98e933c..cf4f997 100644 --- a/src/views/removed_chat.py +++ b/src/views/removed_chat.py @@ -11,8 +11,8 @@ getHistory/getMessages for it (hooks/removed_chats.py). import threading -import accounts import diag +import media_share import peers import store import tg @@ -124,17 +124,18 @@ class History: if pointer is not None: by_group.setdefault(pointer[0], set()).add(pointer[1]) found = {} + masters = [master, *self.api.masters()] 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: + reader = media_share.reader_for(self.account, log_chat, masters) + if reader is None: 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(): + for message_id, copy in _fetch(reader, -channel_id, chunk).items(): + if reader != self.account: + media_share.copy_media(reader, copy) found[(log_chat, message_id)] = copy return found