generated from exteraGramPlugins/pluginBase
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5141f47878 | ||
|
|
5e4c2f2a6f | ||
|
|
8535bde5ae |
No files matched your search
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
"name": "Message Logger",
|
"name": "Message Logger",
|
||||||
"description": "Shows deleted and edited messages and their edit history from your telegram-panel message logger.",
|
"description": "Shows deleted and edited messages and their edit history from your telegram-panel message logger.",
|
||||||
"author": "@lyuksovannyy",
|
"author": "@lyuksovannyy",
|
||||||
"version": "0.1.4",
|
"version": "0.1.7",
|
||||||
"icon": "exteraGramPluginIcons/4",
|
"icon": "exteraGramPluginIcons/4",
|
||||||
"app_version": ">=12.5.1",
|
"app_version": ">=12.5.1",
|
||||||
"sdk_version": ">=1.4.4.3",
|
"sdk_version": ">=1.4.4.3",
|
||||||
|
|||||||
+5
-3
@@ -209,10 +209,12 @@ class Api:
|
|||||||
chats.setdefault(int(chat["chat_id"]), chat)
|
chats.setdefault(int(chat["chat_id"]), chat)
|
||||||
return list(chats.values()) if any_result else None
|
return list(chats.values()) if any_result else None
|
||||||
|
|
||||||
def history(self, account_id, chat_id, before=0, limit=100):
|
def history(self, account_id, chat_id, before=0, limit=100, topic_id=None):
|
||||||
"""Logged messages of a chat older than `before`, newest first: the
|
"""Logged messages older than `before`, newest first. For a forum
|
||||||
first master that has any."""
|
topic, pass its source topic id; 0 selects General."""
|
||||||
params = {"account_id": account_id, "before": int(before), "limit": int(limit)}
|
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(
|
for _token, result in self._each(
|
||||||
"GET", f"/chats/{chat_id}/history", params=params
|
"GET", f"/chats/{chat_id}/history", params=params
|
||||||
):
|
):
|
||||||
|
|||||||
+214
-2
@@ -29,6 +29,9 @@ from views.history import _epoch
|
|||||||
CONTROLLER = "org.telegram.messenger.MessagesController"
|
CONTROLLER = "org.telegram.messenger.MessagesController"
|
||||||
THUMBS = "org.telegram.ui.Components.GroupedPhotosListView"
|
THUMBS = "org.telegram.ui.Components.GroupedPhotosListView"
|
||||||
VIEWER = "org.telegram.ui.PhotoViewer"
|
VIEWER = "org.telegram.ui.PhotoViewer"
|
||||||
|
GALLERY = "org.telegram.ui.Components.ProfileGalleryView"
|
||||||
|
OVERLAYS = "org.telegram.ui.ProfileActivity$OverlaysView"
|
||||||
|
_MARK_TAG = "message_logger_trash"
|
||||||
NOTIFICATIONS = "org.telegram.messenger.NotificationCenter"
|
NOTIFICATIONS = "org.telegram.messenger.NotificationCenter"
|
||||||
# A logged photo whose id isn't Telegram's (logged by a bot) is the chat's
|
# A logged photo whose id isn't Telegram's (logged by a bot) is the chat's
|
||||||
# own when one of the chat's photos was set this close to its post.
|
# own when one of the chat's photos was set this close to its post.
|
||||||
@@ -87,6 +90,9 @@ class Avatars:
|
|||||||
self._appending = False
|
self._appending = False
|
||||||
self._updates = {} # (account, dialog) -> args of its last dialogPhotosUpdate
|
self._updates = {} # (account, dialog) -> args of its last dialogPhotosUpdate
|
||||||
self._icon = None
|
self._icon = None
|
||||||
|
self._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()
|
self._shown = None # PhotoViewer.isVisible()
|
||||||
|
|
||||||
def install(self):
|
def install(self):
|
||||||
@@ -99,6 +105,35 @@ class Avatars:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
diag.error("avatars.photos", exc)
|
diag.error("avatars.photos", exc)
|
||||||
|
|
||||||
|
class AfterSetData(MethodHook):
|
||||||
|
def after_hooked_method(self, param):
|
||||||
|
try:
|
||||||
|
avatars.watch_gallery(param.thisObject)
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("avatars.gallery", exc)
|
||||||
|
|
||||||
|
class AfterOverlays(MethodHook):
|
||||||
|
def after_hooked_method(self, param):
|
||||||
|
try:
|
||||||
|
avatars.update_profile(field(param.thisObject, "this$0"))
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("avatars.profile", exc)
|
||||||
|
|
||||||
|
class 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):
|
class AfterThumbs(MethodHook):
|
||||||
def after_hooked_method(self, param):
|
def after_hooked_method(self, param):
|
||||||
try:
|
try:
|
||||||
@@ -115,6 +150,15 @@ class Avatars:
|
|||||||
|
|
||||||
self.handles += hook_methods(self.plugin, CONTROLLER, "getDialogPhotos", AfterPhotos())
|
self.handles += hook_methods(self.plugin, CONTROLLER, "getDialogPhotos", AfterPhotos())
|
||||||
self.handles += hook_methods(self.plugin, THUMBS, "onDraw", AfterThumbs())
|
self.handles += hook_methods(self.plugin, THUMBS, "onDraw", AfterThumbs())
|
||||||
|
self.handles += hook_methods(self.plugin, GALLERY, "setData", AfterSetData())
|
||||||
|
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.handles += hook_methods(
|
||||||
self.plugin, NOTIFICATIONS, "postNotificationName", BeforeNotify()
|
self.plugin, NOTIFICATIONS, "postNotificationName", BeforeNotify()
|
||||||
)
|
)
|
||||||
@@ -171,6 +215,7 @@ class Avatars:
|
|||||||
recorded.append(
|
recorded.append(
|
||||||
(photo, int(original) if original else None, _epoch(avatar.get("date")))
|
(photo, int(original) if original else None, _epoch(avatar.get("date")))
|
||||||
)
|
)
|
||||||
|
self._dates[int(photo.id)] = (avatar.get("date"), avatar.get("deleted_at"))
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._recorded[(account, dialog_id)] = recorded
|
self._recorded[(account, dialog_id)] = recorded
|
||||||
self._loaded_at[(account, dialog_id)] = time.monotonic()
|
self._loaded_at[(account, dialog_id)] = time.monotonic()
|
||||||
@@ -227,14 +272,14 @@ class Avatars:
|
|||||||
field(viewer, "avatarsDialogId") or 0
|
field(viewer, "avatarsDialogId") or 0
|
||||||
) == int(dialog_id)
|
) == int(dialog_id)
|
||||||
|
|
||||||
def _append(self, account, dialog_id):
|
def _append(self, account, dialog_id, force=False):
|
||||||
"""Puts the logged photos into the chat's photos and tells the
|
"""Puts the logged photos into the chat's photos and tells the
|
||||||
gallery, at most every few seconds (it reads the photos again)."""
|
gallery, at most every few seconds (it reads the photos again)."""
|
||||||
from org.telegram.messenger import MessagesController, NotificationCenter
|
from org.telegram.messenger import MessagesController, NotificationCenter
|
||||||
|
|
||||||
if not self.handles:
|
if not self.handles:
|
||||||
return # unloaded
|
return # unloaded
|
||||||
if self._viewing(dialog_id):
|
if not force and self._viewing(dialog_id):
|
||||||
on_ui(lambda: self._append(account, dialog_id), 1000) # once it closed
|
on_ui(lambda: self._append(account, dialog_id), 1000) # once it closed
|
||||||
return
|
return
|
||||||
self._appending = True # getDialogPhotos calls back into photos_wanted
|
self._appending = True # getDialogPhotos calls back into photos_wanted
|
||||||
@@ -344,6 +389,173 @@ class Avatars:
|
|||||||
icon.setBounds(right - size, bottom - size, right, bottom)
|
icon.setBounds(right - size, bottom - size, right, bottom)
|
||||||
icon.draw(canvas)
|
icon.draw(canvas)
|
||||||
|
|
||||||
|
# the profile's trash icon (under the menu button)
|
||||||
|
|
||||||
|
def watch_gallery(self, gallery):
|
||||||
|
"""Redraws the icon as the profile's gallery changes page."""
|
||||||
|
key = int(gallery.hashCode())
|
||||||
|
if key in self._watched:
|
||||||
|
return
|
||||||
|
from java import dynamic_proxy, jclass
|
||||||
|
|
||||||
|
listener_class = jclass("androidx.viewpager.widget.ViewPager$OnPageChangeListener")
|
||||||
|
avatars = self
|
||||||
|
|
||||||
|
class Pages(dynamic_proxy(listener_class)):
|
||||||
|
def onPageScrolled(self, position, offset, pixels):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def onPageSelected(self, position):
|
||||||
|
on_ui(lambda: avatars.update_profile(field(gallery, "this$0")))
|
||||||
|
|
||||||
|
def onPageScrollStateChanged(self, state):
|
||||||
|
pass
|
||||||
|
|
||||||
|
listener = Pages()
|
||||||
|
gallery.addOnPageChangeListener(listener)
|
||||||
|
self._watched[key] = listener
|
||||||
|
|
||||||
|
def _current_photo(self, gallery):
|
||||||
|
if self._position is None:
|
||||||
|
method = java_class(GALLERY).getDeclaredMethod("getRealPosition")
|
||||||
|
method.setAccessible(True)
|
||||||
|
self._position = method
|
||||||
|
photos = field(gallery, "photos")
|
||||||
|
index = int(self._position.invoke(gallery))
|
||||||
|
if photos is None or not 0 <= index < photos.size():
|
||||||
|
return None
|
||||||
|
return photos.get(index)
|
||||||
|
|
||||||
|
def update_profile(self, profile):
|
||||||
|
"""Shows the icon while the expanded gallery shows a logged photo."""
|
||||||
|
if profile is None or not self._ids:
|
||||||
|
return
|
||||||
|
gallery = field(profile, "avatarsViewPager")
|
||||||
|
if gallery is None:
|
||||||
|
return
|
||||||
|
photo = self._current_photo(gallery)
|
||||||
|
shown = (
|
||||||
|
photo is not None
|
||||||
|
and int(photo.id) in self._ids
|
||||||
|
and bool(field(profile, "isPulledDown"))
|
||||||
|
)
|
||||||
|
mark = self._profile_mark(profile, create=shown)
|
||||||
|
if mark is None:
|
||||||
|
return
|
||||||
|
from android.view import View
|
||||||
|
|
||||||
|
mark.setVisibility(View.VISIBLE if shown else View.GONE)
|
||||||
|
mark.setAlpha(float(field(profile, "currentExpandAnimatorValue") or 1.0))
|
||||||
|
|
||||||
|
def _profile_mark(self, profile, create):
|
||||||
|
root = field(profile, "fragmentView")
|
||||||
|
if root is None or not hasattr(root, "addView"):
|
||||||
|
return None
|
||||||
|
for index in range(root.getChildCount()):
|
||||||
|
child = root.getChildAt(index)
|
||||||
|
if str(child.getTag()) == _MARK_TAG:
|
||||||
|
return child
|
||||||
|
if not create:
|
||||||
|
return None
|
||||||
|
from android.view import Gravity
|
||||||
|
from android.widget import FrameLayout, ImageView
|
||||||
|
from java import jint
|
||||||
|
from org.telegram.messenger import AndroidUtilities
|
||||||
|
from org.telegram.ui.ActionBar import ActionBar, Theme
|
||||||
|
|
||||||
|
from android.graphics import PorterDuff, PorterDuffColorFilter
|
||||||
|
from org.telegram.messenger import R
|
||||||
|
|
||||||
|
from hooks.render import _trash_drawable
|
||||||
|
|
||||||
|
dp = AndroidUtilities.dp
|
||||||
|
icon = _trash_drawable(R.drawable.msg_delete)
|
||||||
|
red = jint(Theme.getColor(Theme.key_text_RedBold))
|
||||||
|
icon.setColorFilter(PorterDuffColorFilter(red, PorterDuff.Mode.SRC_IN))
|
||||||
|
mark = ImageView(root.getContext())
|
||||||
|
mark.setTag(_MARK_TAG)
|
||||||
|
mark.setImageDrawable(icon)
|
||||||
|
mark.setPadding(dp(8), dp(8), dp(8), dp(8))
|
||||||
|
mark.setBackground(Theme.createCircleDrawable(dp(36), jint(0x66000000)))
|
||||||
|
params = FrameLayout.LayoutParams(dp(36), dp(36))
|
||||||
|
params.gravity = Gravity.TOP | Gravity.RIGHT
|
||||||
|
params.topMargin = AndroidUtilities.statusBarHeight + ActionBar.getCurrentActionBarHeight()
|
||||||
|
params.rightMargin = dp(10)
|
||||||
|
root.addView(mark, params)
|
||||||
|
return mark
|
||||||
|
|
||||||
|
# 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):
|
def _trash(self):
|
||||||
if self._icon is None:
|
if self._icon is None:
|
||||||
from android.graphics import Paint, PorterDuff, PorterDuffColorFilter
|
from android.graphics import Paint, PorterDuff, PorterDuffColorFilter
|
||||||
|
|||||||
@@ -218,9 +218,25 @@ def build_from_copy(account, dialog_id, message_id, state, copy, strip):
|
|||||||
own = peers.own_user_id(account)
|
own = peers.own_user_id(account)
|
||||||
msg.out = sender is not None and int(sender) == own
|
msg.out = sender is not None and int(sender) == own
|
||||||
msg.unread = False
|
msg.unread = False
|
||||||
|
if sender:
|
||||||
|
_remember_sender(account, _android_peer_id(sender))
|
||||||
return MessageObject(account, msg, True, True)
|
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:
|
class ChatSync:
|
||||||
def __init__(self, plugin, api):
|
def __init__(self, plugin, api):
|
||||||
self.plugin = plugin
|
self.plugin = plugin
|
||||||
|
|||||||
+599
-45
@@ -33,7 +33,7 @@ import store
|
|||||||
from api import ApiError
|
from api import ApiError
|
||||||
from config import DEFAULTS, SHOW_REMOVED_CHATS
|
from config import DEFAULTS, SHOW_REMOVED_CHATS
|
||||||
from hooks import avatars
|
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 import removed_chat
|
||||||
from views.comments import make_read_only
|
from views.comments import make_read_only
|
||||||
from views.history import _epoch
|
from views.history import _epoch
|
||||||
@@ -43,13 +43,19 @@ CONNECTIONS = "org.telegram.tgnet.ConnectionsManager"
|
|||||||
CHAT = "org.telegram.ui.ChatActivity"
|
CHAT = "org.telegram.ui.ChatActivity"
|
||||||
IMAGES = "org.telegram.messenger.ImageReceiver"
|
IMAGES = "org.telegram.messenger.ImageReceiver"
|
||||||
STORAGE = "org.telegram.messenger.MessagesStorage"
|
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
|
_REFRESH_SECONDS = 900
|
||||||
_REPORT_AGAIN_SECONDS = 30
|
_REPORT_AGAIN_SECONDS = 30
|
||||||
_ANSWERED = (
|
_ANSWERED = (
|
||||||
"TLRPC$TL_messages_getHistory",
|
"TL_messages_getHistory",
|
||||||
"TLRPC$TL_channels_getMessages",
|
"TL_channels_getMessages",
|
||||||
"TLRPC$TL_messages_search",
|
"TL_messages_search",
|
||||||
|
"TL_messages_getForumTopics",
|
||||||
|
"TL_messages_getForumTopicsByID",
|
||||||
|
"TL_messages_getReplies",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -69,8 +75,44 @@ def _dialog_id(chat_id):
|
|||||||
return _android_peer_id(int(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):
|
def _target(request):
|
||||||
"""The dialog a request is about (its peer/channel/user), else None."""
|
"""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"):
|
for name in ("peer", "channel", "user_id", "id"):
|
||||||
value = getattr(request, name, None)
|
value = getattr(request, name, None)
|
||||||
if value is None or isinstance(value, (bool, int, float, str)):
|
if value is None or isinstance(value, (bool, int, float, str)):
|
||||||
@@ -82,9 +124,114 @@ def _target(request):
|
|||||||
return -int(value.chat_id)
|
return -int(value.chat_id)
|
||||||
if "nputPeerUser" in kind or kind.endswith("TL_inputUser"):
|
if "nputPeerUser" in kind or kind.endswith("TL_inputUser"):
|
||||||
return int(value.user_id)
|
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
|
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:
|
class RemovedChats:
|
||||||
def __init__(self, plugin, api):
|
def __init__(self, plugin, api):
|
||||||
self.plugin = plugin
|
self.plugin = plugin
|
||||||
@@ -95,7 +242,14 @@ class RemovedChats:
|
|||||||
# account -> {dialog_id: (Dialog, [MessageObject], its top message id)}
|
# account -> {dialog_id: (Dialog, [MessageObject], its top message id)}
|
||||||
self._injected = {}
|
self._injected = {}
|
||||||
self._histories = {} # (account, dialog_id) -> views.removed_chat.History
|
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._avatar_paths = {} # (account, dialog_id) -> local photo path
|
||||||
|
self._positions = {} # (account, dialog_id) -> saved dialog placement
|
||||||
self._reported = {} # (account, dialog_id) -> monotonic time
|
self._reported = {} # (account, dialog_id) -> monotonic time
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._timer = None
|
self._timer = None
|
||||||
@@ -131,6 +285,13 @@ class RemovedChats:
|
|||||||
hook(CHAT, "updateBottomOverlay", after=self.after_overlay)
|
hook(CHAT, "updateBottomOverlay", after=self.after_overlay)
|
||||||
hook(STORAGE, "putMessages", before=self.before_store)
|
hook(STORAGE, "putMessages", before=self.before_store)
|
||||||
hook(IMAGES, "setForUserOrChat", before=self.before_avatar, arity=6)
|
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)
|
in_background(self._start)
|
||||||
|
|
||||||
def _start(self):
|
def _start(self):
|
||||||
@@ -139,6 +300,15 @@ class RemovedChats:
|
|||||||
cached = store.get_meta(f"removed_chats:{account}")
|
cached = store.get_meta(f"removed_chats:{account}")
|
||||||
if cached:
|
if cached:
|
||||||
self._set_list(account, json.loads(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)
|
on_ui(self.inject_all)
|
||||||
self._schedule(5)
|
self._schedule(5)
|
||||||
|
|
||||||
@@ -151,13 +321,21 @@ class RemovedChats:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self.handles = []
|
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] = {}
|
self._lists[account] = {}
|
||||||
|
|
||||||
|
def take_out():
|
||||||
|
for account in accounts_listed:
|
||||||
try:
|
try:
|
||||||
self.inject(account)
|
self.inject(account)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
diag.error("removed_chats.uninstall", exc)
|
diag.error("removed_chats.uninstall", exc)
|
||||||
|
|
||||||
|
on_ui(take_out)
|
||||||
|
|
||||||
# the server's list (background)
|
# the server's list (background)
|
||||||
|
|
||||||
def _schedule(self, seconds):
|
def _schedule(self, seconds):
|
||||||
@@ -186,11 +364,26 @@ class RemovedChats:
|
|||||||
return
|
return
|
||||||
if chats is None:
|
if chats is None:
|
||||||
return
|
return
|
||||||
|
previous = self._lists.get(account) or {}
|
||||||
store.put_meta(f"removed_chats:{account}", json.dumps(chats))
|
store.put_meta(f"removed_chats:{account}", json.dumps(chats))
|
||||||
self._set_list(account, 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:
|
with self._lock:
|
||||||
for key in [k for k in self._histories if k[0] == account]:
|
for dialog_id, item in (self._lists.get(account) or {}).items():
|
||||||
del self._histories[key] # read again on next open
|
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))
|
on_ui(lambda: self.inject(account))
|
||||||
for dialog_id, item in self._lists.get(account, {}).items():
|
for dialog_id, item in self._lists.get(account, {}).items():
|
||||||
avatar = item.get("avatar") or {}
|
avatar = item.get("avatar") or {}
|
||||||
@@ -207,24 +400,13 @@ class RemovedChats:
|
|||||||
self._lists[account] = lists
|
self._lists[account] = lists
|
||||||
|
|
||||||
def _peer(self, account, dialog_id, item):
|
def _peer(self, account, dialog_id, item):
|
||||||
"""The app's user/chat (cache or storage), else a stand-in."""
|
"""The removed chat's user/chat: the app's, else the last snapshot
|
||||||
from org.telegram.messenger import MessagesController, MessagesStorage
|
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:
|
if dialog_id > 0:
|
||||||
user = controller.getUser(dialog_id)
|
return people.user(account, dialog_id, item.get("title"))
|
||||||
if user is None:
|
return people.chat(account, -dialog_id, item.get("title"), item.get("kind"))
|
||||||
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")
|
|
||||||
)
|
|
||||||
|
|
||||||
def _load_avatar(self, account, dialog_id, avatar):
|
def _load_avatar(self, account, dialog_id, avatar):
|
||||||
found = avatars.logged_photo(account, avatar, self.api.masters())
|
found = avatars.logged_photo(account, avatar, self.api.masters())
|
||||||
@@ -244,16 +426,14 @@ class RemovedChats:
|
|||||||
self.inject(account)
|
self.inject(account)
|
||||||
|
|
||||||
def inject(self, account):
|
def inject(self, account):
|
||||||
"""Puts the account's removed chats into the app's chat list (and
|
"""Puts the account's removed chats into the app's chat list."""
|
||||||
takes out those no longer removed)."""
|
|
||||||
from org.telegram.messenger import MessagesController
|
from org.telegram.messenger import MessagesController
|
||||||
|
|
||||||
controller = MessagesController.getInstance(account)
|
controller = MessagesController.getInstance(account)
|
||||||
wanted = {}
|
wanted = {}
|
||||||
if self._enabled():
|
if self._enabled():
|
||||||
wanted = {
|
wanted = {
|
||||||
d: item
|
d: item for d, item in self._lists.get(account, {}).items()
|
||||||
for d, item in self._lists.get(account, {}).items()
|
|
||||||
if not item.get("hidden_at") and item.get("top_message")
|
if not item.get("hidden_at") and item.get("top_message")
|
||||||
}
|
}
|
||||||
current = self._injected.setdefault(account, {})
|
current = self._injected.setdefault(account, {})
|
||||||
@@ -262,8 +442,23 @@ class RemovedChats:
|
|||||||
self._take_out(controller, dialog_id, current.pop(dialog_id)[0])
|
self._take_out(controller, dialog_id, current.pop(dialog_id)[0])
|
||||||
store.set_removed_dialog(account, dialog_id, False)
|
store.set_removed_dialog(account, dialog_id, False)
|
||||||
for dialog_id, item in wanted.items():
|
for dialog_id, item in wanted.items():
|
||||||
if dialog_id in current or controller.dialogs_dict.get(dialog_id) is not None:
|
if dialog_id in current:
|
||||||
continue # listed, or the app has the chat (it is back)
|
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)
|
current[dialog_id] = self._dialog(controller, account, dialog_id, item)
|
||||||
store.set_removed_dialog(account, dialog_id, True)
|
store.set_removed_dialog(account, dialog_id, True)
|
||||||
self._put(controller, account)
|
self._put(controller, account)
|
||||||
@@ -284,14 +479,22 @@ class RemovedChats:
|
|||||||
dialog.id = dialog_id
|
dialog.id = dialog_id
|
||||||
dialog.peer = controller.getPeer(dialog_id)
|
dialog.peer = controller.getPeer(dialog_id)
|
||||||
dialog.top_message = int(top["message_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_inbox_max_id = dialog.top_message
|
||||||
dialog.read_outbox_max_id = dialog.top_message
|
dialog.read_outbox_max_id = dialog.top_message
|
||||||
dialog.last_message_date = _epoch(top.get("source_date"))
|
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
|
from java.util import ArrayList
|
||||||
|
|
||||||
message = removed_chat.from_text(account, dialog_id, top)
|
message = removed_chat.from_text(account, dialog_id, top)
|
||||||
store.put_server_states(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 = ArrayList()
|
||||||
preview.add(MessageObject(account, message, False, False))
|
preview.add(MessageObject(account, message, False, False))
|
||||||
return dialog, preview, int(dialog.top_message)
|
return dialog, preview, int(dialog.top_message)
|
||||||
@@ -314,6 +517,27 @@ class RemovedChats:
|
|||||||
dialogs.put(dialog_id, dialog)
|
dialogs.put(dialog_id, dialog)
|
||||||
if not everything.contains(dialog):
|
if not everything.contains(dialog):
|
||||||
everything.add(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:
|
if messages.get(dialog_id) is None:
|
||||||
messages.put(dialog_id, message)
|
messages.put(dialog_id, message)
|
||||||
|
|
||||||
@@ -324,6 +548,15 @@ class RemovedChats:
|
|||||||
controller.dialogs_dict.remove(dialog_id)
|
controller.dialogs_dict.remove(dialog_id)
|
||||||
controller.dialogMessage.remove(dialog_id)
|
controller.dialogMessage.remove(dialog_id)
|
||||||
controller.allDialogs.remove(dialog)
|
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):
|
def _redraw(self, account):
|
||||||
from java import jarray
|
from java import jarray
|
||||||
@@ -335,7 +568,9 @@ class RemovedChats:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def before_sort(self, param):
|
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):
|
if self._injected.get(account):
|
||||||
self._put(param.thisObject, account)
|
self._put(param.thisObject, account)
|
||||||
|
|
||||||
@@ -368,6 +603,9 @@ class RemovedChats:
|
|||||||
return
|
return
|
||||||
if only_history == 1 or not self.api.configured:
|
if only_history == 1 or not self.api.configured:
|
||||||
return
|
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)
|
key = (account, dialog_id)
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if now - self._reported.get(key, -_REPORT_AGAIN_SECONDS) < _REPORT_AGAIN_SECONDS:
|
if now - self._reported.get(key, -_REPORT_AGAIN_SECONDS) < _REPORT_AGAIN_SECONDS:
|
||||||
@@ -378,6 +616,88 @@ class RemovedChats:
|
|||||||
return
|
return
|
||||||
own = _from_screens()
|
own = _from_screens()
|
||||||
in_background(lambda: self._report(account, chat_id, own))
|
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):
|
def _report(self, account, chat_id, own):
|
||||||
try:
|
try:
|
||||||
@@ -390,6 +710,41 @@ class RemovedChats:
|
|||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
self.refresh(account)
|
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):
|
def _hide(self, account, dialog_id):
|
||||||
item = self._lists.get(account, {}).get(dialog_id)
|
item = self._lists.get(account, {}).get(dialog_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
@@ -408,17 +763,43 @@ class RemovedChats:
|
|||||||
|
|
||||||
# an opened removed chat
|
# 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):
|
def after_overlay(self, param):
|
||||||
chat = param.thisObject
|
chat = param.thisObject
|
||||||
if self.is_injected(chat.getCurrentAccount(), chat.getDialogId()):
|
if self.is_injected(chat.getCurrentAccount(), chat.getDialogId()):
|
||||||
make_read_only(chat, _NOTICE)
|
make_read_only(chat, _NOTICE)
|
||||||
# Again once the screen settled (a channel's bar comes back).
|
_clear_loading(chat)
|
||||||
on_ui(lambda: make_read_only(chat, _NOTICE), 600)
|
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):
|
def before_store(self, param):
|
||||||
"""putMessages(messages_Messages, dialogId, ...): a removed chat's
|
"""Prevent logged messages from becoming Telegram's cached history."""
|
||||||
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)."""
|
|
||||||
args = param.args
|
args = param.args
|
||||||
if len(args) < 2 or not hasattr(args[0], "messages"):
|
if len(args) < 2 or not hasattr(args[0], "messages"):
|
||||||
return
|
return
|
||||||
@@ -429,7 +810,6 @@ class RemovedChats:
|
|||||||
done = args[-1]
|
done = args[-1]
|
||||||
if done is not None and hasattr(done, "run"):
|
if done is not None and hasattr(done, "run"):
|
||||||
done.run()
|
done.run()
|
||||||
|
|
||||||
def _history(self, account, dialog_id):
|
def _history(self, account, dialog_id):
|
||||||
with self._lock:
|
with self._lock:
|
||||||
history = self._histories.get((account, dialog_id))
|
history = self._histories.get((account, dialog_id))
|
||||||
@@ -442,15 +822,106 @@ class RemovedChats:
|
|||||||
history.load()
|
history.load()
|
||||||
return history
|
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):
|
def before_request(self, param):
|
||||||
request = param.args[0]
|
request = param.args[0]
|
||||||
if request is None or not any(self._injected.values()):
|
if request is None or not any(self._injected.values()):
|
||||||
return
|
return
|
||||||
account = int(field(param.thisObject, "currentAccount") or 0)
|
account = int(cached_field(param.thisObject, "currentAccount") or 0)
|
||||||
dialog_id = _target(request)
|
dialog_id = _target(request)
|
||||||
if dialog_id is None or not self.is_injected(account, dialog_id):
|
if dialog_id is None or not self.is_injected(account, dialog_id):
|
||||||
return
|
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]
|
delegate, timestamped = param.args[1], param.args[2]
|
||||||
if param.method.getReturnType().getName() == "void":
|
if param.method.getReturnType().getName() == "void":
|
||||||
param.setResult(None)
|
param.setResult(None)
|
||||||
@@ -458,20 +929,52 @@ class RemovedChats:
|
|||||||
from java import jint
|
from java import jint
|
||||||
|
|
||||||
param.setResult(jint(0))
|
param.setResult(jint(0))
|
||||||
if name not in _ANSWERED:
|
if not answered:
|
||||||
return # Telegram has nothing for this chat
|
return # Telegram has nothing for this chat
|
||||||
|
|
||||||
def deliver():
|
def deliver():
|
||||||
history = self._history(account, dialog_id)
|
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(
|
picked = history.page(
|
||||||
int(request.offset_id), int(request.add_offset), int(request.limit)
|
int(request.offset_id), int(request.add_offset), int(request.limit)
|
||||||
)
|
)
|
||||||
elif name == "TLRPC$TL_channels_getMessages":
|
total = len(history.messages)
|
||||||
picked = history.by_ids(int(m.id) for m in java_list(request.id))
|
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:
|
else:
|
||||||
picked = []
|
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:
|
if delegate is not None:
|
||||||
delegate.run(response, None)
|
delegate.run(response, None)
|
||||||
elif timestamped is not None:
|
elif timestamped is not None:
|
||||||
@@ -504,3 +1007,54 @@ class RemovedChats:
|
|||||||
ImageLocation.getForPath(path), "50_50", param.args[1], None, target, jint(0)
|
ImageLocation.getForPath(path), "50_50", param.args[1], None, target, jint(0)
|
||||||
)
|
)
|
||||||
param.setResult(None)
|
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
@@ -36,15 +36,76 @@ def methods_named(class_name, name):
|
|||||||
return [m for m in cls.getDeclaredMethods() if m.getName() == 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):
|
def hook_methods(plugin, class_name, name, hook, arity=None):
|
||||||
"""Hooks every overload of `class_name.name` (or those with `arity`
|
"""Hooks every overload of `class_name.name` (or those with `arity`
|
||||||
parameters); returns the unhook handles."""
|
parameters); returns the unhook handles."""
|
||||||
handles = []
|
handles = []
|
||||||
|
short = class_name.rsplit(".", 1)[-1]
|
||||||
for method in methods_named(class_name, name):
|
for method in methods_named(class_name, name):
|
||||||
if arity is not None and len(method.getParameterTypes()) != arity:
|
if arity is not None and len(method.getParameterTypes()) != arity:
|
||||||
continue
|
continue
|
||||||
try:
|
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:
|
if handle is not None:
|
||||||
handles.append(handle)
|
handles.append(handle)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -67,6 +128,41 @@ def field(obj, name):
|
|||||||
return None
|
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):
|
def on_ui(fn, delay_ms=0):
|
||||||
from android_utils import run_on_ui_thread
|
from android_utils import run_on_ui_thread
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -92,7 +92,7 @@ class MessageLoggerPlugin(BasePlugin):
|
|||||||
)
|
)
|
||||||
in_background(accounts.refresh)
|
in_background(accounts.refresh)
|
||||||
in_background(self.purge_cache)
|
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):
|
def _reset_chat(self, context):
|
||||||
"""Chat menu: forget this chat's cached marks and check it again."""
|
"""Chat menu: forget this chat's cached marks and check it again."""
|
||||||
|
|||||||
+182
@@ -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
@@ -47,15 +47,35 @@ def make_read_only(chat, notice="Viewing deleted comments"):
|
|||||||
text = field(chat, "bottomOverlayText")
|
text = field(chat, "bottomOverlayText")
|
||||||
if overlay is None or text is None:
|
if overlay is None or text is None:
|
||||||
return
|
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 (
|
for name in (
|
||||||
"bottomOverlayChat", # a channel's bar (search, mute/join)
|
"bottomOverlayChat", # a channel's bar (search, mute/join)
|
||||||
"bottomOverlayChatText",
|
"bottomOverlayChatText",
|
||||||
"bottomOverlayStartButton",
|
"bottomOverlayStartButton",
|
||||||
"bottomOverlayProgress",
|
"bottomOverlayProgress",
|
||||||
|
"bottomOverlayImage",
|
||||||
|
"bottomOverlayLinks",
|
||||||
|
"bottomOverlayChatText2",
|
||||||
|
"bottomOverlayChatWaitsReply",
|
||||||
|
"replyButton",
|
||||||
|
"unblockButton",
|
||||||
|
"joinButton",
|
||||||
|
"addToContactsButton",
|
||||||
|
"reportSpamButton",
|
||||||
|
"topViewSeparator",
|
||||||
):
|
):
|
||||||
other = field(chat, name)
|
other = field(chat, name)
|
||||||
if other is not None:
|
if other is not None and other is not text:
|
||||||
|
try:
|
||||||
other.setVisibility(View.GONE)
|
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:
|
# The app animates the overlay out when the thread has an input bar:
|
||||||
# stop that, or it ends invisible (alpha 0) after this.
|
# stop that, or it ends invisible (alpha 0) after this.
|
||||||
animation = field(chat, "bottomOverlayAnimation")
|
animation = field(chat, "bottomOverlayAnimation")
|
||||||
@@ -63,10 +83,89 @@ def make_read_only(chat, notice="Viewing deleted comments"):
|
|||||||
animation.cancel()
|
animation.cancel()
|
||||||
text.setText(notice)
|
text.setText(notice)
|
||||||
text.setVisibility(View.VISIBLE)
|
text.setVisibility(View.VISIBLE)
|
||||||
|
text.setAlpha(1.0)
|
||||||
overlay.setAlpha(1.0)
|
overlay.setAlpha(1.0)
|
||||||
overlay.setVisibility(View.VISIBLE)
|
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):
|
def answer(account, request):
|
||||||
"""The response for a getReplies of a thread opened here, else None."""
|
"""The response for a getReplies of a thread opened here, else None."""
|
||||||
with _lock:
|
with _lock:
|
||||||
@@ -102,28 +201,17 @@ def _peer(controller, marked):
|
|||||||
return controller.getPeer(_android_peer_id(int(marked)))
|
return controller.getPeer(_android_peer_id(int(marked)))
|
||||||
|
|
||||||
|
|
||||||
def _sender_object(controller, marked, name):
|
def _sender_object(account, marked, name):
|
||||||
"""The sender's User/Chat from the account's cache, else a stand-in with
|
"""The sender's User/Chat: the app's, else the last snapshot saved while
|
||||||
the logged name (avatar drawn from its initials)."""
|
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 hooks.chat import _android_peer_id
|
||||||
from org.telegram.tgnet import TLRPC
|
|
||||||
|
|
||||||
peer_id = _android_peer_id(int(marked))
|
peer_id = _android_peer_id(int(marked))
|
||||||
if peer_id > 0:
|
if peer_id > 0:
|
||||||
user = controller.getUser(peer_id)
|
return people.user(account, peer_id, name), None
|
||||||
if user is None:
|
return None, people.chat(account, -peer_id, name, "channel")
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class Comments(History):
|
class Comments(History):
|
||||||
@@ -221,7 +309,7 @@ class Comments(History):
|
|||||||
self._into_thread(msg, item, discussion, top, sources)
|
self._into_thread(msg, item, discussion, top, sources)
|
||||||
if item.get("sender_id"):
|
if item.get("sender_id"):
|
||||||
user, chat = _sender_object(
|
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:
|
if user is not None:
|
||||||
users[user.id] = user
|
users[user.id] = user
|
||||||
|
|||||||
+354
-50
@@ -9,6 +9,7 @@ message.
|
|||||||
getHistory/getMessages for it (hooks/removed_chats.py).
|
getHistory/getMessages for it (hooks/removed_chats.py).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
import diag
|
import diag
|
||||||
@@ -23,34 +24,19 @@ _PAGES = 3 # history pages (of 100) loaded when the chat opens
|
|||||||
_COPIES_PER_REQUEST = 100
|
_COPIES_PER_REQUEST = 100
|
||||||
|
|
||||||
|
|
||||||
def stand_in_user(controller, peer_id, name):
|
def stand_in_user(account, peer_id, name):
|
||||||
from org.telegram.tgnet import TLRPC
|
"""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))
|
return people.user(account, peer_id, name)
|
||||||
if user is None:
|
|
||||||
user = TLRPC.TL_user()
|
|
||||||
user.id = int(peer_id)
|
|
||||||
user.first_name = name or "Deleted"
|
|
||||||
user.flags |= 2
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
def stand_in_chat(controller, chat_id, title, kind):
|
def stand_in_chat(account, chat_id, title, kind):
|
||||||
"""The app's chat (cached or stored), else a stand-in of the right kind."""
|
"""The app's chat, else its last saved snapshot, else a stand-in."""
|
||||||
from org.telegram.tgnet import TLRPC
|
import people
|
||||||
|
|
||||||
chat = controller.getChat(int(chat_id))
|
return people.chat(account, chat_id, title, kind)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class History:
|
class History:
|
||||||
@@ -62,13 +48,23 @@ class History:
|
|||||||
self.messages = [] # TL messages, newest first
|
self.messages = [] # TL messages, newest first
|
||||||
self.users = {}
|
self.users = {}
|
||||||
self.chats = {}
|
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.loaded = False
|
||||||
self._lock = threading.Lock()
|
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)
|
# loading (background)
|
||||||
|
|
||||||
def load(self):
|
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:
|
with self._lock:
|
||||||
if self.loaded:
|
if self.loaded:
|
||||||
return
|
return
|
||||||
@@ -80,25 +76,76 @@ class History:
|
|||||||
|
|
||||||
def _load(self):
|
def _load(self):
|
||||||
me = peers.own_user_id(self.account)
|
me = peers.own_user_id(self.account)
|
||||||
items, before, master = [], 0, None
|
disk_key = f"chat_head:{self.account}:{self.chat_id}"
|
||||||
for _page in range(_PAGES):
|
result = None
|
||||||
|
for attempt in range(2):
|
||||||
try:
|
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:
|
except ApiError as exc:
|
||||||
diag.log(f"removed chat {self.chat_id}: {exc}")
|
diag.log(f"removed chat {self.chat_id} try {attempt + 1}: {exc}")
|
||||||
break
|
if result:
|
||||||
page = (result or {}).get("messages") or []
|
try:
|
||||||
if not page:
|
store.put_meta(disk_key, json.dumps(result))
|
||||||
break
|
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")
|
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)
|
revealed = store.is_revealed(self.account, self.dialog_id)
|
||||||
items = [i for i in items if revealed or not i.get("hidden_at")]
|
items = [i for i in items if revealed or not i.get("hidden_at")]
|
||||||
store.put_server_states(self.account, self.dialog_id, items)
|
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 = []
|
built = []
|
||||||
for item in items:
|
for item in items:
|
||||||
try:
|
try:
|
||||||
@@ -106,7 +153,97 @@ class History:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
diag.error("removed_chat.message", exc)
|
diag.error("removed_chat.message", exc)
|
||||||
self.messages = sorted(built, key=lambda m: -int(m.id))
|
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
|
@staticmethod
|
||||||
def _pointer(item):
|
def _pointer(item):
|
||||||
@@ -115,7 +252,7 @@ class History:
|
|||||||
return None
|
return None
|
||||||
return int(logged["chat_id"]), int(logged["message_id"])
|
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
|
"""Logged copies by (log chat, message), read through an account here
|
||||||
that can, per log group."""
|
that can, per log group."""
|
||||||
by_group = {}
|
by_group = {}
|
||||||
@@ -126,19 +263,67 @@ class History:
|
|||||||
found = {}
|
found = {}
|
||||||
masters = [master, *self.api.masters()]
|
masters = [master, *self.api.masters()]
|
||||||
for log_chat, ids in by_group.items():
|
for log_chat, ids in by_group.items():
|
||||||
|
# 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
|
||||||
channel_id = peers.channel_id_from_marked(log_chat)
|
channel_id = peers.channel_id_from_marked(log_chat)
|
||||||
reader = media_share.reader_for(self.account, log_chat, masters)
|
reader = media_share.reader_for(self.account, log_chat, masters)
|
||||||
if reader is None:
|
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
|
continue
|
||||||
ordered = sorted(ids)
|
ordered = sorted(missing)
|
||||||
|
fetched = 0
|
||||||
for start in range(0, len(ordered), _COPIES_PER_REQUEST):
|
for start in range(0, len(ordered), _COPIES_PER_REQUEST):
|
||||||
chunk = ordered[start : start + _COPIES_PER_REQUEST]
|
chunk = ordered[start : start + _COPIES_PER_REQUEST]
|
||||||
for message_id, copy in _fetch(reader, -channel_id, chunk).items():
|
for message_id, copy in _fetch(reader, -channel_id, chunk).items():
|
||||||
if reader != self.account:
|
fetched += 1
|
||||||
media_share.copy_media(reader, copy)
|
|
||||||
found[(log_chat, message_id)] = copy
|
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
|
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
|
# building
|
||||||
|
|
||||||
def _message(self, item, copy):
|
def _message(self, item, copy):
|
||||||
@@ -159,12 +344,27 @@ class History:
|
|||||||
if sender:
|
if sender:
|
||||||
peer_id = _android_peer_id(int(sender))
|
peer_id = _android_peer_id(int(sender))
|
||||||
if peer_id > 0:
|
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
|
self.users[int(user.id)] = user
|
||||||
else:
|
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
|
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 = TLRPC.TL_messageReplyHeader()
|
||||||
reply.reply_to_msg_id = int(item["reply_to"])
|
reply.reply_to_msg_id = int(item["reply_to"])
|
||||||
reply.flags |= 16
|
reply.flags |= 16
|
||||||
@@ -172,9 +372,113 @@ class History:
|
|||||||
msg.flags |= 8
|
msg.flags |= 8
|
||||||
return msg
|
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
|
# 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 java.util import ArrayList
|
||||||
from org.telegram.tgnet import TLRPC
|
from org.telegram.tgnet import TLRPC
|
||||||
|
|
||||||
@@ -190,10 +494,10 @@ class History:
|
|||||||
response.chats.add(chat)
|
response.chats.add(chat)
|
||||||
return response
|
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:
|
"""getHistory's slice: newest first, from `offset_id` (exclusive; 0:
|
||||||
the newest) moved by `add_offset` (negative: newer ones too)."""
|
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:
|
if offset_id:
|
||||||
start = next(
|
start = next(
|
||||||
(i for i, m in enumerate(messages) if int(m.id) < offset_id),
|
(i for i, m in enumerate(messages) if int(m.id) < offset_id),
|
||||||
|
|||||||
Reference in new issue
Block a user