Message Logger: show logged edits and deletions from telegram-panel [no bump]
Publish plugin / publish (push) Successful in 4s

Keeps deleted messages (75% opacity, red icon-pack trash icon), restores
logged messages deleted while away, reports deletions, and shows edit
history as a mock chat (Edit history in the message menu, Open topic).
Message links and history open as any signed-in account with access.
Per-master API tokens; chat state synced per checked range with an hour's
validity, cached in SQLite with a configurable lifetime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lyuksovannyyandClaude Opus 5.5 committed 2026-10-08 23:55:23 +02:00
1 parent 80f1d5e55c
commit 9b2f26a845
26 files changed
+2928 -112

No files matched your search

View File
Whitespace-only changes.
+501
View File
@@ -0,0 +1,501 @@
"""Per open chat: sync the loaded window with the server, confirm and report
messages that vanished, and put deleted messages back from the logger.
ChatActivity (12.10.6, probed): updateMessagesVisiblePart(boolean) runs on
every scroll/load; `messages` is the loaded ArrayList<MessageObject>, newest
first; `chatListView`, `chatAdapter`.
The server is asked about a chat only when needed (store.coverage keeps the
range of ids already checked, per chat, for COVERAGE_SECONDS):
- the chat wasn't checked within the hour: everything loaded, from the oldest
loaded message on, and the answer replaces the chat's cached marks;
- history older than the checked range was loaded: that older part only;
- an edit or delete event in the chat (or a deletion this client reported):
the check is expired, so the next look asks again.
Answers hold at most 100 marks and 100 missing ids, newest first; a capped
answer covers down to its oldest mark, the rest is asked for next.
Restored messages go through ChatActivity.processNewMessages, like messages
that just arrived: the app places them and keeps `messagesDict` and its
adapter consistent (inserting into `messages` directly broke later arrivals),
and deleting one removes it the usual way.
"""
import time
from base_plugin import MethodHook
import diag
import header
import peers
import store
import tg
from api import ApiError
from config import COVERAGE_SECONDS, REPORT_DELETIONS, RESTORE_MISSING
from jutil import field, hook_methods, in_background, java_class, java_list, on_ui
CHAT = "org.telegram.ui.ChatActivity"
_DEBOUNCE_MS = 700
# The server logs an edit asynchronously; sync after it had time to.
_EDIT_DELAY_MS = 4000
EDIT_UPDATES = ("TL_updateEditMessage", "TL_updateEditChannelMessage")
def _epoch(iso):
if not iso:
return 0
from datetime import datetime
try:
return int(datetime.fromisoformat(iso).timestamp())
except ValueError:
return 0
_MAX_ID = 2**31 - 1
def _end_reached(fragment, name):
"""ChatActivity.endReached / forwardEndReached: boolean[2], index 0 is
the chat itself (1 is the merged migrated chat)."""
flags = field(fragment, name)
try:
return bool(flags[0])
except Exception:
return False
_process = []
def _process_new_messages():
"""ChatActivity.processNewMessages(ArrayList, boolean) (private)."""
if not _process:
from java.lang import Boolean
method = java_class(CHAT).getDeclaredMethod(
"processNewMessages", java_class("java.util.ArrayList"), Boolean.TYPE
)
method.setAccessible(True)
_process.append(method)
return _process[0]
def _plan(coverage, low, high, now):
"""(low, high, fresh) to ask the server about, or None when the loaded
range [low, high] is already checked."""
if coverage is None or now - coverage[2] >= COVERAGE_SECONDS:
return low, high, True
covered_low, covered_high, _at = coverage
if low < covered_low:
return low, covered_low - 1, False # older history was loaded
if high > covered_high:
return covered_high + 1, high, False # newer messages were loaded
return None
def _cover(account, dialog_id, low, high, fresh):
"""Records [low, high] as checked: a fresh check starts the hour, a
partial one extends the range it belongs to."""
current = store.coverage(account, dialog_id)
if fresh or current is None:
store.set_coverage(account, dialog_id, low, high, time.time())
return
covered_low, covered_high, at = current
store.set_coverage(
account, dialog_id, min(covered_low, low), max(covered_high, high), at
)
def _loaded(fragment, message_id):
"""Whether the app itself has `message_id` in this chat (messagesDict[0])."""
try:
return field(fragment, "messagesDict")[0].get(int(message_id)) is not None
except Exception:
return False
def _android_peer_id(marked):
"""Telethon sender id -> Android peer id (channels are -id)."""
marked = int(marked)
if marked <= -peers.CHANNEL_OFFSET:
return -(-marked - peers.CHANNEL_OFFSET)
return marked
def _strip_rich_header(rich):
"""A rich copy is header paragraphs, a divider, then the body
(telegram-panel render.compose): drop everything up to the divider."""
blocks = rich.blocks
for index in range(min(blocks.size(), 12)):
if blocks.get(index).getClass().getSimpleName() == "pageBlockDivider":
for _ in range(index + 1):
blocks.remove(0)
return
def _rich_text(node):
"""Plain text of a TL_iv RichText tree (textPlain/textConcat/styles...)."""
if node is None:
return ""
value = getattr(node, "text", None)
if isinstance(value, str):
return value
texts = getattr(node, "texts", None)
if texts is not None and hasattr(texts, "size"):
return "".join(_rich_text(t) for t in java_list(texts))
return _rich_text(value)
def _rich_plain_text(rich):
"""The body as plain text when it is only text paragraphs, else None."""
blocks = java_list(rich.blocks)
if not blocks or any(
b.getClass().getSimpleName() != "pageBlockParagraph" for b in blocks
):
return None
return "\n".join(_rich_text(b.text) for b in blocks)
class ChatSync:
def __init__(self, plugin, api):
self.plugin = plugin
self.api = api
self.handles = []
self._scheduled = set()
self._inflight = set() # (account, dialog) with a sync running
self._exists = {} # (account, dialog) -> ids Telegram still has
self._restoring = set() # (account, dialog, id) in flight
self._fragments = {} # (account, dialog) -> last ChatActivity seen
def install(self):
for name in EDIT_UPDATES:
self.plugin.add_hook(name)
sync = self
class AfterVisible(MethodHook):
def after_hooked_method(self, param):
try:
sync.schedule(param.thisObject)
except Exception as exc:
diag.error("chat.schedule", exc)
self.handles += hook_methods(
self.plugin, CHAT, "updateMessagesVisiblePart", AfterVisible()
)
def uninstall(self):
for name in EDIT_UPDATES:
try:
self.plugin.remove_hook(name)
except Exception:
pass
for handle in self.handles:
try:
self.plugin.unhook_method(handle)
except Exception:
pass
self.handles = []
# collecting the window (UI thread)
def schedule(self, fragment):
if not self.api.configured:
return
guid = int(fragment.getClassGuid())
if guid in self._scheduled:
return
self._scheduled.add(guid)
on_ui(lambda: self._collect(fragment, guid), _DEBOUNCE_MS)
def _collect(self, fragment, guid):
self._scheduled.discard(guid)
self.plugin.purge_cache() # once a day
account = int(fragment.getCurrentAccount())
dialog_id = int(fragment.getDialogId())
chat_id = peers.marked_chat_id(account, dialog_id)
if chat_id is None:
return
key = (account, dialog_id)
self._fragments[key] = fragment
if key in self._inflight:
return
ids = [
int(m.getId())
for m in java_list(field(fragment, "messages"))
if m is not None and int(m.getId()) > 0
]
if not ids:
return
# The oldest loaded message bounds every request; an end of the chat
# that is loaded is open (deleted past it still belongs to it).
low = 1 if _end_reached(fragment, "endReached") else min(ids)
high = _MAX_ID if _end_reached(fragment, "forwardEndReached") else max(ids)
request = _plan(store.coverage(account, dialog_id), low, high, time.time())
if request is None:
# Checked within the hour: deleted messages come back from the
# cached answers (they were never in the app's own database).
if self.plugin.get_setting(RESTORE_MISSING, True):
present = set(ids)
gone = [
s
for s in store.cached_deleted(account, dialog_id, low, high)
if 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
present = {i for i in ids if request[0] <= i <= request[1]}
self._inflight.add(key)
in_background(
lambda: self._sync(fragment, account, dialog_id, chat_id, request, present)
)
# server round trip (background)
def _sync(self, fragment, account, dialog_id, chat_id, request, present):
key = (account, dialog_id)
try:
self._sync_range(fragment, account, dialog_id, chat_id, request, present)
finally:
self._inflight.discard(key)
def _sync_range(self, fragment, account, dialog_id, chat_id, request, present):
low, high, fresh = request
me = peers.own_user_id(account)
started = time.monotonic()
try:
result = self.api.sync(me, chat_id, low, high, sorted(present))
except ApiError as exc:
diag.log(f"sync {chat_id}: {exc}")
return
if not result:
return
states = result.get("states") or []
capped = len(states) >= 100
diag.log(
f"sync {chat_id} [{low}..{high}]{' fresh' if fresh else ''} "
f"loaded={len(present)} marks={len(states)} "
f"missing={len(result.get('missing') or [])} "
f"{int((time.monotonic() - started) * 1000)}ms"
)
dropped = store.replace_window(
account, dialog_id, low, high, states, capped, full=fresh
)
# A capped answer is complete from its oldest mark up only.
covered_low = max(low, min(int(s["message_id"]) for s in states)) if capped else low
_cover(account, dialog_id, covered_low, high, fresh)
changed = {int(s["message_id"]) for s in states} | set(dropped)
if changed:
on_ui(lambda: self.refresh(fragment, changed))
known = self._exists.setdefault((account, dialog_id), set())
missing = [int(i) for i in result.get("missing") or [] if int(i) not in known]
if missing:
self._confirm(fragment, account, dialog_id, chat_id, me, missing[:100])
if self.plugin.get_setting(RESTORE_MISSING, True):
gone = [
s
for s in states
if s.get("deleted_at") and int(s["message_id"]) not in present
]
if gone:
self._restore(fragment, account, dialog_id, chat_id, me, gone)
def _confirm(self, fragment, account, dialog_id, chat_id, me, ids):
"""Ask Telegram about ids the server has and the client lacks; those
it no longer has were deleted while we were away."""
from org.telegram.tgnet import TLRPC
def done(found, error):
if found is None:
diag.log(f"confirm {chat_id}: {error}")
return
gone = [
i
for i in ids
if found.get(i) is None or isinstance(found[i], TLRPC.TL_messageEmpty)
]
self._exists.setdefault((account, dialog_id), set()).update(
set(ids) - set(gone)
)
if not gone:
return
diag.log(f"confirmed deleted in {chat_id}: {gone}")
store.mark_deleted(account, dialog_id, gone)
if self.plugin.get_setting(REPORT_DELETIONS, True):
in_background(lambda: self._report(account, dialog_id, chat_id, me, gone))
tg.get_messages(account, dialog_id, ids, done)
def _report(self, account, dialog_id, chat_id, me, ids):
try:
marked = self.api.deletions(me, chat_id, ids)
diag.log(f"reported {len(ids)} deletions in {chat_id}, marked {marked}")
except ApiError as exc:
diag.log(f"report {chat_id}: {exc}")
return
# Sync again for the server's new state (and the logged copies).
self.changed(account, dialog_id)
# restoring (background -> UI)
def _restore(self, fragment, account, dialog_id, chat_id, me, states):
for state in states:
message_id = int(state["message_id"])
key = (account, dialog_id, message_id)
if key in self._restoring:
continue
pointer, strip = self._source(me, chat_id, message_id, state)
if pointer is None:
continue
self._restoring.add(key)
self._fetch_copy(fragment, account, dialog_id, message_id, state, pointer, strip)
def _source(self, me, chat_id, message_id, state):
"""Where to read the deleted message from: its last version (no
header) when it was edited, else the saved copy (header stripped)."""
if int(state.get("edit_count") or 0) > 0:
try:
versions = self.api.versions(me, chat_id, message_id)
except ApiError as exc:
diag.log(f"versions {chat_id}/{message_id}: {exc}")
versions = None
if versions and versions.get("chat_id") and versions.get("versions"):
last = versions["versions"][-1]
return {"chat_id": versions["chat_id"], "message_id": last["message_id"]}, False
return state.get("logged"), True
def _fetch_copy(self, fragment, account, dialog_id, message_id, state, pointer, strip):
channel_id = peers.channel_id_from_marked(pointer["chat_id"])
copy_id = int(pointer["message_id"])
def done(found, error):
copy = (found or {}).get(copy_id)
if copy is None or getattr(copy, "message", None) is None and getattr(copy, "media", None) is None:
self._restoring.discard((account, dialog_id, message_id))
diag.log(f"copy {pointer} unavailable: {error}")
return
on_ui(lambda: self._insert(fragment, account, dialog_id, message_id, state, copy, strip))
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 is not None:
# 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
own dicts and adapter consistent."""
self._restoring.discard((account, dialog_id, message_id))
messages = field(fragment, "messages")
if messages is None:
return
if _loaded(fragment, message_id) or any(
m is not None and int(m.getId()) == message_id for m in java_list(messages)
):
return
from java import jboolean
from java.util import ArrayList
obj = self._build(account, dialog_id, message_id, state, copy, strip)
store.mark_deleted(account, dialog_id, [message_id])
batch = ArrayList()
batch.add(obj)
_process_new_messages().invoke(fragment, batch, jboolean(False))
diag.log(f"restored {dialog_id}/{message_id}")
# cells (UI thread)
def refresh(self, fragment, ids):
"""Rebind the rows of `ids`: their alpha or pill (and so their height)
changed, which only a relayout of the list places correctly."""
messages = field(fragment, "messages")
adapter = field(fragment, "chatAdapter")
if messages is None or adapter is None:
return
# A rebind alone reuses the cached measure: flag the cells for layout.
list_view = field(fragment, "chatListView")
if list_view is not None:
for i in range(list_view.getChildCount()):
child = list_view.getChildAt(i)
message = child.getMessageObject() if hasattr(child, "getMessageObject") else None
if message is not None and int(message.getId()) in ids:
child.forceLayout()
start = int(adapter.messagesStartRow)
for index, m in enumerate(java_list(messages)):
if m is not None and int(m.getId()) in ids:
m.forceUpdate = True # rebinding lays the cell out again (time row)
adapter.notifyItemChanged(start + index)
def on_edited(self, account, message):
"""An edit arrived: sync that chat again once the server logged it."""
from org.telegram.messenger import MessageObject
self.changed(
int(account), int(MessageObject.getDialogId(message)), _EDIT_DELAY_MS
)
def changed(self, account, dialog_id, delay_ms=0):
"""An edit/delete event in a chat: its check is expired, and the chat
synced again (after `delay_ms`) when it is open."""
store.expire_coverage(account, dialog_id)
fragment = self._fragments.get((account, dialog_id))
if fragment is not None:
on_ui(lambda: self.schedule(fragment), delay_ms)
def refresh_dialog(self, account, dialog_id, ids):
fragment = self._fragments.get((account, dialog_id))
if fragment is not None:
on_ui(lambda: self.refresh(fragment, set(int(i) for i in ids)))
+145
View File
@@ -0,0 +1,145 @@
"""Keeps messages others delete: the delete updates are dropped (the message
stays in the app's cache and chat) and the ids are remembered as deleted.
Deletions the user makes here go through MessagesController.deleteMessages
(both overloads start `(messages, randoms, encryptedChat, dialogId, ...)`);
those ids are let through when their echo update arrives.
TL_updateDeleteMessages (private chats, basic groups) carries no chat: ids
are per account, so the dialog is looked up in the app's own database on the
storage queue.
"""
from base_plugin import HookResult, HookStrategy, MethodHook
import diag
import peers
import store
from api import ApiError
from config import KEEP_DELETED, REPORT_DELETIONS
from jutil import field, hook_methods, in_background, java_ints
UPDATES = ("TL_updateDeleteMessages", "TL_updateDeleteChannelMessages")
class Deletes:
def __init__(self, plugin, api, chats):
self.plugin = plugin
self.api = api
self.chats = chats
self.handles = []
def install(self):
for name in UPDATES:
self.plugin.add_hook(name)
deletes = self
class BeforeDelete(MethodHook):
def before_hooked_method(self, param):
try:
deletes.on_own_delete(param)
except Exception as exc:
diag.error("deletes.own", exc)
self.handles += hook_methods(
self.plugin,
"org.telegram.messenger.MessagesController",
"deleteMessages",
BeforeDelete(),
)
def uninstall(self):
for name in UPDATES:
try:
self.plugin.remove_hook(name)
except Exception:
pass
for handle in self.handles:
try:
self.plugin.unhook_method(handle)
except Exception:
pass
self.handles = []
def on_own_delete(self, param):
args = list(param.args or [])
if len(args) < 4:
return
ids = [i for i in java_ints(args[0]) if i > 0]
if not ids:
return
account = int(field(param.thisObject, "currentAccount") or 0)
dialog_id = int(args[3] or 0)
store.note_own_deletion(account, ids)
store.forget_deleted(account, dialog_id, ids)
def on_update(self, name, account, update):
ids = java_ints(update.messages)
others = [i for i in ids if not store.is_own_deletion(account, i)]
if not others:
return HookResult()
if name == "TL_updateDeleteChannelMessages":
self._deleted(account, -int(update.channel_id), others)
else:
self._resolve_dialogs(account, others)
if not self.plugin.get_setting(KEEP_DELETED, True):
return HookResult()
diag.log(f"kept {name} {others}")
if len(others) == len(ids):
return HookResult(strategy=HookStrategy.CANCEL)
from java import jint
from java.util import ArrayList
own = ArrayList()
for i in ids:
if i not in others:
own.add(jint(i)) # a plain int would be boxed as Long
update.messages = own
return HookResult(strategy=HookStrategy.MODIFY, update=update)
def _deleted(self, account, dialog_id, ids):
store.mark_deleted(account, dialog_id, ids)
self.chats.refresh_dialog(account, dialog_id, ids)
# Check the chat again once the server had the report.
self.chats.changed(account, dialog_id, 3000)
if self.plugin.get_setting(REPORT_DELETIONS, True) and self.api.configured:
in_background(lambda: self._report(account, dialog_id, ids))
def _report(self, account, dialog_id, ids):
chat_id = peers.marked_chat_id(account, dialog_id)
if chat_id is None:
return
try:
self.api.deletions(peers.own_user_id(account), chat_id, ids)
except ApiError as exc:
diag.log(f"report {chat_id}: {exc}")
def _resolve_dialogs(self, account, ids):
"""Dialogs of per-account message ids, from the app's database."""
from java import dynamic_proxy
from java.lang import Runnable
from org.telegram.messenger import MessagesStorage
storage = MessagesStorage.getInstance(account)
deletes = self
class Lookup(dynamic_proxy(Runnable)):
def run(self):
try:
found = {}
query = "SELECT mid, uid FROM messages_v2 WHERE mid IN (%s)" % (
",".join(str(int(i)) for i in ids)
)
cursor = storage.getDatabase().queryFinalized(query)
while cursor.next():
uid = int(cursor.longValue(1))
if uid < 0 and peers.is_channel_dialog(account, uid):
continue # channel ids are numbered per channel
found.setdefault(uid, []).append(int(cursor.intValue(0)))
cursor.dispose()
for dialog_id, mids in found.items():
deletes._deleted(account, dialog_id, mids)
except Exception as exc:
diag.error("deletes.resolve", exc)
storage.getStorageQueue().postRunnable(Lookup())
+112
View File
@@ -0,0 +1,112 @@
"""Message links into chats the current account isn't in (the logger's
"Message" links, mostly) open as a signed-in account that is (see
views/open_as.py).
Browser.openUrl (all overloads, Uri or String) is where tapped links go.
"""
import re
from base_plugin import MethodHook
import accounts
import diag
from jutil import hook_methods, on_ui
from views import open_as
BROWSER = "org.telegram.messenger.browser.Browser"
_PRIVATE = re.compile(
r"^(?:https?://)?(?:www\.)?(?:t|telegram)\.me/c/(\d+)/(?:(\d+)/)?(\d+)", re.I
)
_PUBLIC = re.compile(
r"^(?:https?://)?(?:www\.)?(?:t|telegram)\.me/([A-Za-z0-9_]{4,32})/(\d+)(?:[/?#]|$)",
re.I,
)
_TG_PRIVATE = re.compile(r"^tg://privatepost\?(.*)$", re.I)
_TG_RESOLVE = re.compile(r"^tg://resolve\?(.*)$", re.I)
def _query(text):
out = {}
for part in text.split("&"):
key, _, value = part.partition("=")
out[key] = value
return out
def parse(url):
"""(dialog id, message id) of a message link, else None."""
url = url.strip()
match = _PRIVATE.match(url)
if match:
return -int(match.group(1)), int(match.group(3))
match = _TG_PRIVATE.match(url)
if match:
q = _query(match.group(1))
if q.get("channel", "").isdigit() and q.get("post", "").isdigit():
return -int(q["channel"]), int(q["post"])
return None
username, post = None, None
match = _PUBLIC.match(url)
if match:
username, post = match.group(1), match.group(2)
else:
match = _TG_RESOLVE.match(url)
if match:
q = _query(match.group(1))
username, post = q.get("domain"), q.get("post")
if not username or not post or not post.isdigit():
return None
dialog_id = accounts.resolve_username(username)
return (dialog_id, int(post)) if dialog_id is not None else None
class Links:
def __init__(self, plugin):
self.plugin = plugin
self.handles = []
def install(self):
links = self
class BeforeOpen(MethodHook):
def before_hooked_method(self, param):
try:
if links.open(param.args[1]):
param.setResult(None)
except Exception as exc:
diag.error("links.open", exc)
self.handles += hook_methods(self.plugin, BROWSER, "openUrl", BeforeOpen())
def uninstall(self):
for handle in self.handles:
try:
self.plugin.unhook_method(handle)
except Exception:
pass
self.handles = []
def open(self, uri):
"""Opens the link as another account when needed; True if handled."""
if uri is None:
return False
target = parse(str(uri))
if target is None:
return False
dialog_id, message_id = target
from client_utils import get_last_fragment
fragment = get_last_fragment()
if fragment is None:
return False
current = int(fragment.getCurrentAccount())
if accounts.has_access(current, dialog_id):
return False
others = [s for s in accounts.with_access(dialog_id) if s != current]
if not others:
return False
slot = others[0]
on_ui(lambda: open_as.open_chat(fragment, slot, dialog_id, message_id))
return True
+129
View File
@@ -0,0 +1,129 @@
""""Edit history" 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
the row is added to the built popup instead: after ChatActivity.createMenu,
an ActionBarMenuSubItem styled like the Forward row (found by its localized
text) is inserted above it, or first in the list when Forward is an icon of
the bottom action bar (groups). ChatActivity.selectedObject is the message.
"""
from base_plugin import MethodHook
import diag
import store
from jutil import field, hook_methods, java_class
CHAT = "org.telegram.ui.ChatActivity"
_LABEL = "Edit history"
def _worth_it(message):
"""Messages that may have a logged history: edited ones (the app's own
flag or the server's state), which logger posts are once their header
was refreshed."""
if message is None or int(message.getId()) <= 0:
return False
if int(getattr(message.messageOwner, "edit_date", 0) or 0):
return True
return store.is_edited(
int(message.currentAccount), int(message.getDialogId()), int(message.getId())
)
def _first_item(view):
"""The first ActionBarMenuSubItem under `view` (the main page comes
before the swipe-back pages), else None."""
if view.getClass().getSimpleName() == "ActionBarMenuSubItem":
return view
if hasattr(view, "getChildCount"):
for index in range(view.getChildCount()):
found = _first_item(view.getChildAt(index))
if found is not None:
return found
return None
def _find_item(view, text):
"""The ActionBarMenuSubItem showing `text` under `view`, else None."""
if view.getClass().getSimpleName() == "ActionBarMenuSubItem":
label = view.getTextView()
return view if label is not None and str(label.getText()) == text else None
if hasattr(view, "getChildCount"):
for index in range(view.getChildCount()):
found = _find_item(view.getChildAt(index), text)
if found is not None:
return found
return None
class Menu:
def __init__(self, plugin, open_history):
self.plugin = plugin
self.open_history = open_history
self.handles = []
self._close = None
def install(self):
menu = self
class AfterCreate(MethodHook):
def after_hooked_method(self, param):
try:
menu.add_item(param.thisObject)
except Exception as exc:
diag.error("menu.add", exc)
self.handles += hook_methods(self.plugin, CHAT, "createMenu", AfterCreate())
def uninstall(self):
for handle in self.handles:
try:
self.plugin.unhook_method(handle)
except Exception:
pass
self.handles = []
def add_item(self, chat):
message = field(chat, "selectedObject")
popup = field(chat, "scrimPopupWindow")
if popup is None or not _worth_it(message):
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
content = popup.getContentView()
# 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.
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")
return
forward = anchor
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()
item.setColors(color, color)
item.setMinimumWidth(forward.getMinimumWidth())
def chosen(_view):
self._close_menu(chat)
self.open_history(message)
item.setOnClickListener(OnClickListener(chosen))
parent.addView(item, parent.indexOfChild(forward), forward.getLayoutParams())
def _close_menu(self, chat):
if self._close is None:
method = java_class(CHAT).getDeclaredMethod("closeMenu")
method.setAccessible(True)
self._close = method
self._close.invoke(chat)
+176
View File
@@ -0,0 +1,176 @@
"""Chat cell decoration of deleted messages: translucent, with a red trash icon
(from the active icon pack) in the time row like the app's own "edited" pencil.
ChatMessageCell (exteraGram 12.10.6, probed on device): setMessageObject
(5 and 6 args), setAlpha(float), measureTime(MessageObject), and the private
`currentTimeString` (SpannableStringBuilder), `timeTextWidth`, `timeWidth`,
`timeLayout`. The pencil is a ColoredImageSpan on a leading "\\u200d " whose
width (Layout.getDesiredWidth with Theme.chat_timePaint) the time widths
include; the trash icon is added the same way, before the bubble is laid out.
"""
from base_plugin import MethodHook
import diag
import store
from config import ALPHA_CHOICES, DELETED_ALPHA
from jutil import field, hook_methods
CELL = "org.telegram.ui.Cells.ChatMessageCell"
_ICON = "\u200d "
def _dp(value):
from org.telegram.messenger import AndroidUtilities
return AndroidUtilities.dp(value)
def _message(cell):
try:
return cell.getMessageObject()
except Exception:
return None
def _key(message):
"""(account, dialog_id, message_id) of a real message, else None."""
if message is None:
return None
message_id = int(message.getId())
if message_id <= 0:
return None
return int(message.currentAccount), int(message.getDialogId()), message_id
def _deleted(message):
key = _key(message)
return key is not None and store.is_deleted(*key)
def _trash_drawable(res_id):
"""The trash icon of the active icon pack: the activity's resources are
exteraGram's ExteraResources, which apply it (the application context's
plain Resources give the stock icon)."""
from client_utils import get_last_fragment
fragment = get_last_fragment()
activity = fragment.getParentActivity() if fragment is not None else None
if activity is not None:
resources = activity.getResources()
else:
from org.telegram.messenger import ApplicationLoader
resources = ApplicationLoader.applicationContext.getResources()
return resources.getDrawable(res_id, None).mutate()
def _set_int(cell, name, value):
from hook_utils import set_private_field
from java import jint
set_private_field(cell, name, jint(int(value)))
class Render:
def __init__(self, plugin):
self.plugin = plugin
self.handles = []
def _alpha(self):
index = int(self.plugin.get_setting(DELETED_ALPHA, 0) or 0)
return ALPHA_CHOICES[max(0, min(index, len(ALPHA_CHOICES) - 1))][1]
def install(self):
plugin = self.plugin
render = self
class AfterBind(MethodHook):
def after_hooked_method(self, param):
try:
render.apply_alpha(param.thisObject)
except Exception as exc:
diag.error("render.bind", exc)
class BeforeAlpha(MethodHook):
def before_hooked_method(self, param):
try:
render.scale_alpha(param)
except Exception as exc:
diag.error("render.alpha", exc)
class AfterMeasureTime(MethodHook):
def after_hooked_method(self, param):
try:
render.add_trash(param.thisObject, param.args[0])
except Exception as exc:
diag.error("render.time", exc)
self.handles += hook_methods(plugin, CELL, "setMessageObject", AfterBind())
self.handles += hook_methods(plugin, CELL, "setAlpha", BeforeAlpha())
self.handles += hook_methods(plugin, CELL, "measureTime", AfterMeasureTime())
def uninstall(self):
for handle in self.handles:
try:
self.plugin.unhook_method(handle)
except Exception:
pass
self.handles = []
# opacity
def apply_alpha(self, cell):
"""Re-applies the cell's alpha through setAlpha (scaled below)."""
cell.setAlpha(1.0)
def scale_alpha(self, param):
"""Deleted messages stay translucent whatever alpha the list's item
animators set: every setAlpha on their cell is scaled."""
if not _deleted(_message(param.thisObject)):
return
from java import jfloat
param.args[0] = jfloat(float(param.args[0]) * self._alpha())
# trash icon
def add_trash(self, cell, message):
if not _deleted(message):
return
# measureTime builds the string afresh: the icon goes in once, before
# the edited pencil if there is one.
text = field(cell, "currentTimeString")
if text is None:
return
from android.text import Layout, Spanned, StaticLayout
from org.telegram.messenger import R
from org.telegram.ui.ActionBar import Theme
from org.telegram.ui.Components import ColoredImageSpan
paint = Theme.chat_timePaint
before = Layout.getDesiredWidth(text, paint)
from java import jint
span = ColoredImageSpan(_trash_drawable(R.drawable.msg_delete))
span.setSize(_dp(13))
span.setTranslateX(-3.0)
span.setOverrideColor(jint(Theme.getColor(Theme.key_text_RedBold)))
text.insert(0, _ICON)
text.setSpan(span, 0, 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
added = int(Layout.getDesiredWidth(text, paint) - before + 0.999)
_set_int(cell, "timeTextWidth", int(field(cell, "timeTextWidth") or 0) + added)
_set_int(cell, "timeWidth", int(field(cell, "timeWidth") or 0) + added)
if field(cell, "timeLayout") is not None:
from hook_utils import set_private_field
layout = StaticLayout(
text,
paint,
int(field(cell, "timeTextWidth")) + _dp(100),
Layout.Alignment.ALIGN_NORMAL,
1.0,
0.0,
False,
)
set_private_field(cell, "timeLayout", layout)