Message Logger: hidden messages on the server, removed chats, old chat photos
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:
lyuksovannyyandClaude Opus 5.5 committed 2026-10-10 23:12:33 +02:00
1 parent 0176f7099d
commit 362b358d8d
15 files changed
+1821 -125

No files matched your search

+356
View File
@@ -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
View File
@@ -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
View File
@@ -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):
+202
View File
@@ -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
View File
@@ -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:
+505
View File
@@ -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)