1 Commits
Author SHA1 Message Date
lyuksovannyyandClaude Opus 5.5 dba0c4f833 Message Logger: restore deep in long chats, media through the master, photo fixes
Publish plugin / publish (push) Successful in 4s
- 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 <noreply@anthropic.com>
2026-10-11 01:12:23 +02:00
7 changed files with 296 additions and 77 deletions

No files matched your search

+1 -1
View File
@@ -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",
+12
View File
@@ -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,
+71 -65
View File
@@ -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
reader = media_share.reader_for(
account, logged["chat_id"], [avatar.get("master_tg_id"), *masters]
)
if reader is None:
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
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,13 +132,15 @@ 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)
# 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
@@ -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
+67 -3
View File
@@ -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)
# 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)
+3 -2
View File
@@ -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
+135
View File
@@ -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)
+7 -6
View File
@@ -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