4 Commits
Author SHA1 Message Date
lyuksovannyy 5141f47878 Message Logger: removed forums load, stay smooth and cached
Publish plugin / publish (push) Successful in 4s
- Topics load fully again: the bottom-bar cleanup no longer hides the
  message list; each page fetches a full window from the server and
  answers only what the app asked for.
- Reopened chats and topics come from memory (pages, logged copies,
  downloaded media); pages are also kept on the device for restarts and
  server timeouts, with one retry before falling back.
- Media downloads run in one background queue per chat instead of
  blocking page answers or spawning a thread per page.
- The deleted badge is drawn in DialogCell.drawAvatarOverlays, under the
  avatar's own transform, so it follows the avatar through transitions;
  its paints are built once and the hot hooks skip reflection.
- Senders keep their last known profile (name, username, photo
  reference) when the app no longer has them (people.py).
- Removed chats are searchable, show "deleted chat" only in place of the
  loading subtitle, hide the join bar, order topics by activity, and
  have no unread counters.
- Uninstall takes removed chats out on the UI thread.
2026-10-14 17:31:22 +02:00
lyuksovannyyandClaude Opus 5.5 5e4c2f2a6f Message Logger: deleted dates of logged photos, open them without a photo
Publish plugin / publish (push) Successful in 4s
- The photo viewer's subtitle of a logged photo the chat no longer has
  says when it was deleted and set.
