generated from exteraGramPlugins/pluginBase
Message Logger: restore deep in long chats, media through the master, photo fixes
Publish plugin / publish (push) Successful in 4s
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>
This commit is contained in:
1 parent
362b358d8d
commit
dba0c4f833
7 files changed
+297
-78
No files matched your search
+71
-65
@@ -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
|
||||
|
||||
+68
-4
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user