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 414 additions and 78 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.5",
"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,
+188 -65
View File
@@ -19,20 +19,26 @@ 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"
GALLERY = "org.telegram.ui.Components.ProfileGalleryView"
OVERLAYS = "org.telegram.ui.ProfileActivity$OverlaysView"
_MARK_TAG = "message_logger_trash"
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 +56,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 +85,14 @@ 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._watched = {} # gallery hash -> its page listener (kept alive)
self._position = None # ProfileGalleryView.getRealPosition
self._shown = None # PhotoViewer.isVisible()
def install(self):
avatars = self
@@ -133,6 +104,20 @@ class Avatars:
except Exception as 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):
def after_hooked_method(self, param):
try:
@@ -149,6 +134,9 @@ class Avatars:
self.handles += hook_methods(self.plugin, CONTROLLER, "getDialogPhotos", AfterPhotos())
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.plugin, NOTIFICATIONS, "postNotificationName", BeforeNotify()
)
@@ -166,14 +154,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 +180,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 +233,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 +297,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
@@ -338,6 +366,101 @@ class Avatars:
icon.setBounds(right - size, bottom - size, right, bottom)
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):
if self._icon is None:
from android.graphics import Paint, PorterDuff, PorterDuffColorFilter
+68 -4
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)
_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)
+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