- Tapping the avatar of a chat without a photo opens the viewer on its
  photos, the logged ones included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:16:42 +02:00
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
11 changed files with 1870 additions and 197 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.7",
"icon": "exteraGramPluginIcons/4",
"app_version": ">=12.5.1",
"sdk_version": ">=1.4.4.3",
+17 -3
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,
@@ -197,10 +209,12 @@ class Api:
chats.setdefault(int(chat["chat_id"]), chat)
return list(chats.values()) if any_result else None
def history(self, account_id, chat_id, before=0, limit=100):
"""Logged messages of a chat older than `before`, newest first: the
first master that has any."""
def history(self, account_id, chat_id, before=0, limit=100, topic_id=None):
"""Logged messages older than `before`, newest first. For a forum
topic, pass its source topic id; 0 selects General."""
params = {"account_id": account_id, "before": int(before), "limit": int(limit)}
if topic_id is not None:
params["topic_id"] = int(topic_id)
for _token, result in self._each(
"GET", f"/chats/{chat_id}/history", params=params
):
+284 -66
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
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 +85,15 @@ 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._dates = {} # logged photo id -> (set at, deleted at), ISO strings
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 +105,35 @@ 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 AfterSwitch(MethodHook):
def after_hooked_method(self, param):
try:
avatars.viewer_dates(param.thisObject, int(param.args[0]))
except Exception as exc:
diag.error("avatars.dates", exc)
class BeforeOpen(MethodHook):
def before_hooked_method(self, param):
try:
if avatars.open_without_photo(param.thisObject):
param.setResult(None)
except Exception as exc:
diag.error("avatars.open", exc)
class AfterThumbs(MethodHook):
def after_hooked_method(self, param):
try:
@@ -149,6 +150,15 @@ 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())
self.handles += hook_methods(
self.plugin, VIEWER, "setIsAboutToSwitchToIndex", AfterSwitch()
)
self.handles += hook_methods(
self.plugin, "org.telegram.ui.ProfileActivity", "openAvatar", BeforeOpen()
)
for name in ("onDraw", "setAlphaValue"):
self.handles += hook_methods(self.plugin, OVERLAYS, name, AfterOverlays())
self.handles += hook_methods(
self.plugin, NOTIFICATIONS, "postNotificationName", BeforeNotify()
)
@@ -166,13 +176,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 +202,23 @@ 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")))
)
self._dates[int(photo.id)] = (avatar.get("date"), avatar.get("deleted_at"))
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 +256,45 @@ class Avatars:
added = True
return added
def _append(self, account, dialog_id):
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, force=False):
"""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 not force and 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 +320,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 +389,173 @@ 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
# dates in the photo viewer
def viewer_dates(self, viewer, index):
"""A logged photo's subtitle: when it was deleted and set (instead of
its upload to the log group)."""
photos = field(viewer, "avatarsArr")
if photos is None or not 0 <= index < photos.size():
return
photo = photos.get(index)
dates = self._dates.get(int(photo.id)) if photo is not None else None
container = field(viewer, "actionBarContainer")
if dates is None or container is None:
return
from views.history import _when
set_at, deleted_at = dates
parts = [f"deleted {_when(deleted_at)}" if deleted_at else "deleted"]
if set_at:
parts.append(f"set {_when(set_at)}")
text = " · ".join(parts)
container.setSubtitle(text[:1].upper() + text[1:])
# a chat without a photo
def open_without_photo(self, profile):
"""openAvatar of a chat that has no photo but logged ones: the photo
viewer opens on the newest logged photo (with the rest)."""
from org.telegram.messenger import ImageLocation, MessagesController
from org.telegram.tgnet import TLRPC
from org.telegram.ui import PhotoViewer
account = int(profile.getCurrentAccount())
controller = MessagesController.getInstance(account)
user_id, chat_id = int(field(profile, "userId") or 0), int(field(profile, "chatId") or 0)
if user_id:
peer, dialog_id = controller.getUser(user_id), user_id
empty = TLRPC.TL_userProfilePhotoEmpty
else:
peer, dialog_id = controller.getChat(chat_id), -chat_id
empty = TLRPC.TL_chatPhotoEmpty
photo = getattr(peer, "photo", None) if peer is not None else None
if photo is not None and not isinstance(photo, empty):
return False # the app opens it
recorded = self._recorded.get((account, dialog_id))
if not recorded:
controller.getDialogPhotos(dialog_id) # loads the logged ones
return False
# The newest of the chat's photos (its own, then the logged ones).
holder = controller.getDialogPhotos(dialog_id)
if holder is not None:
self._add_to(account, dialog_id, holder)
listed = java_list(field(holder, "photos")) if holder is not None else []
first = next((p for p in listed if p is not None), recorded[0][0])
size = media_share.photo_size(first, 800)
if size is None:
return False
viewer = PhotoViewer.getInstance()
viewer.setParentActivity(profile)
viewer.openPhoto(
size.location, ImageLocation.getForPhoto(size, first), field(profile, "provider")
)
# The profile names no chat for a photo it doesn't show itself: the
# viewer is told, and gets the chat's photos (the logged ones).
from hook_utils import set_private_field
from java import jlong
set_private_field(viewer, "avatarsDialogId", jlong(dialog_id))
self._told.pop((account, dialog_id), None)
self._append(account, dialog_id, force=True)
return True
def _trash(self):
if self._icon is None:
from android.graphics import Paint, PorterDuff, PorterDuffColorFilter
+83 -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
@@ -217,9 +218,25 @@ def build_from_copy(account, dialog_id, message_id, state, copy, strip):
own = peers.own_user_id(account)
msg.out = sender is not None and int(sender) == own
msg.unread = False
if sender:
_remember_sender(account, _android_peer_id(sender))
return MessageObject(account, msg, True, True)
def _remember_sender(account, peer_id):
"""Snapshot the sender's profile while the app can still read it, so it
can be shown later when the app no longer has it."""
try:
import people
if peer_id > 0:
people.user(account, peer_id)
elif peer_id < 0:
people.chat(account, -peer_id)
except Exception as exc:
diag.error("chat.remember_sender", exc)
class ChatSync:
def __init__(self, plugin, api):
self.plugin = plugin
@@ -229,6 +246,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 +266,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 +450,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 +479,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 +504,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 +534,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)
+602 -47
View File
@@ -27,12 +27,13 @@ from base_plugin import MethodHook
import accounts
import diag
import media_share
import peers
import store
from api import ApiError
from config import DEFAULTS, SHOW_REMOVED_CHATS
from hooks import avatars
from jutil import field, hook_methods, in_background, java_list, on_ui
from jutil import cached_field, field, hook_methods, in_background, java_list, on_ui
from views import removed_chat
from views.comments import make_read_only
from views.history import _epoch
@@ -42,13 +43,19 @@ CONNECTIONS = "org.telegram.tgnet.ConnectionsManager"
CHAT = "org.telegram.ui.ChatActivity"
IMAGES = "org.telegram.messenger.ImageReceiver"
STORAGE = "org.telegram.messenger.MessagesStorage"
_NOTICE = "Removed chat · kept by the logger"
TOPICS = "org.telegram.messenger.TopicsController"
FORUM = "org.telegram.ui.TopicsFragment"
SEARCH = "org.telegram.ui.Adapters.DialogsSearchAdapter"
_NOTICE = "Viewing deleted chat/topic"
_REFRESH_SECONDS = 900
_REPORT_AGAIN_SECONDS = 30
_ANSWERED = (
"TLRPC$TL_messages_getHistory",
"TLRPC$TL_channels_getMessages",
"TLRPC$TL_messages_search",
"TL_messages_getHistory",
"TL_channels_getMessages",
"TL_messages_search",
"TL_messages_getForumTopics",
"TL_messages_getForumTopicsByID",
"TL_messages_getReplies",
)
@@ -68,8 +75,44 @@ def _dialog_id(chat_id):
return _android_peer_id(int(chat_id))
def _topic_start(controller, chat_id, topic):
"""The topic's "topic created" message, as the app builds one when it
has none (TopicsController.processTopics)."""
from org.telegram.messenger import MessagesController
from org.telegram.tgnet import TLRPC
start = TLRPC.TL_message()
start.message = ""
start.id = int(topic.id)
start.peer_id = MessagesController.getInstance(
int(field(controller, "currentAccount") or 0)
).getPeer(-int(chat_id))
start.action = TLRPC.TL_messageActionTopicCreate()
start.action.title = topic.title
return start
def _topics_loaded(account, chat_id):
from java import jarray, jlong
from java.lang import Boolean, Long, Object
from org.telegram.messenger import NotificationCenter
NotificationCenter.getInstance(account).postNotificationName(
NotificationCenter.topicsDidLoaded,
jarray(Object)([Long.valueOf(jlong(int(chat_id))), Boolean.TRUE]),
)
# Request classes with no peer field: cached so the hook on every outgoing
# request skips them without any reflection.
_NO_TARGET = set()
def _target(request):
"""The dialog a request is about (its peer/channel/user), else None."""
kind_name = type(request).__name__
if kind_name in _NO_TARGET:
return None
for name in ("peer", "channel", "user_id", "id"):
value = getattr(request, name, None)
if value is None or isinstance(value, (bool, int, float, str)):
@@ -81,9 +124,114 @@ def _target(request):
return -int(value.chat_id)
if "nputPeerUser" in kind or kind.endswith("TL_inputUser"):
return int(value.user_id)
if not any(hasattr(request, n) for n in ("peer", "channel", "user_id")):
_NO_TARGET.add(kind_name)
return None
_SUBTITLE = "deleted chat"
def _clear_loading(chat):
"""Replace the header's "connecting/loading" subtitle on a removed chat.
The app keeps that subtitle until a channel-info reply arrives, and for
a removed chat no reply ever comes (its requests are answered here or
dropped), so it would say "loading" forever."""
try:
container = field(chat, "avatarContainer")
if container is None:
return
view = field(container, "subtitleTextView")
current = ""
if view is not None:
try:
current = str(view.getText() or "")
except Exception:
current = ""
# Only replace the placeholder: a topic header legitimately shows
# "In <chat>", and overwriting it made the two flicker.
if current and not _is_loading(current):
return
container.setSubtitle(_SUBTITLE)
except Exception as exc:
diag.error("removed_chats.subtitle", exc)
_LOADING_KEYS = ("Loading", "Connecting", "Updating", "WaitingForNetwork", "ConnectingToProxy")
_loading_texts = None
def _is_loading(text):
"""Whether a header subtitle is one of the app's placeholder strings.
Read from the app's own localisation, so this works in any language
(the device shows Dutch: "Bezig met laden")."""
global _loading_texts
if _loading_texts is None:
found = set()
try:
from org.telegram.messenger import LocaleController
for key in _LOADING_KEYS:
try:
value = LocaleController.getString(key)
if value:
found.add(str(value).strip().lower())
except Exception:
pass
except Exception as exc:
diag.error("removed_chats.loading_strings", exc)
_loading_texts = found
cleaned = str(text).strip().lower().rstrip(".…")
return any(cleaned == known.rstrip(".…") for known in _loading_texts)
def _hide_forum_join(fragment):
"""Hide a removed forum's "join group" bar.
TopicsFragment shows the bar for a chat the account is not in, but a
removed chat cannot be joined: the button would send a request that is
answered here (or dropped) and never completes."""
try:
from android.view import View
for name in ("bottomOverlayContainer", "bottomOverlayChatText",
"bottomOverlayProgress"):
view = cached_field(fragment, name)
if view is None or not hasattr(view, "setVisibility"):
continue
try:
# Only when visible: setVisibility on an already-gone view
# still requests a layout pass during the open animation.
if view.getVisibility() != View.GONE:
view.setVisibility(View.GONE)
except Exception:
pass
except Exception as exc:
diag.error("removed_chats.forum_join", exc)
def _badge_paints():
"""(icon, red paint, ring paint, dp table) for the deleted badge, built
once: the badge is drawn every frame for every visible removed chat."""
from android.graphics import Paint, PorterDuff, PorterDuffColorFilter
from java import jint
from org.telegram.messenger import AndroidUtilities, R
from org.telegram.ui.ActionBar import Theme
from hooks.render import _trash_drawable
icon = _trash_drawable(R.drawable.msg_delete)
icon.setColorFilter(PorterDuffColorFilter(jint(-1), PorterDuff.Mode.SRC_IN))
red = Paint(Paint.ANTI_ALIAS_FLAG)
red.setColor(jint(Theme.getColor(Theme.key_text_RedBold)))
ring = Paint(Paint.ANTI_ALIAS_FLAG)
ring.setColor(jint(Theme.getColor(Theme.key_windowBackgroundWhite)))
dp = {v: float(AndroidUtilities.dp(v)) for v in (1.5, 8, 11, 54)}
return icon, red, ring, dp
class RemovedChats:
def __init__(self, plugin, api):
self.plugin = plugin
@@ -94,7 +242,14 @@ class RemovedChats:
# account -> {dialog_id: (Dialog, [MessageObject], its top message id)}
self._injected = {}
self._histories = {} # (account, dialog_id) -> views.removed_chat.History
# (account, dialog_id) -> java ArrayList of TL_forumTopic (removed forums)
self._topics = {}
self._topic_index = {} # (account, dialog_id) -> {topic id: TL_forumTopic}
self._topics_loading = set()
self._badge = None # (trash icon, red paint, ring paint)
self._search_logged = False
self._avatar_paths = {} # (account, dialog_id) -> local photo path
self._positions = {} # (account, dialog_id) -> saved dialog placement
self._reported = {} # (account, dialog_id) -> monotonic time
self._lock = threading.Lock()
self._timer = None
@@ -130,6 +285,13 @@ class RemovedChats:
hook(CHAT, "updateBottomOverlay", after=self.after_overlay)
hook(STORAGE, "putMessages", before=self.before_store)
hook(IMAGES, "setForUserOrChat", before=self.before_avatar, arity=6)
hook("org.telegram.ui.Cells.DialogCell", "drawAvatarOverlays", after=self.after_cell_draw, arity=1)
hook(TOPICS, "getTopics", before=self.before_topics)
hook(TOPICS, "findTopic", before=self.before_find_topic, arity=2)
hook(TOPICS, "loadTopics", before=self.before_load_topics, arity=3)
hook(FORUM, "updateSubtitle", after=self.after_forum_subtitle)
hook(FORUM, "onResume", after=self.after_forum_resume)
hook(SEARCH, "searchDialogs", after=self.after_search_dialogs, arity=3)
in_background(self._start)
def _start(self):
@@ -138,6 +300,15 @@ class RemovedChats:
cached = store.get_meta(f"removed_chats:{account}")
if cached:
self._set_list(account, json.loads(cached))
saved = store.get_meta(f"removed_positions:{account}")
if saved:
try:
self._positions.update(
{(account, int(dialog_id)): value
for dialog_id, value in json.loads(saved).items()}
)
except Exception as exc:
diag.error("removed_chats.positions", exc)
on_ui(self.inject_all)
self._schedule(5)
@@ -150,13 +321,21 @@ class RemovedChats:
except Exception:
pass
self.handles = []
for account in list(self._injected):
# inject() re-sorts the app's chat list and redraws it, which must
# happen on the UI thread; uninstall runs on the plugins thread.
accounts_listed = list(self._injected)
for account in accounts_listed:
self._lists[account] = {}
def take_out():
for account in accounts_listed:
try:
self.inject(account)
except Exception as exc:
diag.error("removed_chats.uninstall", exc)
on_ui(take_out)
# the server's list (background)
def _schedule(self, seconds):
@@ -185,11 +364,26 @@ class RemovedChats:
return
if chats is None:
return
previous = self._lists.get(account) or {}
store.put_meta(f"removed_chats:{account}", json.dumps(chats))
self._set_list(account, chats)
# Only the chats whose server snapshot really changed lose their
# cached history/topics. Wiping every chat each cycle made an open
# forum reload its 17 topics on every refresh (topicsDidLoaded ->
# DiffUtil -> full relayout), which is what made it crawl.
with self._lock:
for key in [k for k in self._histories if k[0] == account]:
del self._histories[key] # read again on next open
for dialog_id, item in (self._lists.get(account) or {}).items():
before = previous.get(dialog_id)
if before is not None and before == item:
continue
self._histories.pop((account, dialog_id), None)
self._topics.pop((account, dialog_id), None)
self._topic_index.pop((account, dialog_id), None)
for dialog_id in previous:
if dialog_id not in (self._lists.get(account) or {}):
self._histories.pop((account, dialog_id), None)
self._topics.pop((account, dialog_id), None)
self._topic_index.pop((account, dialog_id), None)
on_ui(lambda: self.inject(account))
for dialog_id, item in self._lists.get(account, {}).items():
avatar = item.get("avatar") or {}
@@ -206,31 +400,20 @@ class RemovedChats:
self._lists[account] = lists
def _peer(self, account, dialog_id, item):
"""The app's user/chat (cache or storage), else a stand-in."""
from org.telegram.messenger import MessagesController, MessagesStorage
"""The removed chat's user/chat: the app's, else the last snapshot
saved while it was readable, else a stand-in named from the logger."""
import people
import tg
controller = MessagesController.getInstance(account)
if dialog_id > 0:
user = controller.getUser(dialog_id)
if user is None:
try:
user = MessagesStorage.getInstance(account).getUserSync(dialog_id)
except Exception:
user = None
return user or removed_chat.stand_in_user(controller, dialog_id, item.get("title"))
chat = tg.channel_chat(account, -dialog_id)
return chat or removed_chat.stand_in_chat(
controller, -dialog_id, item.get("title"), item.get("kind")
)
return people.user(account, dialog_id, item.get("title"))
return people.chat(account, -dialog_id, item.get("title"), item.get("kind"))
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
@@ -243,16 +426,14 @@ class RemovedChats:
self.inject(account)
def inject(self, account):
"""Puts the account's removed chats into the app's chat list (and
takes out those no longer removed)."""
"""Puts the account's removed chats into the app's chat list."""
from org.telegram.messenger import MessagesController
controller = MessagesController.getInstance(account)
wanted = {}
if self._enabled():
wanted = {
d: item
for d, item in self._lists.get(account, {}).items()
d: item for d, item in self._lists.get(account, {}).items()
if not item.get("hidden_at") and item.get("top_message")
}
current = self._injected.setdefault(account, {})
@@ -261,8 +442,23 @@ class RemovedChats:
self._take_out(controller, dialog_id, current.pop(dialog_id)[0])
store.set_removed_dialog(account, dialog_id, False)
for dialog_id, item in wanted.items():
if dialog_id in current or controller.dialogs_dict.get(dialog_id) is not None:
continue # listed, or the app has the chat (it is back)
if dialog_id in current:
continue
existing = controller.dialogs_dict.get(dialog_id)
if existing is not None:
# Reloads can leave a previously injected dialog in the
# controller while the in-memory membership cache is reset.
# Match its snapshot top message and re-adopt it.
if int(getattr(existing, "top_message", 0)) == int(item["top_message"]["message_id"]):
current[dialog_id] = (
existing,
controller.dialogMessage.get(dialog_id),
int(existing.top_message),
)
# Without this the normal chat sync treats the removed
# chat as live after a reload and re-syncs its history.
store.set_removed_dialog(account, dialog_id, True)
continue
current[dialog_id] = self._dialog(controller, account, dialog_id, item)
store.set_removed_dialog(account, dialog_id, True)
self._put(controller, account)
@@ -283,14 +479,22 @@ class RemovedChats:
dialog.id = dialog_id
dialog.peer = controller.getPeer(dialog_id)
dialog.top_message = int(top["message_id"])
# Deleted chats are historical snapshots: never expose stale unread
# state, otherwise Telegram draws a counter over our deleted badge.
for name, value in (("unread_count", 0), ("unread_mentions_count", 0), ("unread_marked", False)):
if hasattr(dialog, name):
setattr(dialog, name, value)
dialog.read_inbox_max_id = dialog.top_message
dialog.read_outbox_max_id = dialog.top_message
dialog.last_message_date = _epoch(top.get("source_date"))
saved = self._positions.get((account, dialog_id)) or {}
for name in ("pinned", "folder_id", "pinnedNum"):
if name in saved and hasattr(dialog, name):
setattr(dialog, name, saved[name])
from java.util import ArrayList
message = removed_chat.from_text(account, dialog_id, top)
store.put_server_states(account, dialog_id, [top])
# dialogMessage holds the dialog's preview messages (an album's parts).
preview = ArrayList()
preview.add(MessageObject(account, message, False, False))
return dialog, preview, int(dialog.top_message)
@@ -313,6 +517,27 @@ class RemovedChats:
dialogs.put(dialog_id, dialog)
if not everything.contains(dialog):
everything.add(dialog)
# Chat search reads its own dialog lists, not allDialogs. Mirror
# the dialog into each one the controller keeps, so a removed
# chat can be found by name like any other.
if dialog_id > 0:
kind_lists = ("dialogsUsersOnly",)
else:
peer = self._peers.get((account, dialog_id))
broadcast = bool(getattr(peer, "broadcast", False))
kind_lists = (
("dialogsChannelsOnly", "dialogsMyChannels") if broadcast
else ("dialogsGroupsOnly", "dialogsMyGroups", "dialogsCanAddUsers")
)
for name in ("dialogsServerOnly", "dialogsForward") + kind_lists:
extra = field(controller, name)
if extra is None or not hasattr(extra, "contains"):
continue
try:
if not extra.contains(dialog):
extra.add(dialog)
except Exception:
pass
if messages.get(dialog_id) is None:
messages.put(dialog_id, message)
@@ -323,6 +548,15 @@ class RemovedChats:
controller.dialogs_dict.remove(dialog_id)
controller.dialogMessage.remove(dialog_id)
controller.allDialogs.remove(dialog)
for name in ("dialogsServerOnly", "dialogsForward", "dialogsUsersOnly",
"dialogsGroupsOnly", "dialogsChannelsOnly", "dialogsMyGroups",
"dialogsMyChannels", "dialogsCanAddUsers"):
extra = field(controller, name)
if extra is not None and hasattr(extra, "remove"):
try:
extra.remove(dialog)
except Exception:
pass
def _redraw(self, account):
from java import jarray
@@ -334,7 +568,9 @@ class RemovedChats:
)
def before_sort(self, param):
account = int(field(param.thisObject, "currentAccount") or 0)
if not self._injected:
return
account = int(cached_field(param.thisObject, "currentAccount") or 0)
if self._injected.get(account):
self._put(param.thisObject, account)
@@ -367,6 +603,9 @@ class RemovedChats:
return
if only_history == 1 or not self.api.configured:
return
existing = param.thisObject.dialogs_dict.get(dialog_id)
if existing is not None:
self._save_position(account, dialog_id, existing)
key = (account, dialog_id)
now = time.monotonic()
if now - self._reported.get(key, -_REPORT_AGAIN_SECONDS) < _REPORT_AGAIN_SECONDS:
@@ -377,6 +616,88 @@ class RemovedChats:
return
own = _from_screens()
in_background(lambda: self._report(account, chat_id, own))
def after_search_dialogs(self, param):
"""searchDialogs(query, ...) ran: append matching removed chats.
The app searches its own storage, which removed chats are kept out
of on purpose (they must not become cached app chats), so they never
appear on their own. The adapter's result lists are filled
asynchronously, so the chats are added once those lists exist and
the adapter is told to redraw."""
try:
query = ""
for arg in param.args or []:
if isinstance(arg, str):
query = arg
break
query = (query or "").strip().lower()
if not query or not any(self._injected.values()):
return
adapter = param.thisObject
# The lists are filled asynchronously, so append a moment later
# (twice: the adapter may refill them). Each pass re-reads the
# adapter's current query, so a stale keystroke never adds rows
# for a query the user has already moved past.
on_ui(lambda: self._append_removed(adapter, query), 150)
on_ui(lambda: self._append_removed(adapter, query), 500)
except Exception as exc:
diag.error("removed_chats.search_dialogs", exc)
def _append_removed(self, adapter, query):
"""Put the removed chats matching `query` into the adapter's results."""
try:
# Prefer the adapter's own current query: this runs delayed, and
# the user may have typed more since.
current = field(adapter, "lastSearchText")
if current is not None:
current = str(current).strip().lower()
if current and current != query:
query = current
if not query:
return
result = field(adapter, "searchResult")
names = field(adapter, "searchResultNames")
if result is None or names is None:
return
listed = set()
for index in range(result.size()):
value = getattr(result.get(index), "id", None)
if value is not None:
listed.add(int(value))
from android.text import SpannableStringBuilder
added = 0
for account in list(self._injected):
for dialog_id in list(self._injected.get(account) or {}):
if not self.is_injected(account, dialog_id):
continue
peer = self._peers.get((account, dialog_id))
if peer is None:
continue
title = str(getattr(peer, "title", "") or "").strip()
if not title:
first = str(getattr(peer, "first_name", "") or "")
last = str(getattr(peer, "last_name", "") or "")
title = (first + " " + last).strip()
if not title or query not in title.lower():
continue
key = int(getattr(peer, "id", 0) or 0)
if key in listed:
continue
result.add(peer)
names.add(SpannableStringBuilder(title))
listed.add(key)
added += 1
if added:
try:
adapter.notifyDataSetChanged()
except Exception:
pass
if not self._search_logged:
self._search_logged = True
diag.log(f"search: added {added} removed chat(s) for {query!r}")
except Exception as exc:
diag.error("removed_chats.append_removed", exc)
def _report(self, account, chat_id, own):
try:
@@ -389,6 +710,41 @@ class RemovedChats:
time.sleep(2)
self.refresh(account)
def after_forum_subtitle(self, param):
"""A removed forum's header: the app leaves it on "loading".
TopicsFragment (not ChatActivity) owns the topic list's header, and
it keeps the loading subtitle until channel info arrives, which
never does for a chat whose requests are answered here."""
fragment = param.thisObject
chat_id = int(cached_field(fragment, "chatId") or 0)
if not chat_id:
return
account = int(cached_field(fragment, "currentAccount") or 0)
if not self.is_injected(account, -chat_id):
return
_hide_forum_join(fragment)
container = cached_field(fragment, "avatarContainer")
if container is None:
return
try:
# Replace ONLY the "connecting/updating/loading" placeholder.
# Overwriting unconditionally fought the app's own subtitle and
# made the header flicker between it and ours every tick.
view = cached_field(container, "subtitleTextView")
current = ""
if view is not None:
try:
current = str(view.getText() or "")
except Exception:
current = ""
if current == _SUBTITLE:
return
if not current or _is_loading(current):
container.setSubtitle(_SUBTITLE)
except Exception as exc:
diag.error("removed_chats.forum_subtitle", exc)
def _hide(self, account, dialog_id):
item = self._lists.get(account, {}).get(dialog_id)
if item is None:
@@ -407,17 +763,43 @@ class RemovedChats:
# an opened removed chat
def after_forum_resume(self, param):
"""A removed forum became visible: hide its join bar.
updateSubtitle is not called on every open, so the bar is hidden
here as well (and again once the screen settles)."""
fragment = param.thisObject
chat_id = int(cached_field(fragment, "chatId") or 0)
if not chat_id:
return
account = int(cached_field(fragment, "currentAccount") or 0)
if not self.is_injected(account, -chat_id):
return
_hide_forum_join(fragment)
on_ui(lambda: _hide_forum_join(fragment), 300)
on_ui(lambda: _hide_forum_join(fragment), 900)
def after_overlay(self, param):
chat = param.thisObject
if self.is_injected(chat.getCurrentAccount(), chat.getDialogId()):
make_read_only(chat, _NOTICE)
# Again once the screen settled (a channel's bar comes back).
on_ui(lambda: make_read_only(chat, _NOTICE), 600)
_clear_loading(chat)
on_ui(lambda: (make_read_only(chat, _NOTICE), _clear_loading(chat)), 600)
def _save_position(self, account, dialog_id, dialog):
saved = {}
for name in ("pinned", "folder_id", "pinnedNum"):
value = field(dialog, name)
if value is not None:
saved[name] = int(value) if isinstance(value, (int, float)) else value
self._positions[(account, dialog_id)] = saved
store.put_meta(
f"removed_positions:{account}",
json.dumps({str(d): value for (a, d), value in self._positions.items() if a == account}),
)
def before_store(self, param):
"""putMessages(messages_Messages, dialogId, ...): a removed chat's
logged messages are not stored as the app's own (they would make it
a chat of the app's, read from its cache from then on)."""
"""Prevent logged messages from becoming Telegram's cached history."""
args = param.args
if len(args) < 2 or not hasattr(args[0], "messages"):
return
@@ -428,7 +810,6 @@ class RemovedChats:
done = args[-1]
if done is not None and hasattr(done, "run"):
done.run()
def _history(self, account, dialog_id):
with self._lock:
history = self._histories.get((account, dialog_id))
@@ -441,15 +822,106 @@ class RemovedChats:
history.load()
return history
def _removed_forum(self, param):
"""(account, chat id) when a TopicsController call is about a
removed chat listed here, else None. Called during the topic list's
layout, so it does no reflection unless a removed chat is listed."""
if not param.args or not any(self._injected.values()):
return None
chat_id = int(param.args[0])
account = int(cached_field(param.thisObject, "currentAccount") or 0)
if not self.is_injected(account, -chat_id):
return None
return account, chat_id
def before_topics(self, param):
"""TopicsController.getTopics(chatId): a removed forum's topics are
the logger's (from its history), not those the app cached while the
account was in it. Loaded on first use; empty until then."""
found = self._removed_forum(param)
if found is None:
return
account, chat_id = found
topics = self._topics.get((account, -chat_id))
if topics is None:
from java.util import ArrayList
self._load_topics(param.thisObject, account, chat_id)
topics = ArrayList()
param.setResult(topics)
def before_find_topic(self, param):
"""findTopic(chatId, topicId): from the logger's topics, by id (this
is called for every visible row on every layout pass)."""
found = self._removed_forum(param)
if found is None:
return
account, chat_id = found
by_id = self._topic_index.get((account, -chat_id)) or {}
param.setResult(by_id.get(int(param.args[1])))
def before_load_topics(self, param):
"""loadTopics(chatId, fromCache, loadType): nothing to ask Telegram;
the logger's topics are loaded once (getTopics)."""
found = self._removed_forum(param)
if found is None:
return
param.setResult(None)
account, chat_id = found
self._load_topics(param.thisObject, account, chat_id)
def _load_topics(self, controller, account, chat_id):
key = (account, -chat_id)
with self._lock:
if key in self._topics or key in self._topics_loading:
return
self._topics_loading.add(key)
def load():
history = self._history(account, -chat_id)
response = history.forum_topics()
def apply():
from java.util import ArrayList
topics = ArrayList()
messages = {int(m.id): m for m in java_list(response.messages)}
for topic in java_list(response.topics):
topic.topMessage = messages.get(int(topic.top_message))
topic.topicStartMessage = _topic_start(controller, chat_id, topic)
topics.add(topic)
self._topics[key] = topics
self._topic_index[key] = {int(t.id): t for t in java_list(topics)}
self._topics_loading.discard(key)
diag.log(f"removed forum {chat_id}: {topics.size()} topics shown")
_topics_loaded(account, chat_id)
on_ui(apply)
in_background(load)
def before_request(self, param):
request = param.args[0]
if request is None or not any(self._injected.values()):
return
account = int(field(param.thisObject, "currentAccount") or 0)
account = int(cached_field(param.thisObject, "currentAccount") or 0)
dialog_id = _target(request)
if dialog_id is None or not self.is_injected(account, dialog_id):
return
name = type(request).__name__
name = type(request).__name__.rsplit("$", 1)[-1]
answered = name in _ANSWERED
if answered:
# Only the requests actually answered here are logged: the app
# retries getOnlines/getSearchCounters/search on a loop for a
# chat it cannot reach, and logging each one wrote to disk on
# every retry.
detail = ""
if name in ("TL_messages_search", "TL_messages_getReplies"):
detail = " " + " ".join(
f"{k}={getattr(request, k, None)}"
for k in ("msg_id", "offset_id", "add_offset", "limit")
)
diag.log(f"removed chat request {name} {dialog_id}{detail}")
delegate, timestamped = param.args[1], param.args[2]
if param.method.getReturnType().getName() == "void":
param.setResult(None)
@@ -457,20 +929,52 @@ class RemovedChats:
from java import jint
param.setResult(jint(0))
if name not in _ANSWERED:
if not answered:
return # Telegram has nothing for this chat
def deliver():
history = self._history(account, dialog_id)
if name == "TLRPC$TL_messages_getHistory":
if name == "TL_messages_getForumTopics":
response = history.forum_topics(
int(getattr(request, "offset_topic", 0) or 0),
int(getattr(request, "limit", 100) or 100),
)
elif name == "TL_messages_getForumTopicsByID":
response = history.forum_topics(
only=[int(t) for t in java_list(request.topics)]
)
else:
total = None
if name == "TL_messages_getHistory":
picked = history.page(
int(request.offset_id), int(request.add_offset), int(request.limit)
)
elif name == "TLRPC$TL_channels_getMessages":
picked = history.by_ids(int(m.id) for m in java_list(request.id))
total = len(history.messages)
elif name == "TL_messages_getReplies":
picked, total = history.topic_page(
int(request.msg_id), int(request.offset_id), int(request.limit)
)
# The app probes a thread with limit=1 first. The reply
# must look like a window into a larger thread (a count
# beyond this page, and where this page sits), or the
# app treats the thread as fully loaded and the topic
# never leaves its placeholders.
asked = int(request.limit or 0)
total = int(total or 0)
if not total:
total = len(picked) + (1 if asked and len(picked) >= asked else 0)
elif name == "TL_channels_getMessages":
picked = history.by_ids(
int(getattr(m, "id", m)) for m in java_list(request.id)
)
else:
picked = []
response = history.response(picked)
response = history.response(picked, total, 0)
if name == "TL_messages_getReplies":
diag.log(
f"removed chat topic {int(request.msg_id)}: "
f"{len(picked)} messages (total {total})"
)
if delegate is not None:
delegate.run(response, None)
elif timestamped is not None:
@@ -503,3 +1007,54 @@ class RemovedChats:
ImageLocation.getForPath(path), "50_50", param.args[1], None, target, jint(0)
)
param.setResult(None)
def after_cell_draw(self, param):
"""Draw the deleted badge on a removed chat's avatar.
Hooked on DialogCell.drawAvatarOverlays, which the cell calls under
the same canvas transform it draws the avatar with: drawing there
keeps the badge glued to the avatar through the forum-pane slide
and the chat open/close transitions (drawing after onDraw used the
un-transformed canvas, so the badge flew away from the avatar).
Runs per drawn cell per frame, so the common path returns before any
reflection and the paints are built once."""
injected = self._injected
if not injected:
return
cell = param.thisObject
dialog_id = int(cached_field(cell, "currentDialogId") or 0)
if not dialog_id:
return
account = int(cached_field(cell, "currentAccount") or 0)
entry = (injected.get(account) or {}).get(dialog_id)
if entry is None or int(entry[0].top_message) != entry[2]:
return
# TopicsFragment draws its topic rows with DialogCell too, and they
# all carry the forum's dialog id: only real chat-list cells are
# marked.
if cached_field(cell, "forumTopic") is not None:
return
avatar = cached_field(cell, "avatarImage")
if avatar is None:
return
if self._badge is None:
self._badge = _badge_paints()
icon, red, ring, dp = self._badge
canvas = param.args[0]
left = float(avatar.getImageX())
top = float(avatar.getImageY())
size = float(avatar.getImageWidth())
if size <= 0:
return
# Scaled with the avatar, so it shrinks with it in the side pane.
scale = max(0.45, min(1.0, size / dp[54]))
radius = dp[8] * scale
inset = size * 0.14
cx = left + size - inset
cy = top + float(avatar.getImageHeight()) - inset
canvas.drawCircle(cx, cy, radius + dp[1.5] * scale, ring)
canvas.drawCircle(cx, cy, radius, red)
inner = int(dp[11] * scale) // 2
icon.setBounds(int(cx) - inner, int(cy) - inner, int(cx) + inner, int(cy) + inner)
icon.draw(canvas)
+97 -1
View File
@@ -36,15 +36,76 @@ def methods_named(class_name, name):
return [m for m in cls.getDeclaredMethods() if m.getName() == name]
# Hook profiling: per hook name, [calls, total ns, max ns]. Off in releases.
PROFILE = {}
_PROFILE_ON = False
def _profiled(hook, label):
"""Wrap a MethodHook so each call's time is counted under `label`."""
if not _PROFILE_ON:
return hook
import time as _time
from base_plugin import MethodHook
inner = hook
class Timed(MethodHook):
def before_hooked_method(self, param):
start = _time.perf_counter_ns()
try:
if hasattr(inner, "before_hooked_method"):
inner.before_hooked_method(param)
finally:
_account(label + ":before", _time.perf_counter_ns() - start)
def after_hooked_method(self, param):
start = _time.perf_counter_ns()
try:
if hasattr(inner, "after_hooked_method"):
inner.after_hooked_method(param)
finally:
_account(label + ":after", _time.perf_counter_ns() - start)
return Timed()
def _account(label, spent):
entry = PROFILE.get(label)
if entry is None:
entry = PROFILE[label] = [0, 0, 0]
entry[0] += 1
entry[1] += spent
if spent > entry[2]:
entry[2] = spent
if spent > 16_000_000:
diag.log(f"SLOW {label}: {spent / 1e6:.1f}ms")
def profile_report(reset=True):
rows = sorted(PROFILE.items(), key=lambda kv: -kv[1][1])
lines = [
f"{label}: n={n} total={total / 1e6:.1f}ms max={peak / 1e6:.1f}ms"
for label, (n, total, peak) in rows
if n
]
if reset:
PROFILE.clear()
return lines
def hook_methods(plugin, class_name, name, hook, arity=None):
"""Hooks every overload of `class_name.name` (or those with `arity`
parameters); returns the unhook handles."""
handles = []
short = class_name.rsplit(".", 1)[-1]
for method in methods_named(class_name, name):
if arity is not None and len(method.getParameterTypes()) != arity:
continue
try:
handle = plugin.hook_method(method, hook)
label = f"{short}.{name}/{len(method.getParameterTypes())}"
handle = plugin.hook_method(method, _profiled(hook, label))
if handle is not None:
handles.append(handle)
except Exception as exc:
@@ -67,6 +128,41 @@ def field(obj, name):
return None
_FIELDS = {}
_MISSING = object()
def cached_field(obj, name):
"""`field()` for hot paths: the reflected Field is resolved once per
(class, name) instead of on every call (per-frame draw hooks)."""
try:
cls = obj.getClass()
key = (cls.getName(), name)
except Exception:
return field(obj, name)
found = _FIELDS.get(key, _MISSING)
if found is _MISSING:
found, current = None, cls
while current is not None:
try:
found = current.getDeclaredField(name)
found.setAccessible(True)
break
except Exception:
found = None
try:
current = current.getSuperclass()
except Exception:
current = None
_FIELDS[key] = found
if found is None:
return field(obj, name)
try:
return found.get(obj)
except Exception:
return field(obj, name)
def on_ui(fn, delay_ms=0):
from android_utils import run_on_ui_thread
+1 -1
View File
@@ -92,7 +92,7 @@ class MessageLoggerPlugin(BasePlugin):
)
in_background(accounts.refresh)
in_background(self.purge_cache)
diag.log("loaded")
diag.log(f"loaded (server {self.get_setting(SERVER_URL, '') or 'unset'})")
def _reset_chat(self, context):
"""Chat menu: forget this chat's cached marks and check it again."""
+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)
+182
View File
@@ -0,0 +1,182 @@
"""The last known profile of every sender shown from the logger.
The logger stores only a sender's id and name with each message, so the
app's own user/chat is what carries the avatar, username and the rest. That
object can be gone (the account never saw the sender again, the app's cache
was cleared) or useless (a deleted account: no name, no photo). So each time
a sender is readable here its full TLRPC object is saved, serialized, and it
is used whenever the app has nothing better.
Resolution, best first: the app's cache, the app's database, the snapshot
saved here, a stand-in named from the logger, "Deleted".
"""
import base64
import threading
import diag
import store
_lock = threading.Lock()
_memory = {} # (account, peer_id) -> TLRPC.User/Chat
_saved = {} # (account, peer_id) -> fingerprint of the stored snapshot
def _key(account, peer_id):
return f"person:{int(account)}:{int(peer_id)}"
def _usable_user(user):
"""A user worth showing: has a name, and isn't an emptied deleted
account (which the app keeps with no name and no photo)."""
if user is None:
return False
if type(user).__name__.endswith("userEmpty"):
return False
name = (getattr(user, "first_name", None) or "") + (getattr(user, "last_name", None) or "")
return bool(name.strip())
def _usable_chat(chat):
if chat is None:
return False
if type(chat).__name__.endswith(("chatEmpty", "channelForbidden", "chatForbidden")):
return bool((getattr(chat, "title", None) or "").strip())
return bool((getattr(chat, "title", None) or "").strip())
def _fingerprint(obj):
photo = getattr(obj, "photo", None)
return (
getattr(obj, "first_name", None),
getattr(obj, "last_name", None),
getattr(obj, "title", None),
getattr(obj, "username", None),
int(getattr(photo, "photo_id", 0) or 0) if photo is not None else 0,
)
def _serialize(obj):
from org.telegram.tgnet import SerializedData
data = SerializedData(obj.getObjectSize())
obj.serializeToStream(data)
raw = bytes(data.toByteArray())
data.cleanup()
return base64.b64encode(raw).decode("ascii")
def _deserialize(text, is_user):
from org.telegram.tgnet import SerializedData, TLRPC
raw = base64.b64decode(text)
data = SerializedData(raw)
try:
constructor = data.readInt32(False)
cls = TLRPC.User if is_user else TLRPC.Chat
return cls.TLdeserialize(data, constructor, False)
finally:
data.cleanup()
def remember(account, peer_id, obj):
"""Save `obj` (a User for peer_id > 0, a Chat for < 0) when it is worth
keeping and differs from the last snapshot."""
usable = _usable_user(obj) if peer_id > 0 else _usable_chat(obj)
if not usable:
return
key = (int(account), int(peer_id))
mark = _fingerprint(obj)
with _lock:
_memory[key] = obj
if _saved.get(key) == mark:
return
_saved[key] = mark
try:
text = _serialize(obj)
# After a restart nothing is marked as saved yet: skip the write
# when the stored snapshot is already this one.
if store.get_meta(_key(account, peer_id)) != text:
store.put_meta(_key(account, peer_id), text)
except Exception as exc:
diag.error("people.remember", exc)
def recall(account, peer_id):
"""The last snapshot saved for the sender, else None."""
key = (int(account), int(peer_id))
with _lock:
if key in _memory:
return _memory[key]
text = store.get_meta(_key(account, peer_id))
if not text:
return None
try:
obj = _deserialize(text, int(peer_id) > 0)
except Exception as exc:
diag.error("people.recall", exc)
return None
if obj is None:
return None
with _lock:
_memory[key] = obj
_saved[key] = _fingerprint(obj)
return obj
def _from_app(account, peer_id):
"""The app's object from its in-memory cache.
Not its database: getUserSync/getChatSync block on the storage thread,
and this runs while a topic's history is being built, so a busy storage
queue stalled the whole topic. What the database would add is what the
saved snapshot already holds."""
from org.telegram.messenger import MessagesController
controller = MessagesController.getInstance(int(account))
if peer_id > 0:
return controller.getUser(int(peer_id))
return controller.getChat(-int(peer_id))
def user(account, peer_id, logged_name=None):
"""The best User for a sender (peer_id > 0)."""
from org.telegram.tgnet import TLRPC
found = _from_app(account, peer_id)
if _usable_user(found):
remember(account, peer_id, found)
return found
saved = recall(account, peer_id)
if _usable_user(saved):
return saved
stand_in = TLRPC.TL_user()
stand_in.id = int(peer_id)
stand_in.first_name = logged_name or "Deleted"
stand_in.flags |= 2
return stand_in
def chat(account, chat_id, logged_title=None, kind="channel"):
"""The best Chat for a sender or a removed chat (chat_id > 0)."""
from org.telegram.tgnet import TLRPC
peer_id = -int(chat_id)
found = _from_app(account, peer_id)
if _usable_chat(found):
remember(account, peer_id, found)
return found
saved = recall(account, peer_id)
if _usable_chat(saved):
return saved
if found is not None:
return found
if kind == "group":
stand_in = TLRPC.TL_chat()
else:
stand_in = TLRPC.TL_channel()
stand_in.broadcast = kind == "channel"
stand_in.megagroup = kind != "channel"
stand_in.id = int(chat_id)
stand_in.title = logged_title or "Chat"
return stand_in
+108 -20
View File
@@ -47,15 +47,35 @@ def make_read_only(chat, notice="Viewing deleted comments"):
text = field(chat, "bottomOverlayText")
if overlay is None or text is None:
return
# Hide every other control the app may put in the bottom bar. The names
# differ per build and per chat kind (a forum/discussion screen shows a
# start/join button of its own), so the ones we know are hidden by name
# and anything else inside the overlay is hidden by walking it: leaving
# one visible is what kept drawing a button over the notice.
for name in (
"bottomOverlayChat", # a channel's bar (search, mute/join)
"bottomOverlayChatText",
"bottomOverlayStartButton",
"bottomOverlayProgress",
"bottomOverlayImage",
"bottomOverlayLinks",
"bottomOverlayChatText2",
"bottomOverlayChatWaitsReply",
"replyButton",
"unblockButton",
"joinButton",
"addToContactsButton",
"reportSpamButton",
"topViewSeparator",
):
other = field(chat, name)
if other is not None:
if other is not None and other is not text:
try:
other.setVisibility(View.GONE)
except Exception:
pass
_hide_siblings(overlay, text, View)
_hide_overlay_peers(overlay, View)
# The app animates the overlay out when the thread has an input bar:
# stop that, or it ends invisible (alpha 0) after this.
animation = field(chat, "bottomOverlayAnimation")
@@ -63,10 +83,89 @@ def make_read_only(chat, notice="Viewing deleted comments"):
animation.cancel()
text.setText(notice)
text.setVisibility(View.VISIBLE)
text.setAlpha(1.0)
overlay.setAlpha(1.0)
overlay.setVisibility(View.VISIBLE)
def _hide_siblings(overlay, keep, View):
"""Hide every child of the bottom overlay except the notice label.
The read-only notice is the app's own `bottomOverlayText`; any other
child (a join/start button, a progress spinner) would otherwise be
drawn over it. Chaquopy may hand out distinct Python wrappers for the
same Java view, so the one to keep is matched with Java equality
(`equals`) rather than Python identity."""
try:
from android.view import ViewGroup
if not isinstance(overlay, ViewGroup):
return
for index in range(overlay.getChildCount()):
child = overlay.getChildAt(index)
if child is None:
continue
try:
if child.equals(keep):
continue
except Exception:
if child is keep:
continue
try:
child.setVisibility(View.GONE)
except Exception:
pass
try:
keep.bringToFront()
except Exception:
pass
except Exception as exc:
diag.error("make_read_only.siblings", exc)
def _hide_overlay_peers(overlay, View):
"""Hide sibling views that share the bottom strip with the overlay.
Some screens (a forum/discussion ChatActivity) put their action button
next to `bottomOverlay` instead of inside it, so hiding the overlay's
own children is not enough: the button keeps covering the notice."""
try:
from android.view import ViewGroup
parent = overlay.getParent()
if parent is None or not isinstance(parent, ViewGroup):
return
top, bottom = overlay.getTop(), overlay.getBottom()
for index in range(parent.getChildCount()):
child = parent.getChildAt(index)
if child is None:
continue
try:
if child.equals(overlay):
continue
except Exception:
if child is overlay:
continue
try:
if child.getVisibility() != View.VISIBLE:
continue
# Only the views overlapping the overlay's band: the
# message list and the action bar must stay untouched.
if child.getBottom() <= top or child.getTop() >= bottom:
continue
# The message list spans the whole screen and so overlaps
# the band too; hiding it left topics on their loading
# placeholders. Only bar-sized views are buttons.
band = max(1, bottom - top)
if child.getHeight() > band * 2:
continue
child.setVisibility(View.GONE)
except Exception:
pass
except Exception as exc:
diag.error("make_read_only.peers", exc)
def answer(account, request):
"""The response for a getReplies of a thread opened here, else None."""
with _lock:
@@ -102,28 +201,17 @@ def _peer(controller, marked):
return controller.getPeer(_android_peer_id(int(marked)))
def _sender_object(controller, marked, name):
"""The sender's User/Chat from the account's cache, else a stand-in with
the logged name (avatar drawn from its initials)."""
def _sender_object(account, marked, name):
"""The sender's User/Chat: the app's, else the last snapshot saved while
it was readable (name, username, photo), else a stand-in with the
logged name (avatar drawn from its initials)."""
import people
from hooks.chat import _android_peer_id
from org.telegram.tgnet import TLRPC
peer_id = _android_peer_id(int(marked))
if peer_id > 0:
user = controller.getUser(peer_id)
if user is None:
user = TLRPC.TL_user()
user.id = peer_id
user.first_name = name or "Deleted"
user.flags |= 2
return user, None
chat = controller.getChat(-peer_id)
if chat is None:
chat = TLRPC.TL_channel()
chat.id = -peer_id
chat.title = name or "Channel"
chat.broadcast = True
return None, chat
return people.user(account, peer_id, name), None
return None, people.chat(account, -peer_id, name, "channel")
class Comments(History):
@@ -221,7 +309,7 @@ class Comments(History):
self._into_thread(msg, item, discussion, top, sources)
if item.get("sender_id"):
user, chat = _sender_object(
controller, item["sender_id"], item.get("sender_name")
account, item["sender_id"], item.get("sender_name")
)
if user is not None:
users[user.id] = user
+360 -55
View File
@@ -9,10 +9,11 @@ message.
getHistory/getMessages for it (hooks/removed_chats.py).
"""
import json
import threading
import accounts
import diag
import media_share
import peers
import store
import tg
@@ -23,34 +24,19 @@ _PAGES = 3 # history pages (of 100) loaded when the chat opens
_COPIES_PER_REQUEST = 100
def stand_in_user(controller, peer_id, name):
from org.telegram.tgnet import TLRPC
def stand_in_user(account, peer_id, name):
"""The sender's User: the app's, else its last saved snapshot (name,
username, photo), else a stand-in with the logged name."""
import people
user = controller.getUser(int(peer_id))
if user is None:
user = TLRPC.TL_user()
user.id = int(peer_id)
user.first_name = name or "Deleted"
user.flags |= 2
return user
return people.user(account, peer_id, name)
def stand_in_chat(controller, chat_id, title, kind):
"""The app's chat (cached or stored), else a stand-in of the right kind."""
from org.telegram.tgnet import TLRPC
def stand_in_chat(account, chat_id, title, kind):
"""The app's chat, else its last saved snapshot, else a stand-in."""
import people
chat = controller.getChat(int(chat_id))
if chat is not None:
return chat
if kind == "group":
chat = TLRPC.TL_chat()
else:
chat = TLRPC.TL_channel()
chat.broadcast = kind == "channel"
chat.megagroup = kind != "channel"
chat.id = int(chat_id)
chat.title = title or "Chat"
return chat
return people.chat(account, chat_id, title, kind)
class History:
@@ -62,13 +48,23 @@ class History:
self.messages = [] # TL messages, newest first
self.users = {}
self.chats = {}
# Forum topics from the server: [{"id", "title", "icon_emoji_id"}],
# newest activity first; empty when the chat is no forum.
self.topics = []
self._topic_of = {} # message id -> forum topic id (1: General)
self._latest_by_topic = {}
self.loaded = False
self._lock = threading.Lock()
self._lock_copies = threading.Lock()
self._copy_cache = {} # (log chat, message id) -> copy (TL message)
self._media_done = set() # (log chat, message id) whose media is local
self._pages = {} # (topic id, before) -> (messages, total)
self._downloading = False
self._queue = [] # [(items, master)] waiting for their media
# loading (background)
def load(self):
"""Fetches and builds the chat's history once; later calls wait."""
"""Fetch and build the topic-preview messages once."""
with self._lock:
if self.loaded:
return
@@ -80,25 +76,76 @@ class History:
def _load(self):
me = peers.own_user_id(self.account)
items, before, master = [], 0, None
for _page in range(_PAGES):
disk_key = f"chat_head:{self.account}:{self.chat_id}"
result = None
for attempt in range(2):
try:
result = self.api.history(me, self.chat_id, before)
result = self.api.history(me, self.chat_id, 0, limit=1)
break
except ApiError as exc:
diag.log(f"removed chat {self.chat_id}: {exc}")
break
page = (result or {}).get("messages") or []
if not page:
break
diag.log(f"removed chat {self.chat_id} try {attempt + 1}: {exc}")
if result:
try:
store.put_meta(disk_key, json.dumps(result))
except Exception as exc:
diag.error("removed_chat.head_cache", exc)
else:
# Unreachable: the last answer saved on this device, so the
# chat still opens with its topics and their latest messages.
saved = store.get_meta(disk_key)
if not saved:
return
result = json.loads(saved)
diag.log(f"removed chat {self.chat_id}: from device cache")
result = result or {}
self.topics = list(result.get("topics") or [])
items = list(result.get("messages") or [])
for topic in self.topics:
latest = topic.get("latest") or {}
if latest and int(latest.get("message_id") or 0) not in {
int(i.get("message_id") or 0) for i in items
}:
items.append(latest)
master = result.get("master_tg_id")
items += page
before = min(int(m["message_id"]) for m in page)
if len(page) < 100:
break
revealed = store.is_revealed(self.account, self.dialog_id)
items = [i for i in items if revealed or not i.get("hidden_at")]
store.put_server_states(self.account, self.dialog_id, items)
copies = self._copies(items, master)
# The topic previews need the logged copies (so media shows instead
# of "<kind> unavailable"), but downloading every file here would
# block the topic list for as long as the transfers take. Build with
# the copies now and fetch their files in the background.
self._build(items, master, fetch_copies=True, download=False)
self._download_later(items, master)
def _download_later(self, items, master):
"""Queue the copies' media for download off the UI path.
copy_media blocks per file (up to 20s each), so it never runs while a
page is being answered. One worker per chat drains the queue: a
thread per page made several pages download at once and compete
with the UI while the chat was opening."""
with self._lock_copies:
self._queue.append((list(items), master))
if self._downloading:
return
self._downloading = True
def run():
while True:
with self._lock_copies:
if not self._queue:
self._downloading = False
return
batch, batch_master = self._queue.pop(0)
try:
self._copies(batch, batch_master, download=True)
except Exception as exc:
diag.error("removed_chat.download", exc)
threading.Thread(target=run, daemon=True).start()
def _build(self, items, master=None, fetch_copies=True, download=True):
copies = self._copies(items, master, download=download) if fetch_copies else {}
built = []
for item in items:
try:
@@ -106,7 +153,97 @@ class History:
except Exception as exc:
diag.error("removed_chat.message", exc)
self.messages = sorted(built, key=lambda m: -int(m.id))
diag.log(f"removed chat {self.chat_id}: {len(self.messages)} messages")
self._latest_by_topic = {}
if self.topics:
ids = {int(t["id"]) for t in self.topics}
by_id = {int(m.id): m for m in self.messages}
for item in items:
message = by_id.get(int(item["message_id"]))
if message is None:
continue
topic = int(item.get("source_topic_id") or 0)
topic = topic if topic in ids else 1
self._topic_of[int(message.id)] = topic
self._latest_by_topic.setdefault(topic, message)
diag.log(
f"removed chat {self.chat_id}: {len(self.messages)} latest messages, "
f"{len(self.topics)} topics"
)
def topic_page(self, topic_id, before=0, limit=100):
"""A topic's logged messages, newest first, with its total count.
Returns (messages, total): the app pages with limit=1 first, so the
total must describe the whole topic, not this slice."""
# A page already built is answered from memory: reopening a topic
# used to refetch it from the server and rebuild every message.
key = (int(topic_id), int(before or 0))
with self._lock_copies:
page = self._pages.get(key)
if page is not None:
messages, total = page
asked = int(limit or 0)
return (messages[:asked] if asked > 0 else messages), total
me = peers.own_user_id(self.account)
# The app probes a thread with limit=1 before asking for a page.
# Passing that straight through fetched a single message and the
# topic stayed on its loading placeholders, so a full page is
# always fetched and the slice is taken below.
want = max(int(limit or 0), 100)
disk_key = f"topic_page:{self.account}:{self.chat_id}:{int(topic_id)}:{int(before or 0)}"
result = None
# Up to two tries: a single slow answer from the server used to leave
# the topic empty for good (it looked frozen).
for attempt in range(2):
try:
result = self.api.history(
me, self.chat_id, before, min(100, want), topic_id
) or {}
break
except ApiError as exc:
diag.log(f"removed topic {self.chat_id}/{topic_id} try {attempt + 1}: {exc}")
if result is not None and result.get("messages"):
try:
store.put_meta(disk_key, json.dumps(result))
except Exception as exc:
diag.error("removed_chat.page_cache", exc)
else:
# Unreachable or empty: the last answer saved on this device.
saved = store.get_meta(disk_key)
result = json.loads(saved) if saved else None
if result is None:
return [], 0
diag.log(f"removed topic {self.chat_id}/{topic_id}: from device cache")
items = result.get("messages") or []
revealed = store.is_revealed(self.account, self.dialog_id)
items = [i for i in items if revealed or not i.get("hidden_at")]
# Without the logged copies every message falls back to its server
# text and its media is marked unavailable: no previews in a topic.
# The copies are read now (their media is already in them, by file
# id), but the files are downloaded in the background: downloading
# them inline held the page for up to 20s per file, so the next
# page request waited and the topic sat on its placeholders.
master = result.get("master_tg_id")
copies = self._copies(items, master, download=False)
self._download_later(items, master)
built = []
for item in items:
try:
built.append(self._message(item, copies.get(self._pointer(item))))
except Exception as exc:
diag.error("removed_chat.topic_message", exc)
ordered = sorted(built, key=lambda m: -int(m.id))
total = len(ordered)
if ordered:
with self._lock_copies:
self._pages[key] = (ordered, total)
# Answer with at most what was asked for: the app drops a reply that
# holds more messages than its limit, which left the topic on its
# placeholders even though the history had arrived.
asked = int(limit or 0)
if asked > 0:
ordered = ordered[:asked]
return ordered, total
@staticmethod
def _pointer(item):
@@ -115,7 +252,7 @@ class History:
return None
return int(logged["chat_id"]), int(logged["message_id"])
def _copies(self, items, master):
def _copies(self, items, master, download=True):
"""Logged copies by (log chat, message), read through an account here
that can, per log group."""
by_group = {}
@@ -124,20 +261,69 @@ 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:
# Copies already read are reused: a reopened chat or topic would
# otherwise ask Telegram for every logged copy again.
with self._lock_copies:
missing = [i for i in ids if (log_chat, i) not in self._copy_cache]
for i in ids:
cached = self._copy_cache.get((log_chat, i))
if cached is not None:
found[(log_chat, i)] = cached
if not missing:
if download:
self._download_copies(log_chat, ids, master, found)
continue
ordered = sorted(ids)
channel_id = peers.channel_id_from_marked(log_chat)
reader = media_share.reader_for(self.account, log_chat, masters)
if reader is None:
# No signed-in account is a member of this log group, so its
# copies (and their media) cannot be read at all: every
# message falls back to "<kind> unavailable".
diag.log(f"copies: no reader for log group {log_chat} ({len(missing)} messages)")
continue
ordered = sorted(missing)
fetched = 0
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():
fetched += 1
found[(log_chat, message_id)] = copy
with self._lock_copies:
self._copy_cache[(log_chat, message_id)] = copy
diag.log(
f"copies: log group {log_chat} reader={reader} "
f"asked={len(ordered)} got={fetched}"
)
if download:
self._download_copies(log_chat, ids, master, found)
return found
def _download_copies(self, log_chat, ids, master, found):
"""Download each copy's media once (blocking; background only).
The media is fetched through a log-group member by the copy's own
file reference. Files already downloaded are skipped, so reopening a
chat costs nothing; a file that failed is retried on a later open."""
reader = media_share.reader_for(self.account, log_chat, [master, *self.api.masters()])
if reader is None:
return
ready = failed = 0
for i in ids:
key = (log_chat, i)
copy = found.get(key)
if copy is None or key in self._media_done:
continue
got, total = media_share.copy_media(reader, copy)
if got >= total:
self._media_done.add(key)
ready += got
else:
failed += total - got
if ready or failed:
diag.log(f"media: log group {log_chat} ready={ready} failed={failed}")
# building
def _message(self, item, copy):
@@ -158,12 +344,27 @@ class History:
if sender:
peer_id = _android_peer_id(int(sender))
if peer_id > 0:
user = stand_in_user(controller, peer_id, item.get("sender_name"))
user = stand_in_user(self.account, peer_id, item.get("sender_name"))
self.users[int(user.id)] = user
else:
chat = stand_in_chat(controller, -peer_id, item.get("sender_name"), "channel")
chat = stand_in_chat(self.account, -peer_id, item.get("sender_name"), "channel")
self.chats[int(chat.id)] = chat
if item.get("reply_to"):
topic = int(item.get("source_topic_id") or 0)
if topic and self.topics:
# In a forum topic: the reply header names it (its start message
# id), with the replied message when there is one.
reply = TLRPC.TL_messageReplyHeader()
reply.forum_topic = True
reply.flags |= 8
target = int(item.get("reply_to") or 0)
reply.reply_to_msg_id = target or topic
reply.flags |= 16
if target and target != topic:
reply.reply_to_top_id = topic
reply.flags |= 2
msg.reply_to = reply
msg.flags |= 8
elif item.get("reply_to"):
reply = TLRPC.TL_messageReplyHeader()
reply.reply_to_msg_id = int(item["reply_to"])
reply.flags |= 16
@@ -171,9 +372,113 @@ class History:
msg.flags |= 8
return msg
# forum topics
def topic_of(self, message):
return self._topic_of.get(int(message.id), 1)
def topic_messages(self, topic_id):
return [self._latest_by_topic[topic_id]] if int(topic_id) in self._latest_by_topic else []
def forum_topics(self, offset_topic=0, limit=100, only=None):
"""messages.getForumTopics' answer: General first, then the topics by
newest message; each with its newest message. Every topic asked for
(`only`) is answered, empty ones too, or the app asks again forever."""
from java.util import ArrayList
from org.telegram.tgnet import TLRPC
entries = list(self.topics)
if not any(int(e["id"]) == 1 for e in entries):
entries.insert(0, {"id": 1, "title": "General"})
def _recency(entry):
"""Newest logged activity in the topic (0 when nothing logged)."""
latest = entry.get("latest") or {}
return _epoch(latest.get("source_date")) if latest else 0
# Telegram orders a forum by the most recent topic, General first.
# The logger records no pinned flag or order (the server sends only
# id/title/icon/latest), so recency is the ordering that can be
# rebuilt faithfully; without this the topics came out in the
# server's arbitrary order.
if only is None:
general = [e for e in entries if int(e["id"]) == 1]
rest = [e for e in entries if int(e["id"]) != 1]
rest.sort(key=_recency, reverse=True)
entries = general + rest
if only is not None:
known = {int(e["id"]) for e in entries}
entries = [e for e in entries if int(e["id"]) in only] + [
{"id": i, "title": f"Topic {i}"} for i in only if i not in known
]
elif offset_topic:
ids = [int(e["id"]) for e in entries]
if int(offset_topic) in ids:
entries = entries[ids.index(int(offset_topic)) + 1 :]
entries = entries[: max(1, int(limit or 100))]
response = TLRPC.TL_messages_forumTopics()
response.topics = ArrayList()
response.messages = ArrayList()
tops = set()
for entry in entries:
topic_id = int(entry["id"])
messages = self.topic_messages(topic_id)
if not messages and only is None:
continue
topic = TLRPC.TL_forumTopic()
topic.id = topic_id
topic.title = str(entry.get("title") or f"Topic {topic_id}")
icon = entry.get("icon_emoji_id")
if icon:
topic.icon_emoji_id = int(icon)
topic.flags |= 1
topic.icon_color = 0x6FB9F0
if not messages:
# Nothing logged in it: an empty stand-in, so the app has a
# top message and does not ask for the topic again.
empty = TLRPC.TL_message()
empty.id = topic_id
empty.peer_id = self.messages[0].peer_id if self.messages else None
empty.message = ""
messages = [empty]
# The newest message dates the topic: messages are newest-first,
# so messages[-1] is the OLDEST and dating the topic by it put
# active topics at the bottom of the forum.
topic.date = int(messages[0].date)
topic.top_message = int(messages[0].id)
# Deleted topics are read-only snapshots; don't let stale topic
# unread state create a badge in the topic list.
topic.read_inbox_max_id = topic.top_message
topic.read_outbox_max_id = topic.top_message
if hasattr(topic, "unread_count"):
topic.unread_count = 0
if hasattr(topic, "unread_mentions_count"):
topic.unread_mentions_count = 0
# The topic's creator shown in the list is the newest message's
# sender, matching the preview drawn from messages[0].
if messages[0].from_id is not None:
topic.from_id = messages[0].from_id
response.topics.add(topic)
if topic.top_message not in tops:
tops.add(topic.top_message)
response.messages.add(messages[0])
response.count = response.topics.size()
response.pts = 0
response.users = ArrayList()
for user in self.users.values():
response.users.add(user)
response.chats = ArrayList()
for chat in self.chats.values():
response.chats.add(chat)
return response
# answering the app's requests
def response(self, picked):
def response(self, picked, total=None, offset=None):
"""A getHistory/getReplies answer.
TL_messages_messages is what the app expects here: switching to
TL_messages_channelMessages (to carry a count) made every topic
hang on its loading placeholders, so the plain form is kept."""
from java.util import ArrayList
from org.telegram.tgnet import TLRPC
@@ -189,10 +494,10 @@ class History:
response.chats.add(chat)
return response
def page(self, offset_id, add_offset, limit):
def page(self, offset_id, add_offset, limit, messages=None):
"""getHistory's slice: newest first, from `offset_id` (exclusive; 0:
the newest) moved by `add_offset` (negative: newer ones too)."""
messages = self.messages
messages = self.messages if messages is None else messages
if offset_id:
start = next(
(i for i, m in enumerate(messages) if int(m.id) < offset_id),