2 Commits
Author SHA1 Message Date
lyuksovannyyandClaude Opus 5.5 8535bde5ae Message Logger: trash icon under the profile menu on logged photos
Publish plugin / publish (push) Successful in 4s
The expanded profile gallery shows a red trash icon under the menu button
while it shows a logged photo the chat no longer has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:12:59 +02:00
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 413 additions and 77 deletions

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@
"name": "Message Logger", "name": "Message Logger",
"description": "Shows deleted and edited messages and their edit history from your telegram-panel message logger.", "description": "Shows deleted and edited messages and their edit history from your telegram-panel message logger.",
"author": "@lyuksovannyy", "author": "@lyuksovannyy",
"version": "0.1.3", "version": "0.1.5",
"icon": "exteraGramPluginIcons/4", "icon": "exteraGramPluginIcons/4",
"app_version": ">=12.5.1", "app_version": ">=12.5.1",
"sdk_version": ">=1.4.4.3", "sdk_version": ">=1.4.4.3",
+12
View File
@@ -99,6 +99,18 @@ class Api:
out.append((token[:10], exc)) out.append((token[:10], exc))
return out 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): def sync(self, account_id, chat_id, min_id, max_id, present_ids):
body = { body = {
"account_id": account_id, "account_id": account_id,
+188 -65
View File
@@ -19,20 +19,26 @@ import time
from base_plugin import MethodHook from base_plugin import MethodHook
import accounts
import diag import diag
import media_share
import peers import peers
from api import ApiError 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 from views.history import _epoch
CONTROLLER = "org.telegram.messenger.MessagesController" CONTROLLER = "org.telegram.messenger.MessagesController"
THUMBS = "org.telegram.ui.Components.GroupedPhotosListView" THUMBS = "org.telegram.ui.Components.GroupedPhotosListView"
VIEWER = "org.telegram.ui.PhotoViewer"
GALLERY = "org.telegram.ui.Components.ProfileGalleryView"
OVERLAYS = "org.telegram.ui.ProfileActivity$OverlaysView"
_MARK_TAG = "message_logger_trash"
NOTIFICATIONS = "org.telegram.messenger.NotificationCenter" 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 # 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. # own when one of the chat's photos was set this close to its post.
_SAME_PHOTO_SECONDS = 10 _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): def _photo_of(copy):
@@ -50,65 +56,24 @@ def _photo_of(copy):
return None return None
def logged_photo(account, avatar): def logged_photo(account, avatar, masters=()):
"""(photo, reader slot, post) of a logged chat photo, else None (blocking).""" """(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 from views.removed_chat import _fetch
logged = avatar.get("logged") or {} logged = avatar.get("logged") or {}
if not avatar.get("has_photo") or not logged.get("chat_id"): if not avatar.get("has_photo") or not logged.get("chat_id"):
return None 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"]) 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"]) message_id = int(logged["message_id"])
copy = _fetch(readers[0], -channel_id, [message_id]).get(message_id) copy = _fetch(reader, -channel_id, [message_id]).get(message_id)
photo = _photo_of(copy) found = _photo_of(copy)
return (photo, readers[0], copy) if photo is not None else None return (found, reader, copy) if found 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: class Avatars:
@@ -120,8 +85,14 @@ class Avatars:
self._recorded = {} # (account, dialog) -> [(photo, original id, date)] self._recorded = {} # (account, dialog) -> [(photo, original id, date)]
self._ids = set() # ids of those photos: drawn with the trash icon self._ids = set() # ids of those photos: drawn with the trash icon
self._loading = set() 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._updates = {} # (account, dialog) -> args of its last dialogPhotosUpdate
self._icon = None self._icon = None
self._watched = {} # gallery hash -> its page listener (kept alive)
self._position = None # ProfileGalleryView.getRealPosition
self._shown = None # PhotoViewer.isVisible()
def install(self): def install(self):
avatars = self avatars = self
@@ -133,6 +104,20 @@ class Avatars:
except Exception as exc: except Exception as exc:
diag.error("avatars.photos", exc) diag.error("avatars.photos", exc)
class AfterSetData(MethodHook):
def after_hooked_method(self, param):
try:
avatars.watch_gallery(param.thisObject)
except Exception as exc:
diag.error("avatars.gallery", exc)
class AfterOverlays(MethodHook):
def after_hooked_method(self, param):
try:
avatars.update_profile(field(param.thisObject, "this$0"))
except Exception as exc:
diag.error("avatars.profile", exc)
class AfterThumbs(MethodHook): class AfterThumbs(MethodHook):
def after_hooked_method(self, param): def after_hooked_method(self, param):
try: try:
@@ -149,6 +134,9 @@ class Avatars:
self.handles += hook_methods(self.plugin, CONTROLLER, "getDialogPhotos", AfterPhotos()) self.handles += hook_methods(self.plugin, CONTROLLER, "getDialogPhotos", AfterPhotos())
self.handles += hook_methods(self.plugin, THUMBS, "onDraw", AfterThumbs()) self.handles += hook_methods(self.plugin, THUMBS, "onDraw", AfterThumbs())
self.handles += hook_methods(self.plugin, GALLERY, "setData", AfterSetData())
for name in ("onDraw", "setAlphaValue"):
self.handles += hook_methods(self.plugin, OVERLAYS, name, AfterOverlays())
self.handles += hook_methods( self.handles += hook_methods(
self.plugin, NOTIFICATIONS, "postNotificationName", BeforeNotify() self.plugin, NOTIFICATIONS, "postNotificationName", BeforeNotify()
) )
@@ -166,13 +154,15 @@ class Avatars:
def photos_wanted(self, controller, dialog_id, holder): def photos_wanted(self, controller, dialog_id, holder):
"""getDialogPhotos: the chat's logged photos are loaded once, then """getDialogPhotos: the chat's logged photos are loaded once, then
put into its photos (again after the app reloaded them).""" 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 return
account = int(field(controller, "currentAccount") or 0) account = int(field(controller, "currentAccount") or 0)
key = (account, int(dialog_id)) key = (account, int(dialog_id))
with self._lock: with self._lock:
if key in self._recorded: 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 return
if key in self._loading: if key in self._loading:
return return
@@ -190,20 +180,22 @@ class Avatars:
diag.log(f"avatars {chat_id}: {exc}") diag.log(f"avatars {chat_id}: {exc}")
return return
recorded = [] recorded = []
masters = self.api.masters()
for avatar in logged: for avatar in logged:
found = logged_photo(account, avatar) found = logged_photo(account, avatar, masters)
if found is None: if found is None:
continue continue
photo, reader, copy = found photo, reader, copy = found
download(reader, photo, copy, 160) if not media_share.photo_sizes(reader, photo, copy):
if download(reader, photo, copy, 800) is None: diag.log(f"avatars {chat_id}: logged photo unavailable")
continue continue # it can't be shown
original = avatar.get("photo_id") original = avatar.get("photo_id")
recorded.append( recorded.append(
(photo, int(original) if original else None, _epoch(avatar.get("date"))) (photo, int(original) if original else None, _epoch(avatar.get("date")))
) )
with self._lock: with self._lock:
self._recorded[(account, dialog_id)] = recorded 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) self._ids.update(int(p.id) for p, _o, _d in recorded)
if recorded: if recorded:
diag.log(f"avatars {chat_id}: {len(recorded)} logged photos") diag.log(f"avatars {chat_id}: {len(recorded)} logged photos")
@@ -241,13 +233,45 @@ class Avatars:
added = True added = True
return added 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): 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 from org.telegram.messenger import MessagesController, NotificationCenter
holder = MessagesController.getInstance(account).getDialogPhotos(dialog_id) if not self.handles:
if holder is None or not self._add_to(account, dialog_id, holder): return # unloaded
if self._viewing(dialog_id):
on_ui(lambda: self._append(account, dialog_id), 1000) # once it closed
return 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: if args is None:
from java import jarray from java import jarray
from java.lang import Object from java.lang import Object
@@ -273,7 +297,11 @@ class Avatars:
account = int(field(param.thisObject, "currentAccount") or 0) account = int(field(param.thisObject, "currentAccount") or 0)
dialog_id = int(field(holder, "dialogId") or 0) dialog_id = int(field(holder, "dialogId") or 0)
self._updates[(account, dialog_id)] = extra 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) self._add_to(account, dialog_id, holder)
# the trash icon # the trash icon
@@ -338,6 +366,101 @@ class Avatars:
icon.setBounds(right - size, bottom - size, right, bottom) icon.setBounds(right - size, bottom - size, right, bottom)
icon.draw(canvas) icon.draw(canvas)
# the profile's trash icon (under the menu button)
def watch_gallery(self, gallery):
"""Redraws the icon as the profile's gallery changes page."""
key = int(gallery.hashCode())
if key in self._watched:
return
from java import dynamic_proxy, jclass
listener_class = jclass("androidx.viewpager.widget.ViewPager$OnPageChangeListener")
avatars = self
class Pages(dynamic_proxy(listener_class)):
def onPageScrolled(self, position, offset, pixels):
pass
def onPageSelected(self, position):
on_ui(lambda: avatars.update_profile(field(gallery, "this$0")))
def onPageScrollStateChanged(self, state):
pass
listener = Pages()
gallery.addOnPageChangeListener(listener)
self._watched[key] = listener
def _current_photo(self, gallery):
if self._position is None:
method = java_class(GALLERY).getDeclaredMethod("getRealPosition")
method.setAccessible(True)
self._position = method
photos = field(gallery, "photos")
index = int(self._position.invoke(gallery))
if photos is None or not 0 <= index < photos.size():
return None
return photos.get(index)
def update_profile(self, profile):
"""Shows the icon while the expanded gallery shows a logged photo."""
if profile is None or not self._ids:
return
gallery = field(profile, "avatarsViewPager")
if gallery is None:
return
photo = self._current_photo(gallery)
shown = (
photo is not None
and int(photo.id) in self._ids
and bool(field(profile, "isPulledDown"))
)
mark = self._profile_mark(profile, create=shown)
if mark is None:
return
from android.view import View
mark.setVisibility(View.VISIBLE if shown else View.GONE)
mark.setAlpha(float(field(profile, "currentExpandAnimatorValue") or 1.0))
def _profile_mark(self, profile, create):
root = field(profile, "fragmentView")
if root is None or not hasattr(root, "addView"):
return None
for index in range(root.getChildCount()):
child = root.getChildAt(index)
if str(child.getTag()) == _MARK_TAG:
return child
if not create:
return None
from android.view import Gravity
from android.widget import FrameLayout, ImageView
from java import jint
from org.telegram.messenger import AndroidUtilities
from org.telegram.ui.ActionBar import ActionBar, Theme
from android.graphics import PorterDuff, PorterDuffColorFilter
from org.telegram.messenger import R
from hooks.render import _trash_drawable
dp = AndroidUtilities.dp
icon = _trash_drawable(R.drawable.msg_delete)
red = jint(Theme.getColor(Theme.key_text_RedBold))
icon.setColorFilter(PorterDuffColorFilter(red, PorterDuff.Mode.SRC_IN))
mark = ImageView(root.getContext())
mark.setTag(_MARK_TAG)
mark.setImageDrawable(icon)
mark.setPadding(dp(8), dp(8), dp(8), dp(8))
mark.setBackground(Theme.createCircleDrawable(dp(36), jint(0x66000000)))
params = FrameLayout.LayoutParams(dp(36), dp(36))
params.gravity = Gravity.TOP | Gravity.RIGHT
params.topMargin = AndroidUtilities.statusBarHeight + ActionBar.getCurrentActionBarHeight()
params.rightMargin = dp(10)
root.addView(mark, params)
return mark
def _trash(self): def _trash(self):
if self._icon is None: if self._icon is None:
from android.graphics import Paint, PorterDuff, PorterDuffColorFilter from android.graphics import Paint, PorterDuff, PorterDuffColorFilter
+67 -3
View File
@@ -27,6 +27,7 @@ from base_plugin import MethodHook
import diag import diag
import header import header
import media_share
import peers import peers
import store import store
import tg import tg
@@ -229,6 +230,8 @@ class ChatSync:
self._inflight = set() # (account, dialog) with a sync running self._inflight = set() # (account, dialog) with a sync running
self._exists = {} # (account, dialog) -> ids Telegram still has self._exists = {} # (account, dialog) -> ids Telegram still has
self._restoring = set() # (account, dialog, id) in flight 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 self._fragments = {} # (account, dialog) -> last ChatActivity seen
def install(self): def install(self):
@@ -247,6 +250,18 @@ class ChatSync:
self.plugin, CHAT, "updateMessagesVisiblePart", AfterVisible() 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): def uninstall(self):
for name in EDIT_UPDATES: for name in EDIT_UPDATES:
try: try:
@@ -419,6 +434,14 @@ class ChatSync:
key = (account, dialog_id, message_id) key = (account, dialog_id, message_id)
if key in self._restoring: if key in self._restoring:
continue 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) pointer, strip = self._source(me, chat_id, message_id, state)
if pointer is None: if pointer is None:
continue continue
@@ -440,8 +463,24 @@ class ChatSync:
return state.get("logged"), True return state.get("logged"), True
def _fetch_copy(self, fragment, account, dialog_id, message_id, state, pointer, strip): 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"]) channel_id = peers.channel_id_from_marked(pointer["chat_id"])
copy_id = int(pointer["message_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): def done(found, error):
copy = (found or {}).get(copy_id) copy = (found or {}).get(copy_id)
@@ -449,22 +488,29 @@ class ChatSync:
self._restoring.discard((account, dialog_id, message_id)) self._restoring.discard((account, dialog_id, message_id))
diag.log(f"copy {pointer} unavailable: {error}") diag.log(f"copy {pointer} unavailable: {error}")
return 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): def _insert(self, fragment, account, dialog_id, message_id, state, copy, strip):
"""Hands the rebuilt message to the chat the way new messages arrive """Hands the rebuilt message to the chat the way new messages arrive
(ChatActivity.processNewMessages), so the app places it and keeps its (ChatActivity.processNewMessages), so the app places it and keeps its
own dicts and adapter consistent.""" 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") messages = field(fragment, "messages")
if messages is None: if messages is None:
return return
if _loaded(fragment, message_id) or any( if _loaded(fragment, message_id) or any(
m is not None and int(m.getId()) == message_id for m in java_list(messages) m is not None and int(m.getId()) == message_id for m in java_list(messages)
): ):
self._copies.pop(key, None)
return 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 import jboolean
from java.util import ArrayList from java.util import ArrayList
@@ -472,7 +518,25 @@ class ChatSync:
store.mark_deleted(account, dialog_id, [message_id]) store.mark_deleted(account, dialog_id, [message_id])
batch = ArrayList() batch = ArrayList()
batch.add(obj) 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)) _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}") diag.log(f"restored {dialog_id}/{message_id}")
# cells (UI thread) # cells (UI thread)
+3 -2
View File
@@ -27,6 +27,7 @@ from base_plugin import MethodHook
import accounts import accounts
import diag import diag
import media_share
import peers import peers
import store import store
from api import ApiError from api import ApiError
@@ -226,11 +227,11 @@ class RemovedChats:
) )
def _load_avatar(self, account, dialog_id, avatar): 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: if found is None:
return return
photo, reader, copy = found photo, reader, copy = found
path = avatars.download(reader, photo, copy, 160) path = media_share.photo(reader, photo, copy, 160)
if path is None: if path is None:
return return
self._avatar_paths[(account, dialog_id)] = path 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 threading
import accounts
import diag import diag
import media_share
import peers import peers
import store import store
import tg import tg
@@ -124,17 +124,18 @@ class History:
if pointer is not None: if pointer is not None:
by_group.setdefault(pointer[0], set()).add(pointer[1]) by_group.setdefault(pointer[0], set()).add(pointer[1])
found = {} found = {}
masters = [master, *self.api.masters()]
for log_chat, ids in by_group.items(): for log_chat, ids in by_group.items():
channel_id = peers.channel_id_from_marked(log_chat) channel_id = peers.channel_id_from_marked(log_chat)
readers = accounts.with_access( reader = media_share.reader_for(self.account, log_chat, masters)
-channel_id, prefer=(self.account, accounts.slot_of(master)) if reader is None:
)
if not readers:
continue continue
ordered = sorted(ids) ordered = sorted(ids)
for start in range(0, len(ordered), _COPIES_PER_REQUEST): for start in range(0, len(ordered), _COPIES_PER_REQUEST):
chunk = ordered[start : start + _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 found[(log_chat, message_id)] = copy
return found return found