generated from exteraGramPlugins/pluginBase
Message Logger: show logged edits and deletions from telegram-panel [no bump]
Publish plugin / publish (push) Successful in 4s
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:
1 parent
80f1d5e55c
commit
9b2f26a845
26 files changed
+2928
-112
No files matched your search
Whitespace-only changes.
@@ -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)))
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user