generated from exteraGramPlugins/pluginBase
Message Logger: hidden messages on the server, removed chats, old chat photos
Publish plugin / publish (push) Successful in 6s
Publish plugin / publish (push) Successful in 6s
- Hidden deleted messages are marked on the server (local marks migrated); "Reveal hidden messages" in the chat menu, Unhide in the message menu and the selection bar. - Chats an account lost are reported, listed among its chats again and opened read-only with their logged messages, all deleted. - Logged chat photos: a removed chat's avatar, and in a chat's photos the ones it no longer has, translucent with a trash icon in the viewer's previews. - Fix: the deleted-post comments thread is answered locally again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0176f7099d
commit
362b358d8d
15 files changed
+1821
-125
No files matched your search
@@ -0,0 +1,356 @@
|
||||
"""Chat photos the logger posted (service posts in the chat's log topic, see
|
||||
telegram-panel `avatars`), read from those posts.
|
||||
|
||||
- A removed chat shows its latest logged photo (hooks/removed_chats.py).
|
||||
- A chat's photos (MessagesController.getDialogPhotos: DialogPhotos `photos`,
|
||||
shown by the profile's gallery and the photo viewer, both redrawn on
|
||||
NotificationCenter.dialogPhotosUpdate) get the logged photos the chat no
|
||||
longer has, after its own. The photo viewer's thumbnails of those
|
||||
(GroupedPhotosListView.onDraw) are translucent like deleted messages, with
|
||||
a red trash icon.
|
||||
|
||||
A post is read and its photo downloaded through an account that can read
|
||||
the log group; the app's file cache is shared by its accounts, so the photo
|
||||
then shows for any of them.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
from base_plugin import MethodHook
|
||||
|
||||
import accounts
|
||||
import diag
|
||||
import peers
|
||||
from api import ApiError
|
||||
from jutil import field, hook_methods, in_background, java_list, on_ui
|
||||
from views.history import _epoch
|
||||
|
||||
CONTROLLER = "org.telegram.messenger.MessagesController"
|
||||
THUMBS = "org.telegram.ui.Components.GroupedPhotosListView"
|
||||
NOTIFICATIONS = "org.telegram.messenger.NotificationCenter"
|
||||
_DOWNLOAD_SECONDS = 20
|
||||
# A logged photo whose id isn't Telegram's (logged by a bot) is the chat's
|
||||
# own when one of the chat's photos was set this close to its post.
|
||||
_SAME_PHOTO_SECONDS = 10
|
||||
|
||||
|
||||
def _photo_of(copy):
|
||||
"""The photo of a logger post (a photo message, or the first photo of a
|
||||
rich message)."""
|
||||
if copy is None:
|
||||
return None
|
||||
photo = getattr(getattr(copy, "media", None), "photo", None)
|
||||
if photo is not None and getattr(photo, "sizes", None) is not None:
|
||||
return photo
|
||||
rich = getattr(copy, "rich_message", None)
|
||||
photos = getattr(rich, "photos", None) if rich is not None else None
|
||||
if photos is not None and photos.size():
|
||||
return photos.get(0)
|
||||
return None
|
||||
|
||||
|
||||
def logged_photo(account, avatar):
|
||||
"""(photo, reader slot, post) of a logged chat photo, else None (blocking)."""
|
||||
from views.removed_chat import _fetch
|
||||
|
||||
logged = avatar.get("logged") or {}
|
||||
if not avatar.get("has_photo") or not logged.get("chat_id"):
|
||||
return None
|
||||
channel_id = peers.channel_id_from_marked(logged["chat_id"])
|
||||
readers = accounts.with_access(
|
||||
-channel_id, prefer=(account, accounts.slot_of(avatar.get("master_tg_id")))
|
||||
)
|
||||
if not readers:
|
||||
return None
|
||||
message_id = int(logged["message_id"])
|
||||
copy = _fetch(readers[0], -channel_id, [message_id]).get(message_id)
|
||||
photo = _photo_of(copy)
|
||||
return (photo, readers[0], copy) if photo is not None else None
|
||||
|
||||
|
||||
def _size(photo, side):
|
||||
"""The smallest downloadable size at least `side` px wide (else the
|
||||
largest): inline thumbnails have no file."""
|
||||
sizes = [
|
||||
size
|
||||
for size in java_list(photo.sizes)
|
||||
if getattr(size, "location", None) is not None and int(size.w or 0) > 0
|
||||
]
|
||||
if not sizes:
|
||||
return None
|
||||
bigger = [size for size in sizes if max(int(size.w), int(size.h)) >= side]
|
||||
if bigger:
|
||||
return min(bigger, key=lambda size: int(size.w))
|
||||
return max(sizes, key=lambda size: int(size.w))
|
||||
|
||||
|
||||
def download(reader, photo, copy, side):
|
||||
"""Downloads the photo's size closest to `side` px through `reader` into
|
||||
the shared cache; its path, or None (blocking)."""
|
||||
from java import jint
|
||||
from org.telegram.messenger import FileLoader, ImageLocation, MessageObject
|
||||
|
||||
size = _size(photo, side)
|
||||
if size is None:
|
||||
return None
|
||||
loader = FileLoader.getInstance(reader)
|
||||
path = loader.getPathToAttach(size, True)
|
||||
if not path.exists():
|
||||
parent = MessageObject(reader, copy, False, False)
|
||||
loader.loadFile(
|
||||
ImageLocation.getForPhoto(size, photo),
|
||||
parent,
|
||||
"jpg",
|
||||
jint(FileLoader.PRIORITY_HIGH),
|
||||
jint(1),
|
||||
)
|
||||
deadline = time.monotonic() + _DOWNLOAD_SECONDS
|
||||
while not path.exists() and time.monotonic() < deadline:
|
||||
time.sleep(0.25)
|
||||
return str(path.getAbsolutePath()) if path.exists() else None
|
||||
|
||||
|
||||
class Avatars:
|
||||
def __init__(self, plugin, api):
|
||||
self.plugin = plugin
|
||||
self.api = api
|
||||
self.handles = []
|
||||
self._lock = threading.Lock()
|
||||
self._recorded = {} # (account, dialog) -> [(photo, original id, date)]
|
||||
self._ids = set() # ids of those photos: drawn with the trash icon
|
||||
self._loading = set()
|
||||
self._updates = {} # (account, dialog) -> args of its last dialogPhotosUpdate
|
||||
self._icon = None
|
||||
|
||||
def install(self):
|
||||
avatars = self
|
||||
|
||||
class AfterPhotos(MethodHook):
|
||||
def after_hooked_method(self, param):
|
||||
try:
|
||||
avatars.photos_wanted(param.thisObject, param.args[0], param.getResult())
|
||||
except Exception as exc:
|
||||
diag.error("avatars.photos", exc)
|
||||
|
||||
class AfterThumbs(MethodHook):
|
||||
def after_hooked_method(self, param):
|
||||
try:
|
||||
avatars.draw_thumbs(param.thisObject, param.args[0])
|
||||
except Exception as exc:
|
||||
diag.error("avatars.thumbs", exc)
|
||||
|
||||
class BeforeNotify(MethodHook):
|
||||
def before_hooked_method(self, param):
|
||||
try:
|
||||
avatars.photos_updated(param)
|
||||
except Exception as exc:
|
||||
diag.error("avatars.update", exc)
|
||||
|
||||
self.handles += hook_methods(self.plugin, CONTROLLER, "getDialogPhotos", AfterPhotos())
|
||||
self.handles += hook_methods(self.plugin, THUMBS, "onDraw", AfterThumbs())
|
||||
self.handles += hook_methods(
|
||||
self.plugin, NOTIFICATIONS, "postNotificationName", BeforeNotify()
|
||||
)
|
||||
|
||||
def uninstall(self):
|
||||
for handle in self.handles:
|
||||
try:
|
||||
self.plugin.unhook_method(handle)
|
||||
except Exception:
|
||||
pass
|
||||
self.handles = []
|
||||
|
||||
# loading (background)
|
||||
|
||||
def photos_wanted(self, controller, dialog_id, holder):
|
||||
"""getDialogPhotos: the chat's logged photos are loaded once, then
|
||||
put into its photos (again after the app reloaded them)."""
|
||||
if not self.api.configured or holder is None:
|
||||
return
|
||||
account = int(field(controller, "currentAccount") or 0)
|
||||
key = (account, int(dialog_id))
|
||||
with self._lock:
|
||||
if key in self._recorded:
|
||||
self._add_to(*key, holder)
|
||||
return
|
||||
if key in self._loading:
|
||||
return
|
||||
self._loading.add(key)
|
||||
in_background(lambda: self._load(*key))
|
||||
|
||||
def _load(self, account, dialog_id):
|
||||
try:
|
||||
chat_id = peers.marked_chat_id(account, dialog_id)
|
||||
if chat_id is None:
|
||||
return
|
||||
try:
|
||||
logged = self.api.avatars(peers.own_user_id(account), chat_id)
|
||||
except ApiError as exc:
|
||||
diag.log(f"avatars {chat_id}: {exc}")
|
||||
return
|
||||
recorded = []
|
||||
for avatar in logged:
|
||||
found = logged_photo(account, avatar)
|
||||
if found is None:
|
||||
continue
|
||||
photo, reader, copy = found
|
||||
download(reader, photo, copy, 160)
|
||||
if download(reader, photo, copy, 800) is None:
|
||||
continue
|
||||
original = avatar.get("photo_id")
|
||||
recorded.append(
|
||||
(photo, int(original) if original else None, _epoch(avatar.get("date")))
|
||||
)
|
||||
with self._lock:
|
||||
self._recorded[(account, dialog_id)] = recorded
|
||||
self._ids.update(int(p.id) for p, _o, _d in recorded)
|
||||
if recorded:
|
||||
diag.log(f"avatars {chat_id}: {len(recorded)} logged photos")
|
||||
on_ui(lambda: self._append(account, dialog_id))
|
||||
finally:
|
||||
with self._lock:
|
||||
self._loading.discard((account, dialog_id))
|
||||
|
||||
# the gallery (UI thread)
|
||||
|
||||
def _gone(self, photos, original, date):
|
||||
"""Whether a logged photo is not one the chat still has."""
|
||||
for photo in photos:
|
||||
if photo is None or int(photo.id) in self._ids:
|
||||
continue
|
||||
if original is not None and int(photo.id) == original:
|
||||
return False
|
||||
if original is None and abs(int(photo.date) - date) <= _SAME_PHOTO_SECONDS:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _add_to(self, account, dialog_id, holder):
|
||||
"""Appends the logged photos the chat no longer has; whether any."""
|
||||
photos = field(holder, "photos")
|
||||
if photos is None:
|
||||
return False
|
||||
current = java_list(photos)
|
||||
shown = {int(p.id) for p in current if p is not None}
|
||||
added = False
|
||||
for photo, original, date in self._recorded.get((account, dialog_id), []):
|
||||
if int(photo.id) in shown or not self._gone(current, original, date):
|
||||
continue
|
||||
photos.add(photo)
|
||||
shown.add(int(photo.id))
|
||||
added = True
|
||||
return added
|
||||
|
||||
def _append(self, account, dialog_id):
|
||||
from org.telegram.messenger import MessagesController, NotificationCenter
|
||||
|
||||
holder = MessagesController.getInstance(account).getDialogPhotos(dialog_id)
|
||||
if holder is None or not self._add_to(account, dialog_id, holder):
|
||||
return
|
||||
args = self._updates.get((account, dialog_id))
|
||||
if args is None:
|
||||
from java import jarray
|
||||
from java.lang import Object
|
||||
|
||||
args = jarray(Object)([holder])
|
||||
NotificationCenter.getInstance(account).postNotificationName(
|
||||
NotificationCenter.dialogPhotosUpdate, args
|
||||
)
|
||||
|
||||
def photos_updated(self, param):
|
||||
"""dialogPhotosUpdate: the logged photos are put back into the
|
||||
(reloaded) list before the gallery redraws."""
|
||||
from org.telegram.messenger import NotificationCenter
|
||||
|
||||
if int(param.args[0]) != int(NotificationCenter.dialogPhotosUpdate):
|
||||
return
|
||||
extra = param.args[1]
|
||||
holder = next(
|
||||
(a for a in (extra or []) if a is not None and hasattr(a, "photos")), None
|
||||
)
|
||||
if holder is None:
|
||||
return
|
||||
account = int(field(param.thisObject, "currentAccount") or 0)
|
||||
dialog_id = int(field(holder, "dialogId") or 0)
|
||||
self._updates[(account, dialog_id)] = extra
|
||||
if (account, dialog_id) in self._recorded:
|
||||
self._add_to(account, dialog_id, holder)
|
||||
|
||||
# the trash icon
|
||||
|
||||
def _logged_at(self, strip, index):
|
||||
"""Whether the strip's item `index` is a logged photo."""
|
||||
for name in ("currentObjects", "currentPhotos"):
|
||||
items = field(strip, name)
|
||||
if items is None or not 0 <= index < items.size():
|
||||
continue
|
||||
item = items.get(index)
|
||||
photo_id = getattr(item, "photoId", None) if name == "currentPhotos" else None
|
||||
if photo_id is None:
|
||||
photo_id = getattr(item, "id", None)
|
||||
if photo_id is not None and int(photo_id) in self._ids:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _alpha(self):
|
||||
from config import ALPHA_CHOICES, DEFAULTS, DELETED_ALPHA
|
||||
|
||||
index = int(self.plugin.get_setting(DELETED_ALPHA, DEFAULTS[DELETED_ALPHA]) or 0)
|
||||
return ALPHA_CHOICES[max(0, min(index, len(ALPHA_CHOICES) - 1))][1]
|
||||
|
||||
def draw_thumbs(self, strip, canvas):
|
||||
"""The photo viewer's thumbnails of logged photos: translucent, with a
|
||||
small trash icon (GroupedPhotosListView: `imagesToDraw`, each
|
||||
receiver's param its index)."""
|
||||
if not self._ids:
|
||||
return
|
||||
receivers = field(strip, "imagesToDraw")
|
||||
if receivers is None:
|
||||
return
|
||||
from org.telegram.messenger import AndroidUtilities
|
||||
|
||||
from android.graphics import Paint
|
||||
from java import jint
|
||||
|
||||
icon, paint = self._trash()
|
||||
# The strip draws its thumbnails opaque (it sets their alpha itself)
|
||||
# over the viewer's black: black at the remaining opacity on top
|
||||
# looks the same as the deleted message opacity.
|
||||
shade = Paint()
|
||||
shade.setColor(jint(int((1.0 - self._alpha()) * 255) << 24))
|
||||
dp = AndroidUtilities.dp
|
||||
size = dp(14)
|
||||
for receiver in java_list(receivers):
|
||||
if receiver is None or not self._logged_at(strip, int(receiver.getParam())):
|
||||
continue
|
||||
canvas.drawRect(
|
||||
float(receiver.getImageX()),
|
||||
float(receiver.getImageY()),
|
||||
float(receiver.getImageX2()),
|
||||
float(receiver.getImageY2()),
|
||||
shade,
|
||||
)
|
||||
right = int(receiver.getImageX2()) - dp(2)
|
||||
bottom = int(receiver.getImageY2()) - dp(2)
|
||||
paint.setAlpha(0x99)
|
||||
canvas.drawCircle(right - size / 2.0, bottom - size / 2.0, size * 0.8, paint)
|
||||
icon.setAlpha(255)
|
||||
icon.setBounds(right - size, bottom - size, right, bottom)
|
||||
icon.draw(canvas)
|
||||
|
||||
def _trash(self):
|
||||
if self._icon is None:
|
||||
from android.graphics import Paint, PorterDuff, PorterDuffColorFilter
|
||||
from java import jint
|
||||
from org.telegram.messenger import R
|
||||
from org.telegram.ui.ActionBar import Theme
|
||||
|
||||
from hooks.render import _trash_drawable
|
||||
|
||||
icon = _trash_drawable(R.drawable.msg_delete)
|
||||
red = jint(Theme.getColor(Theme.key_text_RedBold))
|
||||
icon.setColorFilter(PorterDuffColorFilter(red, PorterDuff.Mode.SRC_IN))
|
||||
paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
paint.setColor(jint(0x99000000 - (1 << 32))) # ARGB as a signed int
|
||||
self._icon = (icon, paint)
|
||||
return self._icon
|
||||
+90
-54
@@ -167,6 +167,59 @@ def _rich_plain_text(rich):
|
||||
return "\n".join(_rich_text(b.text) for b in blocks)
|
||||
|
||||
|
||||
def build_from_copy(account, dialog_id, message_id, state, copy, strip):
|
||||
"""A deleted message rebuilt from its logged copy (`strip`: the copy
|
||||
starts with the logger's header) with the server's `state`."""
|
||||
from java.util import ArrayList
|
||||
from org.telegram.messenger import MessageObject, MessagesController
|
||||
from org.telegram.tgnet import TLRPC
|
||||
|
||||
controller = MessagesController.getInstance(account)
|
||||
msg = TLRPC.TL_message()
|
||||
msg.id = message_id
|
||||
msg.dialog_id = dialog_id
|
||||
msg.peer_id = controller.getPeer(dialog_id)
|
||||
sender = state.get("sender_id")
|
||||
msg.from_id = (
|
||||
controller.getPeer(_android_peer_id(sender)) if sender else msg.peer_id
|
||||
)
|
||||
msg.flags |= 256
|
||||
msg.date = _epoch(state.get("source_date")) or int(copy.date)
|
||||
if int(state.get("edit_count") or 0) > 0:
|
||||
msg.edit_date = _epoch(state.get("edit_date")) or msg.date
|
||||
msg.flags |= 32768 # edited: the app draws its pencil
|
||||
text = copy.message or ""
|
||||
entities = [(int(e.offset), int(e.length), e) for e in java_list(copy.entities)]
|
||||
if strip:
|
||||
text, entities = header.strip(text, entities)
|
||||
msg.message = text
|
||||
msg.entities = ArrayList()
|
||||
for offset, length, entity in entities:
|
||||
entity.offset = offset
|
||||
entity.length = length
|
||||
msg.entities.add(entity)
|
||||
media = getattr(copy, "media", None)
|
||||
if media is not None and not isinstance(media, TLRPC.TL_messageMediaEmpty):
|
||||
msg.media = media
|
||||
msg.flags |= 512
|
||||
rich = getattr(copy, "rich_message", None)
|
||||
if rich is not None:
|
||||
if strip:
|
||||
_strip_rich_header(rich)
|
||||
plain = _rich_plain_text(rich)
|
||||
if plain: # never a blank bubble: else it stays rich
|
||||
# Text only: a normal bubble, as the original was.
|
||||
msg.message = plain
|
||||
msg.entities = ArrayList()
|
||||
else:
|
||||
msg.rich_message = rich
|
||||
msg.flags2 |= 1 << 12
|
||||
own = peers.own_user_id(account)
|
||||
msg.out = sender is not None and int(sender) == own
|
||||
msg.unread = False
|
||||
return MessageObject(account, msg, True, True)
|
||||
|
||||
|
||||
class ChatSync:
|
||||
def __init__(self, plugin, api):
|
||||
self.plugin = plugin
|
||||
@@ -227,6 +280,8 @@ class ChatSync:
|
||||
if chat_id is None:
|
||||
return
|
||||
key = (account, dialog_id)
|
||||
if store.is_removed_dialog(account, dialog_id):
|
||||
return # its history brings its state (hooks/removed_chats.py)
|
||||
self._fragments[key] = fragment
|
||||
if key in self._inflight:
|
||||
return
|
||||
@@ -247,9 +302,10 @@ class ChatSync:
|
||||
# cached answers (they were never in the app's own database).
|
||||
if self.plugin.get_setting(RESTORE_MISSING, True):
|
||||
present = set(ids)
|
||||
revealed = store.is_revealed(account, dialog_id)
|
||||
gone = [
|
||||
s
|
||||
for s in store.cached_deleted(account, dialog_id, low, high)
|
||||
for s in store.cached_deleted(account, dialog_id, low, high, revealed)
|
||||
if int(s["message_id"]) not in present
|
||||
]
|
||||
if gone:
|
||||
@@ -355,10 +411,11 @@ class ChatSync:
|
||||
# restoring (background -> UI)
|
||||
|
||||
def _restore(self, fragment, account, dialog_id, chat_id, me, states):
|
||||
revealed = store.is_revealed(account, dialog_id)
|
||||
for state in states:
|
||||
message_id = int(state["message_id"])
|
||||
if store.is_hidden(account, dialog_id, message_id):
|
||||
continue # the user removed it from view
|
||||
if not revealed and store.is_hidden(account, dialog_id, message_id):
|
||||
continue # removed from view
|
||||
key = (account, dialog_id, message_id)
|
||||
if key in self._restoring:
|
||||
continue
|
||||
@@ -396,56 +453,6 @@ class ChatSync:
|
||||
|
||||
tg.get_messages(account, -channel_id, [copy_id], done)
|
||||
|
||||
def _build(self, account, dialog_id, message_id, state, copy, strip):
|
||||
from java.util import ArrayList
|
||||
from org.telegram.messenger import MessageObject, MessagesController
|
||||
from org.telegram.tgnet import TLRPC
|
||||
|
||||
controller = MessagesController.getInstance(account)
|
||||
msg = TLRPC.TL_message()
|
||||
msg.id = message_id
|
||||
msg.dialog_id = dialog_id
|
||||
msg.peer_id = controller.getPeer(dialog_id)
|
||||
sender = state.get("sender_id")
|
||||
msg.from_id = (
|
||||
controller.getPeer(_android_peer_id(sender)) if sender else msg.peer_id
|
||||
)
|
||||
msg.flags |= 256
|
||||
msg.date = _epoch(state.get("source_date")) or int(copy.date)
|
||||
if int(state.get("edit_count") or 0) > 0:
|
||||
msg.edit_date = _epoch(state.get("edit_date")) or msg.date
|
||||
msg.flags |= 32768 # edited: the app draws its pencil
|
||||
text = copy.message or ""
|
||||
entities = [(int(e.offset), int(e.length), e) for e in java_list(copy.entities)]
|
||||
if strip:
|
||||
text, entities = header.strip(text, entities)
|
||||
msg.message = text
|
||||
msg.entities = ArrayList()
|
||||
for offset, length, entity in entities:
|
||||
entity.offset = offset
|
||||
entity.length = length
|
||||
msg.entities.add(entity)
|
||||
media = getattr(copy, "media", None)
|
||||
if media is not None and not isinstance(media, TLRPC.TL_messageMediaEmpty):
|
||||
msg.media = media
|
||||
msg.flags |= 512
|
||||
rich = getattr(copy, "rich_message", None)
|
||||
if rich is not None:
|
||||
if strip:
|
||||
_strip_rich_header(rich)
|
||||
plain = _rich_plain_text(rich)
|
||||
if plain: # never a blank bubble: else it stays rich
|
||||
# Text only: a normal bubble, as the original was.
|
||||
msg.message = plain
|
||||
msg.entities = ArrayList()
|
||||
else:
|
||||
msg.rich_message = rich
|
||||
msg.flags2 |= 1 << 12
|
||||
own = peers.own_user_id(account)
|
||||
msg.out = sender is not None and int(sender) == own
|
||||
msg.unread = False
|
||||
return MessageObject(account, msg, True, True)
|
||||
|
||||
def _insert(self, fragment, account, dialog_id, message_id, state, copy, strip):
|
||||
"""Hands the rebuilt message to the chat the way new messages arrive
|
||||
(ChatActivity.processNewMessages), so the app places it and keeps its
|
||||
@@ -461,7 +468,7 @@ class ChatSync:
|
||||
from java import jboolean
|
||||
from java.util import ArrayList
|
||||
|
||||
obj = self._build(account, dialog_id, message_id, state, copy, strip)
|
||||
obj = build_from_copy(account, dialog_id, message_id, state, copy, strip)
|
||||
store.mark_deleted(account, dialog_id, [message_id])
|
||||
batch = ArrayList()
|
||||
batch.add(obj)
|
||||
@@ -552,6 +559,35 @@ class ChatSync:
|
||||
on_ui(redraw)
|
||||
return removed
|
||||
|
||||
def restore_hidden(self, fragment):
|
||||
"""The chat reveals its hidden messages: those deleted in the loaded
|
||||
range come back from the logger (UI thread). Returns how many."""
|
||||
account = int(fragment.getCurrentAccount())
|
||||
dialog_id = int(fragment.getDialogId())
|
||||
chat_id = peers.marked_chat_id(account, dialog_id)
|
||||
ids = [
|
||||
int(m.getId())
|
||||
for m in java_list(field(fragment, "messages"))
|
||||
if m is not None and int(m.getId()) > 0
|
||||
]
|
||||
if chat_id is None or not ids:
|
||||
return 0
|
||||
low = 1 if _end_reached(fragment, "endReached") else min(ids)
|
||||
high = _MAX_ID if _end_reached(fragment, "forwardEndReached") else max(ids)
|
||||
present = set(ids)
|
||||
hidden = set(store.hidden_deleted(account, dialog_id))
|
||||
gone = [
|
||||
s
|
||||
for s in store.cached_deleted(account, dialog_id, low, high, True)
|
||||
if int(s["message_id"]) in hidden and int(s["message_id"]) not in present
|
||||
]
|
||||
if gone:
|
||||
me = peers.own_user_id(account)
|
||||
in_background(
|
||||
lambda: self._restore(fragment, account, dialog_id, chat_id, me, gone)
|
||||
)
|
||||
return len(gone)
|
||||
|
||||
def drop_from_view(self, account, dialog_id, ids):
|
||||
"""Takes messages out of the open chat's list (UI thread)."""
|
||||
fragment = self._open_chat(account, dialog_id)
|
||||
|
||||
+17
-12
@@ -5,10 +5,10 @@
|
||||
filtered out of MessagesStorage.markMessagesAsDeleted and the
|
||||
messagesDeleted notification; the server's echo then counts as someone
|
||||
else's deletion, see hooks/deletes.py). Unticked (default), it is deleted
|
||||
as usual and remembered as hidden, so it is never restored from the
|
||||
logger here;
|
||||
as usual and hidden (on the server, for every client), so it is not
|
||||
restored from the logger;
|
||||
- already deleted (kept) messages get a plain dialog without checkboxes
|
||||
whose button removes them from view (locally only) and hides them.
|
||||
whose button removes them from this device's view and hides them.
|
||||
|
||||
AlertsCreator.createDeleteMessagesAlert(fragment, user, chat, encryptedChat,
|
||||
chatInfo, mergeDialogId, selectedMessage, selectedMessages[2], group,
|
||||
@@ -53,9 +53,10 @@ def _deleted(message):
|
||||
|
||||
|
||||
class DeleteDialog:
|
||||
def __init__(self, plugin, chats):
|
||||
def __init__(self, plugin, chats, hidden):
|
||||
self.plugin = plugin
|
||||
self.chats = chats # hooks/chat.ChatSync
|
||||
self.hidden = hidden # hooks/hidden.Hidden
|
||||
self.handles = []
|
||||
self._choice = None # [checked] of the open dialog
|
||||
self._keeping = {} # (account, id) -> monotonic time to keep until
|
||||
@@ -169,12 +170,14 @@ class DeleteDialog:
|
||||
|
||||
count = len(targets)
|
||||
builder = AlertDialogBuilder(fragment.getParentActivity())
|
||||
builder.set_title("Remove deleted message" + ("s" if count > 1 else ""))
|
||||
builder.set_title("Hide deleted message" + ("s" if count > 1 else ""))
|
||||
builder.set_message(
|
||||
("These messages were" if count > 1 else "This message was")
|
||||
+ " already deleted. Remove "
|
||||
+ " already deleted. Hide "
|
||||
+ ("them" if count > 1 else "it")
|
||||
+ " from view on this device?"
|
||||
+ " from this chat? The logger keeps "
|
||||
+ ("them" if count > 1 else "it")
|
||||
+ ": the chat menu reveals hidden messages."
|
||||
)
|
||||
|
||||
def remove(_builder, _which):
|
||||
@@ -188,14 +191,15 @@ class DeleteDialog:
|
||||
if hide_dim is not None:
|
||||
hide_dim.run()
|
||||
|
||||
builder.set_positive_button("Remove", remove)
|
||||
builder.set_positive_button("Hide", remove)
|
||||
builder.set_negative_button("Back")
|
||||
builder.make_button_red(AlertDialogBuilder.BUTTON_POSITIVE)
|
||||
builder.set_on_dismiss_listener(dismissed)
|
||||
builder.show()
|
||||
|
||||
def _remove_from_view(self, targets):
|
||||
"""Removes already deleted messages from this device only."""
|
||||
"""Hides already deleted messages (on the server) and takes them out
|
||||
of this device's chat."""
|
||||
from java import jboolean, jint, jlong
|
||||
from java.lang import Object
|
||||
from java.util import ArrayList
|
||||
@@ -207,8 +211,9 @@ class DeleteDialog:
|
||||
account, dialog_id, message_id = _key(message)
|
||||
by_chat.setdefault((account, dialog_id), []).append(message_id)
|
||||
for (account, dialog_id), ids in by_chat.items():
|
||||
store.hide(account, dialog_id, ids)
|
||||
store.forget_deleted(account, dialog_id, ids)
|
||||
# Its cached state stays: revealing the chat's hidden messages
|
||||
# restores it from there.
|
||||
self.hidden.hide(account, dialog_id, ids)
|
||||
channel_id = 0
|
||||
if dialog_id < 0:
|
||||
chat = MessagesController.getInstance(account).getChat(-dialog_id)
|
||||
@@ -249,7 +254,7 @@ class DeleteDialog:
|
||||
store.mark_deleted(account, dialog_id, ids)
|
||||
diag.log(f"keeping own deletion {dialog_id}: {ids}")
|
||||
return list(ids)
|
||||
store.hide(account, dialog_id, ids)
|
||||
self.hidden.hide(account, dialog_id, ids)
|
||||
return []
|
||||
|
||||
def filter_storage(self, param):
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Deleted messages removed from view ("hidden"), marked on the server so
|
||||
every client skips them; a chat can reveal its hidden messages again.
|
||||
|
||||
- `flush` sends the marks waiting in the store to the server;
|
||||
- the chat menu toggles revealing (per chat, until the app restarts): the
|
||||
hidden deleted messages come back from the logger, then go again;
|
||||
- a revealed hidden message has "Unhide" in its menu (hooks/menu.py), and
|
||||
while revealing, the selection bar gets an Unhide button for those
|
||||
selected (ChatActivity.createActionMode adds the bar's items,
|
||||
updateActionModeTitle runs on every selection change; probed on 12.10.6).
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
||||
from base_plugin import MethodHook
|
||||
|
||||
import diag
|
||||
import peers
|
||||
import store
|
||||
from api import ApiError
|
||||
from jutil import field, hook_methods, in_background, on_ui
|
||||
|
||||
CHAT = "org.telegram.ui.ChatActivity"
|
||||
_UNHIDE_ID = 0x4D4C01 # the selection bar item
|
||||
|
||||
|
||||
def _bulletin(text, error=False):
|
||||
def show():
|
||||
from ui.bulletin import BulletinHelper
|
||||
|
||||
(BulletinHelper.show_error if error else BulletinHelper.show_info)(text)
|
||||
|
||||
on_ui(show)
|
||||
|
||||
|
||||
def _selected(chat):
|
||||
"""MessageObjects selected in the chat (both halves of a merged chat)."""
|
||||
out = []
|
||||
for array in field(chat, "selectedMessagesIds") or []:
|
||||
if array is None:
|
||||
continue
|
||||
for index in range(array.size()):
|
||||
message = array.valueAt(index)
|
||||
if message is not None:
|
||||
out.append(message)
|
||||
return out
|
||||
|
||||
|
||||
def is_revealed_hidden(message):
|
||||
"""A hidden deleted message shown because its chat reveals them."""
|
||||
if message is None or int(message.getId()) <= 0:
|
||||
return False
|
||||
account, dialog_id = int(message.currentAccount), int(message.getDialogId())
|
||||
message_id = int(message.getId())
|
||||
return (
|
||||
store.is_revealed(account, dialog_id)
|
||||
and store.is_deleted(account, dialog_id, message_id)
|
||||
and store.is_hidden(account, dialog_id, message_id)
|
||||
)
|
||||
|
||||
|
||||
class Hidden:
|
||||
def __init__(self, plugin, api, chats):
|
||||
self.plugin = plugin
|
||||
self.api = api
|
||||
self.chats = chats # hooks/chat.ChatSync
|
||||
self.handles = []
|
||||
self._flushing = threading.Lock()
|
||||
self._items = {} # ChatActivity classGuid -> the bar's Unhide item
|
||||
|
||||
def install(self):
|
||||
hidden = self
|
||||
|
||||
class AfterActionMode(MethodHook):
|
||||
def after_hooked_method(self, param):
|
||||
try:
|
||||
hidden.add_bar_item(param.thisObject)
|
||||
except Exception as exc:
|
||||
diag.error("hidden.bar", exc)
|
||||
|
||||
class AfterTitle(MethodHook):
|
||||
def after_hooked_method(self, param):
|
||||
try:
|
||||
hidden.update_bar_item(param.thisObject)
|
||||
except Exception as exc:
|
||||
diag.error("hidden.title", exc)
|
||||
|
||||
self.handles += hook_methods(self.plugin, CHAT, "createActionMode", AfterActionMode())
|
||||
self.handles += hook_methods(self.plugin, CHAT, "updateActionModeTitle", AfterTitle())
|
||||
in_background(self.flush)
|
||||
|
||||
def uninstall(self):
|
||||
for handle in self.handles:
|
||||
try:
|
||||
self.plugin.unhook_method(handle)
|
||||
except Exception:
|
||||
pass
|
||||
self.handles = []
|
||||
|
||||
# the server
|
||||
|
||||
def flush(self):
|
||||
"""Sends hidden marks waiting in the store (background)."""
|
||||
if not self.api.configured or not self._flushing.acquire(blocking=False):
|
||||
return
|
||||
try:
|
||||
for (account, dialog_id, hidden), ids in store.pending_hidden().items():
|
||||
me = peers.own_user_id(account)
|
||||
chat_id = peers.marked_chat_id(account, dialog_id)
|
||||
if not me or chat_id is None:
|
||||
continue
|
||||
try:
|
||||
self.api.hidden(me, chat_id, ids, hidden)
|
||||
except ApiError as exc:
|
||||
diag.log(f"hidden {chat_id}: {exc}")
|
||||
continue
|
||||
store.hidden_sent(account, dialog_id, ids, hidden)
|
||||
diag.log(f"{'hid' if hidden else 'unhid'} {chat_id}: {ids}")
|
||||
finally:
|
||||
self._flushing.release()
|
||||
|
||||
def hide(self, account, dialog_id, ids, hidden=True):
|
||||
store.set_hidden(account, dialog_id, ids, hidden)
|
||||
in_background(self.flush)
|
||||
|
||||
# revealing (chat menu)
|
||||
|
||||
def toggle_reveal(self, fragment):
|
||||
account = int(fragment.getCurrentAccount())
|
||||
dialog_id = int(fragment.getDialogId())
|
||||
on = not store.is_revealed(account, dialog_id)
|
||||
store.reveal(account, dialog_id, on)
|
||||
if on:
|
||||
count = self.chats.restore_hidden(fragment)
|
||||
# And the server's answer, for those not cached.
|
||||
store.expire_coverage(account, dialog_id)
|
||||
self.chats.schedule(fragment)
|
||||
_bulletin(
|
||||
f"Message Logger: revealing hidden deleted messages ({count} loaded)"
|
||||
if count
|
||||
else "Message Logger: revealing hidden deleted messages"
|
||||
)
|
||||
else:
|
||||
ids = store.hidden_deleted(account, dialog_id)
|
||||
self.chats.drop_from_view(account, dialog_id, ids)
|
||||
_bulletin("Message Logger: hidden deleted messages are hidden again")
|
||||
|
||||
# unhiding
|
||||
|
||||
def unhide(self, messages):
|
||||
by_chat = {}
|
||||
for message in messages:
|
||||
if is_revealed_hidden(message):
|
||||
key = (int(message.currentAccount), int(message.getDialogId()))
|
||||
by_chat.setdefault(key, []).append(int(message.getId()))
|
||||
for (account, dialog_id), ids in by_chat.items():
|
||||
self.hide(account, dialog_id, ids, hidden=False)
|
||||
self.chats.refresh_dialog(account, dialog_id, ids)
|
||||
count = sum(len(ids) for ids in by_chat.values())
|
||||
if count:
|
||||
_bulletin(f"Message Logger: {count} message{'s' if count > 1 else ''} unhidden")
|
||||
|
||||
# the selection bar
|
||||
|
||||
def add_bar_item(self, chat):
|
||||
"""The bar's Unhide item (also for a chat opened before the plugin
|
||||
loaded: its bar exists already)."""
|
||||
bar = chat.getActionBar()
|
||||
menu = bar.createActionMode() if bar is not None else None
|
||||
if menu is None:
|
||||
return None
|
||||
from android.view import View
|
||||
from android_utils import OnClickListener
|
||||
from org.telegram.messenger import AndroidUtilities, R
|
||||
|
||||
item = menu.getItem(_UNHIDE_ID)
|
||||
if item is None:
|
||||
item = menu.addItemWithWidth(
|
||||
_UNHIDE_ID, R.drawable.msg_views, AndroidUtilities.dp(54)
|
||||
)
|
||||
item.setContentDescription("Unhide")
|
||||
item.setVisibility(View.GONE)
|
||||
|
||||
def chosen(_view):
|
||||
self.unhide(_selected(chat))
|
||||
chat.clearSelectionMode()
|
||||
|
||||
item.setOnClickListener(OnClickListener(chosen))
|
||||
self._items[int(chat.getClassGuid())] = item
|
||||
return item
|
||||
|
||||
def update_bar_item(self, chat):
|
||||
item = self._items.get(int(chat.getClassGuid())) or self.add_bar_item(chat)
|
||||
if item is None:
|
||||
return
|
||||
from android.view import View
|
||||
|
||||
account, dialog_id = int(chat.getCurrentAccount()), int(chat.getDialogId())
|
||||
show = store.is_revealed(account, dialog_id) and any(
|
||||
is_revealed_hidden(m) for m in _selected(chat)
|
||||
)
|
||||
item.setVisibility(View.VISIBLE if show else View.GONE)
|
||||
+32
-18
@@ -1,4 +1,5 @@
|
||||
""""Edit history" in the message menu itself, right above Forward.
|
||||
""""Edit history" (and "Unhide" for a revealed hidden message, see
|
||||
hooks/hidden.py) in the message menu itself, right above Forward.
|
||||
|
||||
Plugin menu items (MenuItemData) only go into the menu's Plugins folder, and
|
||||
exteraGram drops option ids it doesn't know from fillMessageMenu's lists, so
|
||||
@@ -12,10 +13,12 @@ from base_plugin import MethodHook
|
||||
|
||||
import diag
|
||||
import store
|
||||
from hooks.hidden import is_revealed_hidden
|
||||
from jutil import field, hook_methods, java_class
|
||||
|
||||
CHAT = "org.telegram.ui.ChatActivity"
|
||||
_LABEL = "Edit history"
|
||||
_HISTORY = "Edit history"
|
||||
_UNHIDE = "Unhide"
|
||||
|
||||
|
||||
def _worth_it(message):
|
||||
@@ -58,9 +61,10 @@ def _find_item(view, text):
|
||||
|
||||
|
||||
class Menu:
|
||||
def __init__(self, plugin, open_history):
|
||||
def __init__(self, plugin, open_history, unhide):
|
||||
self.plugin = plugin
|
||||
self.open_history = open_history
|
||||
self.unhide = unhide # hooks/hidden.Hidden.unhide
|
||||
self.handles = []
|
||||
self._close = None
|
||||
|
||||
@@ -87,39 +91,49 @@ class Menu:
|
||||
def add_item(self, chat):
|
||||
message = field(chat, "selectedObject")
|
||||
popup = field(chat, "scrimPopupWindow")
|
||||
if popup is None or not _worth_it(message):
|
||||
if popup is None or message is None:
|
||||
return
|
||||
# The createMenu overloads call one another: add the row once.
|
||||
if _find_item(popup.getContentView(), _LABEL) is not None:
|
||||
return
|
||||
from org.telegram.messenger import LocaleController, R
|
||||
from org.telegram.messenger import R
|
||||
|
||||
rows = []
|
||||
if _worth_it(message):
|
||||
rows.append((_HISTORY, R.drawable.msg_edit, lambda: self.open_history(message)))
|
||||
if is_revealed_hidden(message):
|
||||
rows.append((_UNHIDE, R.drawable.msg_views, lambda: self.unhide([message])))
|
||||
# The createMenu overloads call one another: add the rows once.
|
||||
content = popup.getContentView()
|
||||
if not rows or _find_item(content, rows[0][0]) is not None:
|
||||
return
|
||||
from org.telegram.messenger import LocaleController
|
||||
|
||||
# Forward is a row in some chats and an icon in the bottom action bar
|
||||
# in others (groups): then the row goes first in the list.
|
||||
# in others (groups): then the rows go first in the list.
|
||||
anchor = _find_item(
|
||||
content, str(LocaleController.getString(R.string.Forward))
|
||||
) or _first_item(content)
|
||||
if anchor is None:
|
||||
diag.log("menu: no row to place Edit history by")
|
||||
diag.log("menu: no row to place the rows by")
|
||||
return
|
||||
forward = anchor
|
||||
for label, icon, action in rows:
|
||||
self._add_row(chat, anchor, label, icon, action)
|
||||
|
||||
def _add_row(self, chat, anchor, label, icon, action):
|
||||
from android_utils import OnClickListener
|
||||
from org.telegram.ui.ActionBar import ActionBarMenuSubItem
|
||||
|
||||
parent = forward.getParent()
|
||||
item = ActionBarMenuSubItem(forward.getContext(), False, False)
|
||||
item.setTextAndIcon(_LABEL, R.drawable.msg_edit)
|
||||
color = forward.getTextView().getCurrentTextColor()
|
||||
parent = anchor.getParent()
|
||||
item = ActionBarMenuSubItem(anchor.getContext(), False, False)
|
||||
item.setTextAndIcon(label, icon)
|
||||
color = anchor.getTextView().getCurrentTextColor()
|
||||
item.setColors(color, color)
|
||||
item.setMinimumWidth(forward.getMinimumWidth())
|
||||
item.setMinimumWidth(anchor.getMinimumWidth())
|
||||
|
||||
def chosen(_view):
|
||||
self._close_menu(chat)
|
||||
self.open_history(message)
|
||||
action()
|
||||
|
||||
item.setOnClickListener(OnClickListener(chosen))
|
||||
parent.addView(item, parent.indexOfChild(forward), forward.getLayoutParams())
|
||||
parent.addView(item, parent.indexOfChild(anchor), anchor.getLayoutParams())
|
||||
|
||||
def _close_menu(self, chat):
|
||||
if self._close is None:
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
"""Chats an account lost (left, kicked, deleted by it or the other side), kept
|
||||
by the logger: reported to the server, listed among the account's chats
|
||||
again, and opened read-only with their logged messages, all deleted.
|
||||
|
||||
- A chat leaves the list through MessagesController.deleteDialog(long did,
|
||||
int onlyHistory[, boolean revoke]) (the user deleting it, or the app when
|
||||
the account left or was kicked); onlyHistory 1 only clears the history.
|
||||
The removal is reported, `own` when the call came from the app's screens
|
||||
(the user deleted it: it is not listed again). Deleting a listed removed
|
||||
chat hides it.
|
||||
- The server's list (`/chats/removed`) is put into MessagesController
|
||||
(dialogs_dict, allDialogs, dialogMessage) before every sortDialogs, with
|
||||
the user/chat it needs (the app's own when it still has it, else a
|
||||
stand-in) and the chat's latest logged photo as its avatar
|
||||
(ImageReceiver.setForUserOrChat).
|
||||
- Opening one: its requests never reach Telegram (sendRequestInternal):
|
||||
getHistory and channels.getMessages are answered from the logged history
|
||||
(views/removed_chat.py), messages.search with nothing, the rest dropped;
|
||||
the input bar becomes a notice (ChatActivity.updateBottomOverlay).
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
|
||||
from base_plugin import MethodHook
|
||||
|
||||
import accounts
|
||||
import diag
|
||||
import peers
|
||||
import store
|
||||
from api import ApiError
|
||||
from config import DEFAULTS, SHOW_REMOVED_CHATS
|
||||
from hooks import avatars
|
||||
from jutil import field, hook_methods, in_background, java_list, on_ui
|
||||
from views import removed_chat
|
||||
from views.comments import make_read_only
|
||||
from views.history import _epoch
|
||||
|
||||
CONTROLLER = "org.telegram.messenger.MessagesController"
|
||||
CONNECTIONS = "org.telegram.tgnet.ConnectionsManager"
|
||||
CHAT = "org.telegram.ui.ChatActivity"
|
||||
IMAGES = "org.telegram.messenger.ImageReceiver"
|
||||
STORAGE = "org.telegram.messenger.MessagesStorage"
|
||||
_NOTICE = "Removed chat · kept by the logger"
|
||||
_REFRESH_SECONDS = 900
|
||||
_REPORT_AGAIN_SECONDS = 30
|
||||
_ANSWERED = (
|
||||
"TLRPC$TL_messages_getHistory",
|
||||
"TLRPC$TL_channels_getMessages",
|
||||
"TLRPC$TL_messages_search",
|
||||
)
|
||||
|
||||
|
||||
def _from_screens():
|
||||
"""Whether the running call came from the app's screens (a user action)."""
|
||||
from java.lang import Thread
|
||||
|
||||
return any(
|
||||
str(frame.getClassName()).startswith("org.telegram.ui.")
|
||||
for frame in Thread.currentThread().getStackTrace()
|
||||
)
|
||||
|
||||
|
||||
def _dialog_id(chat_id):
|
||||
from hooks.chat import _android_peer_id
|
||||
|
||||
return _android_peer_id(int(chat_id))
|
||||
|
||||
|
||||
def _target(request):
|
||||
"""The dialog a request is about (its peer/channel/user), else None."""
|
||||
for name in ("peer", "channel", "user_id", "id"):
|
||||
value = getattr(request, name, None)
|
||||
if value is None or isinstance(value, (bool, int, float, str)):
|
||||
continue
|
||||
kind = type(value).__name__
|
||||
if "nputPeerChannel" in kind or "nputChannel" in kind:
|
||||
return -int(value.channel_id)
|
||||
if "nputPeerChat" in kind:
|
||||
return -int(value.chat_id)
|
||||
if "nputPeerUser" in kind or kind.endswith("TL_inputUser"):
|
||||
return int(value.user_id)
|
||||
return None
|
||||
|
||||
|
||||
class RemovedChats:
|
||||
def __init__(self, plugin, api):
|
||||
self.plugin = plugin
|
||||
self.api = api
|
||||
self.handles = []
|
||||
self._lists = {} # account -> {dialog_id: server item}
|
||||
self._peers = {} # (account, dialog_id) -> User/Chat (cached or stand-in)
|
||||
# account -> {dialog_id: (Dialog, [MessageObject], its top message id)}
|
||||
self._injected = {}
|
||||
self._histories = {} # (account, dialog_id) -> views.removed_chat.History
|
||||
self._avatar_paths = {} # (account, dialog_id) -> local photo path
|
||||
self._reported = {} # (account, dialog_id) -> monotonic time
|
||||
self._lock = threading.Lock()
|
||||
self._timer = None
|
||||
|
||||
def _enabled(self):
|
||||
return bool(self.plugin.get_setting(SHOW_REMOVED_CHATS, DEFAULTS[SHOW_REMOVED_CHATS]))
|
||||
|
||||
def install(self):
|
||||
chats = self
|
||||
|
||||
def hook(class_name, name, before=None, after=None, arity=None):
|
||||
class Hook(MethodHook):
|
||||
def before_hooked_method(self, param):
|
||||
if before is not None:
|
||||
try:
|
||||
before(param)
|
||||
except Exception as exc:
|
||||
diag.error(f"removed_chats.{name}", exc)
|
||||
|
||||
def after_hooked_method(self, param):
|
||||
if after is not None:
|
||||
try:
|
||||
after(param)
|
||||
except Exception as exc:
|
||||
diag.error(f"removed_chats.{name}", exc)
|
||||
|
||||
chats.handles += hook_methods(chats.plugin, class_name, name, Hook(), arity)
|
||||
|
||||
hook(CONTROLLER, "deleteDialog", before=self.before_delete, arity=2)
|
||||
hook(CONTROLLER, "deleteDialog", before=self.before_delete, arity=3)
|
||||
hook(CONTROLLER, "sortDialogs", before=self.before_sort)
|
||||
hook(CONNECTIONS, "sendRequestInternal", before=self.before_request)
|
||||
hook(CHAT, "updateBottomOverlay", after=self.after_overlay)
|
||||
hook(STORAGE, "putMessages", before=self.before_store)
|
||||
hook(IMAGES, "setForUserOrChat", before=self.before_avatar, arity=6)
|
||||
in_background(self._start)
|
||||
|
||||
def _start(self):
|
||||
"""Lists from the last run at once (offline too), then the server's."""
|
||||
for account, _user in accounts.signed_in():
|
||||
cached = store.get_meta(f"removed_chats:{account}")
|
||||
if cached:
|
||||
self._set_list(account, json.loads(cached))
|
||||
on_ui(self.inject_all)
|
||||
self._schedule(5)
|
||||
|
||||
def uninstall(self):
|
||||
if self._timer is not None:
|
||||
self._timer.cancel()
|
||||
for handle in self.handles:
|
||||
try:
|
||||
self.plugin.unhook_method(handle)
|
||||
except Exception:
|
||||
pass
|
||||
self.handles = []
|
||||
for account in list(self._injected):
|
||||
self._lists[account] = {}
|
||||
try:
|
||||
self.inject(account)
|
||||
except Exception as exc:
|
||||
diag.error("removed_chats.uninstall", exc)
|
||||
|
||||
# the server's list (background)
|
||||
|
||||
def _schedule(self, seconds):
|
||||
self._timer = threading.Timer(seconds, self._tick)
|
||||
self._timer.daemon = True
|
||||
self._timer.start()
|
||||
|
||||
def _tick(self):
|
||||
try:
|
||||
self.refresh_all()
|
||||
finally:
|
||||
self._schedule(_REFRESH_SECONDS)
|
||||
|
||||
def refresh_all(self):
|
||||
if not self.api.configured or not self._enabled():
|
||||
return
|
||||
for account, _user in accounts.signed_in():
|
||||
self.refresh(account)
|
||||
|
||||
def refresh(self, account):
|
||||
me = peers.own_user_id(account)
|
||||
try:
|
||||
chats = self.api.removed_chats(me)
|
||||
except ApiError as exc:
|
||||
diag.log(f"removed chats: {exc}")
|
||||
return
|
||||
if chats is None:
|
||||
return
|
||||
store.put_meta(f"removed_chats:{account}", json.dumps(chats))
|
||||
self._set_list(account, chats)
|
||||
with self._lock:
|
||||
for key in [k for k in self._histories if k[0] == account]:
|
||||
del self._histories[key] # read again on next open
|
||||
on_ui(lambda: self.inject(account))
|
||||
for dialog_id, item in self._lists.get(account, {}).items():
|
||||
avatar = item.get("avatar") or {}
|
||||
if avatar.get("has_photo") and (account, dialog_id) not in self._avatar_paths:
|
||||
in_background(lambda d=dialog_id, a=avatar: self._load_avatar(account, d, a))
|
||||
|
||||
def _set_list(self, account, chats):
|
||||
lists = {}
|
||||
for item in chats:
|
||||
dialog_id = _dialog_id(item["chat_id"])
|
||||
lists[dialog_id] = item
|
||||
if (account, dialog_id) not in self._peers:
|
||||
self._peers[(account, dialog_id)] = self._peer(account, dialog_id, item)
|
||||
self._lists[account] = lists
|
||||
|
||||
def _peer(self, account, dialog_id, item):
|
||||
"""The app's user/chat (cache or storage), else a stand-in."""
|
||||
from org.telegram.messenger import MessagesController, MessagesStorage
|
||||
|
||||
import tg
|
||||
|
||||
controller = MessagesController.getInstance(account)
|
||||
if dialog_id > 0:
|
||||
user = controller.getUser(dialog_id)
|
||||
if user is None:
|
||||
try:
|
||||
user = MessagesStorage.getInstance(account).getUserSync(dialog_id)
|
||||
except Exception:
|
||||
user = None
|
||||
return user or removed_chat.stand_in_user(controller, dialog_id, item.get("title"))
|
||||
chat = tg.channel_chat(account, -dialog_id)
|
||||
return chat or removed_chat.stand_in_chat(
|
||||
controller, -dialog_id, item.get("title"), item.get("kind")
|
||||
)
|
||||
|
||||
def _load_avatar(self, account, dialog_id, avatar):
|
||||
found = avatars.logged_photo(account, avatar)
|
||||
if found is None:
|
||||
return
|
||||
photo, reader, copy = found
|
||||
path = avatars.download(reader, photo, copy, 160)
|
||||
if path is None:
|
||||
return
|
||||
self._avatar_paths[(account, dialog_id)] = path
|
||||
on_ui(lambda: self._redraw(account))
|
||||
|
||||
# the chat list (UI thread)
|
||||
|
||||
def inject_all(self):
|
||||
for account in list(self._lists):
|
||||
self.inject(account)
|
||||
|
||||
def inject(self, account):
|
||||
"""Puts the account's removed chats into the app's chat list (and
|
||||
takes out those no longer removed)."""
|
||||
from org.telegram.messenger import MessagesController
|
||||
|
||||
controller = MessagesController.getInstance(account)
|
||||
wanted = {}
|
||||
if self._enabled():
|
||||
wanted = {
|
||||
d: item
|
||||
for d, item in self._lists.get(account, {}).items()
|
||||
if not item.get("hidden_at") and item.get("top_message")
|
||||
}
|
||||
current = self._injected.setdefault(account, {})
|
||||
for dialog_id in list(current):
|
||||
if dialog_id not in wanted and self.is_injected(account, dialog_id):
|
||||
self._take_out(controller, dialog_id, current.pop(dialog_id)[0])
|
||||
store.set_removed_dialog(account, dialog_id, False)
|
||||
for dialog_id, item in wanted.items():
|
||||
if dialog_id in current or controller.dialogs_dict.get(dialog_id) is not None:
|
||||
continue # listed, or the app has the chat (it is back)
|
||||
current[dialog_id] = self._dialog(controller, account, dialog_id, item)
|
||||
store.set_removed_dialog(account, dialog_id, True)
|
||||
self._put(controller, account)
|
||||
controller.sortDialogs(None)
|
||||
self._redraw(account)
|
||||
|
||||
def _dialog(self, controller, account, dialog_id, item):
|
||||
from org.telegram.messenger import MessageObject
|
||||
from org.telegram.tgnet import TLRPC
|
||||
|
||||
peer = self._peers.get((account, dialog_id))
|
||||
if dialog_id > 0:
|
||||
controller.putUser(peer, True)
|
||||
else:
|
||||
controller.putChat(peer, True)
|
||||
top = item["top_message"]
|
||||
dialog = TLRPC.TL_dialog()
|
||||
dialog.id = dialog_id
|
||||
dialog.peer = controller.getPeer(dialog_id)
|
||||
dialog.top_message = int(top["message_id"])
|
||||
dialog.read_inbox_max_id = dialog.top_message
|
||||
dialog.read_outbox_max_id = dialog.top_message
|
||||
dialog.last_message_date = _epoch(top.get("source_date"))
|
||||
from java.util import ArrayList
|
||||
|
||||
message = removed_chat.from_text(account, dialog_id, top)
|
||||
store.put_server_states(account, dialog_id, [top])
|
||||
# dialogMessage holds the dialog's preview messages (an album's parts).
|
||||
preview = ArrayList()
|
||||
preview.add(MessageObject(account, message, False, False))
|
||||
return dialog, preview, int(dialog.top_message)
|
||||
|
||||
def _put(self, controller, account):
|
||||
"""Our dialogs in the controller's lists (they rebuild them)."""
|
||||
injected = self._injected.get(account) or {}
|
||||
dialogs = controller.dialogs_dict
|
||||
everything = controller.allDialogs
|
||||
messages = controller.dialogMessage
|
||||
for dialog_id, (dialog, message, _top) in list(injected.items()):
|
||||
if not self.is_injected(account, dialog_id):
|
||||
continue # back: the app's dialog now
|
||||
existing = dialogs.get(dialog_id)
|
||||
if existing is not None and not existing.equals(dialog):
|
||||
del injected[dialog_id] # the app has the chat again
|
||||
everything.remove(dialog)
|
||||
continue
|
||||
if existing is None:
|
||||
dialogs.put(dialog_id, dialog)
|
||||
if not everything.contains(dialog):
|
||||
everything.add(dialog)
|
||||
if messages.get(dialog_id) is None:
|
||||
messages.put(dialog_id, message)
|
||||
|
||||
def _take_out(self, controller, dialog_id, dialog):
|
||||
if controller.dialogs_dict.get(dialog_id) is not None and controller.dialogs_dict.get(
|
||||
dialog_id
|
||||
).equals(dialog):
|
||||
controller.dialogs_dict.remove(dialog_id)
|
||||
controller.dialogMessage.remove(dialog_id)
|
||||
controller.allDialogs.remove(dialog)
|
||||
|
||||
def _redraw(self, account):
|
||||
from java import jarray
|
||||
from java.lang import Object
|
||||
from org.telegram.messenger import NotificationCenter
|
||||
|
||||
NotificationCenter.getInstance(account).postNotificationName(
|
||||
NotificationCenter.dialogsNeedReload, jarray(Object)([])
|
||||
)
|
||||
|
||||
def before_sort(self, param):
|
||||
account = int(field(param.thisObject, "currentAccount") or 0)
|
||||
if self._injected.get(account):
|
||||
self._put(param.thisObject, account)
|
||||
|
||||
def is_injected(self, account, dialog_id):
|
||||
"""Whether the dialog is a removed chat listed here. One that got a
|
||||
new message is back: the app keeps the dialog, it is no longer ours."""
|
||||
account, dialog_id = int(account), int(dialog_id)
|
||||
entry = (self._injected.get(account) or {}).get(dialog_id)
|
||||
if entry is None:
|
||||
return False
|
||||
if int(entry[0].top_message) == entry[2]:
|
||||
return True
|
||||
with self._lock:
|
||||
(self._injected.get(account) or {}).pop(dialog_id, None)
|
||||
self._lists.get(account, {}).pop(dialog_id, None)
|
||||
self._histories.pop((account, dialog_id), None)
|
||||
store.set_removed_dialog(account, dialog_id, False)
|
||||
diag.log(f"removed chat {dialog_id} is back")
|
||||
return False
|
||||
|
||||
# removals
|
||||
|
||||
def before_delete(self, param):
|
||||
dialog_id, only_history = int(param.args[0]), int(param.args[1])
|
||||
account = int(field(param.thisObject, "currentAccount") or 0)
|
||||
if self.is_injected(account, dialog_id):
|
||||
param.setResult(None) # not a chat on Telegram
|
||||
if only_history != 1:
|
||||
self._hide(account, dialog_id)
|
||||
return
|
||||
if only_history == 1 or not self.api.configured:
|
||||
return
|
||||
key = (account, dialog_id)
|
||||
now = time.monotonic()
|
||||
if now - self._reported.get(key, -_REPORT_AGAIN_SECONDS) < _REPORT_AGAIN_SECONDS:
|
||||
return # the overloads call one another
|
||||
self._reported[key] = now
|
||||
chat_id = peers.marked_chat_id(account, dialog_id)
|
||||
if chat_id is None:
|
||||
return
|
||||
own = _from_screens()
|
||||
in_background(lambda: self._report(account, chat_id, own))
|
||||
|
||||
def _report(self, account, chat_id, own):
|
||||
try:
|
||||
known = self.api.chat_removed(peers.own_user_id(account), chat_id, own)
|
||||
except ApiError as exc:
|
||||
diag.log(f"chat removal {chat_id}: {exc}")
|
||||
return
|
||||
diag.log(f"chat {chat_id} removed{' by the user' if own else ''}, known={known}")
|
||||
if known:
|
||||
time.sleep(2)
|
||||
self.refresh(account)
|
||||
|
||||
def _hide(self, account, dialog_id):
|
||||
item = self._lists.get(account, {}).get(dialog_id)
|
||||
if item is None:
|
||||
return
|
||||
item["hidden_at"] = "now"
|
||||
on_ui(lambda: self.inject(account))
|
||||
chat_id = item["chat_id"]
|
||||
|
||||
def send():
|
||||
try:
|
||||
self.api.chat_hidden(peers.own_user_id(account), chat_id, True)
|
||||
except ApiError as exc:
|
||||
diag.log(f"chat hide {chat_id}: {exc}")
|
||||
|
||||
in_background(send)
|
||||
|
||||
# an opened removed chat
|
||||
|
||||
def after_overlay(self, param):
|
||||
chat = param.thisObject
|
||||
if self.is_injected(chat.getCurrentAccount(), chat.getDialogId()):
|
||||
make_read_only(chat, _NOTICE)
|
||||
# Again once the screen settled (a channel's bar comes back).
|
||||
on_ui(lambda: make_read_only(chat, _NOTICE), 600)
|
||||
|
||||
def before_store(self, param):
|
||||
"""putMessages(messages_Messages, dialogId, ...): a removed chat's
|
||||
logged messages are not stored as the app's own (they would make it
|
||||
a chat of the app's, read from its cache from then on)."""
|
||||
args = param.args
|
||||
if len(args) < 2 or not hasattr(args[0], "messages"):
|
||||
return
|
||||
account = int(field(param.thisObject, "currentAccount") or 0)
|
||||
if not self.is_injected(account, int(args[1])):
|
||||
return
|
||||
param.setResult(None)
|
||||
done = args[-1]
|
||||
if done is not None and hasattr(done, "run"):
|
||||
done.run()
|
||||
|
||||
def _history(self, account, dialog_id):
|
||||
with self._lock:
|
||||
history = self._histories.get((account, dialog_id))
|
||||
if history is None:
|
||||
item = self._lists.get(account, {}).get(dialog_id) or {}
|
||||
history = removed_chat.History(
|
||||
self.api, account, dialog_id, int(item.get("chat_id") or dialog_id)
|
||||
)
|
||||
self._histories[(account, dialog_id)] = history
|
||||
history.load()
|
||||
return history
|
||||
|
||||
def before_request(self, param):
|
||||
request = param.args[0]
|
||||
if request is None or not any(self._injected.values()):
|
||||
return
|
||||
account = int(field(param.thisObject, "currentAccount") or 0)
|
||||
dialog_id = _target(request)
|
||||
if dialog_id is None or not self.is_injected(account, dialog_id):
|
||||
return
|
||||
name = type(request).__name__
|
||||
delegate, timestamped = param.args[1], param.args[2]
|
||||
if param.method.getReturnType().getName() == "void":
|
||||
param.setResult(None)
|
||||
else:
|
||||
from java import jint
|
||||
|
||||
param.setResult(jint(0))
|
||||
if name not in _ANSWERED:
|
||||
return # Telegram has nothing for this chat
|
||||
|
||||
def deliver():
|
||||
history = self._history(account, dialog_id)
|
||||
if name == "TLRPC$TL_messages_getHistory":
|
||||
picked = history.page(
|
||||
int(request.offset_id), int(request.add_offset), int(request.limit)
|
||||
)
|
||||
elif name == "TLRPC$TL_channels_getMessages":
|
||||
picked = history.by_ids(int(m.id) for m in java_list(request.id))
|
||||
else:
|
||||
picked = []
|
||||
response = history.response(picked)
|
||||
if delegate is not None:
|
||||
delegate.run(response, None)
|
||||
elif timestamped is not None:
|
||||
timestamped.run(response, None, 0)
|
||||
|
||||
in_background(deliver)
|
||||
|
||||
def before_avatar(self, param):
|
||||
"""The latest logged photo of a removed chat as its avatar."""
|
||||
from org.telegram.tgnet import TLRPC
|
||||
|
||||
target = param.args[0]
|
||||
if target is None or not self._avatar_paths:
|
||||
return
|
||||
if isinstance(target, TLRPC.User):
|
||||
dialog_id = int(target.id)
|
||||
elif isinstance(target, TLRPC.Chat):
|
||||
dialog_id = -int(target.id)
|
||||
else:
|
||||
return
|
||||
receiver = param.thisObject
|
||||
account = int(field(receiver, "currentAccount") or 0)
|
||||
path = self._avatar_paths.get((account, dialog_id))
|
||||
if path is None or not self.is_injected(account, dialog_id):
|
||||
return
|
||||
from java import jint
|
||||
from org.telegram.messenger import ImageLocation
|
||||
|
||||
receiver.setImage(
|
||||
ImageLocation.getForPath(path), "50_50", param.args[1], None, target, jint(0)
|
||||
)
|
||||
param.setResult(None)
|
||||
Reference in new issue
Block a user