diff --git a/README.md b/README.md index a914ecf..1927621 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,9 @@ Shows what a [telegram-panel](../../telegram-panel) message logger saved, native for the history topic in the activity group. Versions and their media are read from the activity group through a signed-in account that can (the current one, the master, or any member); otherwise the server's text is shown and media are marked unavailable; +- the comments button of a deleted channel post (whose thread Telegram deletes with it) opens + the app's own comments section for it — avatars, names, replies, media — filled from the + logged comments (the thread's history request is answered locally); - message links into a chat the current account isn't in (e.g. a log post's "Message" link) open that chat as a signed-in account that is in it; going back returns to your account. Every signed-in account's chats are cached client-side for this. diff --git a/plugin.json b/plugin.json index f8b30c5..7b413f5 100644 --- a/plugin.json +++ b/plugin.json @@ -3,9 +3,11 @@ "name": "Message Logger", "description": "Shows deleted and edited messages and their edit history from your telegram-panel message logger.", "author": "@lyuksovannyy", - "version": "0.1.0", + "version": "0.1.1", "icon": "exteraPlugins/1", "app_version": ">=12.5.1", "sdk_version": ">=1.4.4.3", - "requirements": ["requests"] + "requirements": [ + "requests" + ] } diff --git a/src/api.py b/src/api.py index 42aa046..d21e704 100644 --- a/src/api.py +++ b/src/api.py @@ -54,6 +54,12 @@ class Api: except requests.RequestException as exc: raise ApiError(f"Server unreachable: {exc}") from exc if response.status_code == 404: + try: + detail = response.json().get("detail") + except Exception: + detail = None + if detail == "Not Found": # the route itself: an older server + raise ApiError("the server doesn't support this yet (update telegram-panel)") return None if response.status_code >= 400: try: @@ -127,6 +133,13 @@ class Api: params={"account_id": account_id}, ) + def comments(self, account_id, chat_id, message_id): + return self._first( + "GET", + f"/messages/{chat_id}/{message_id}/comments", + params={"account_id": account_id}, + ) + def deletions(self, account_id, chat_id, message_ids): ids = sorted(set(int(i) for i in message_ids)) marked = 0 diff --git a/src/hooks/comments.py b/src/hooks/comments.py new file mode 100644 index 0000000..2b2fe9e --- /dev/null +++ b/src/hooks/comments.py @@ -0,0 +1,121 @@ +"""The comments button of a deleted channel post opens its logged comments +(views/comments.py) instead of the app's discussion, which Telegram deleted +with the post ("message doesn't exist"). + +ChatActivity$ChatMessageCellDelegate.didPressCommentButton(ChatMessageCell) +handles the button; an album's comments hang on any of its messages. +ConnectionsManager.sendRequestInternal (where every sendRequest overload +ends) is where the opened thread's getReplies is answered locally, and +ChatActivity.updateBottomOverlay where its input bar is kept hidden. +""" + +from base_plugin import MethodHook + +import diag +import store +from jutil import field, hook_methods, in_background, java_list +from views import comments as comments_view + +DELEGATE = "org.telegram.ui.ChatActivity$ChatMessageCellDelegate" +CONNECTIONS = "org.telegram.tgnet.ConnectionsManager" + + +def _deleted_post(cell): + """The deleted message of the cell (or its album), else None.""" + message = cell.getMessageObject() + if message is None: + return None + candidates = [message] + group = cell.getCurrentMessagesGroup() + if group is not None: + candidates += java_list(group.messages) + for candidate in candidates: + if candidate is not None and int(candidate.getId()) > 0 and store.is_deleted( + int(candidate.currentAccount), + int(candidate.getDialogId()), + int(candidate.getId()), + ): + return candidate + return None + + +class CommentsButton: + def __init__(self, plugin, open_comments): + self.plugin = plugin + self.open_comments = open_comments + self.handles = [] + + def install(self): + button = self + + class BeforePress(MethodHook): + def before_hooked_method(self, param): + try: + post = _deleted_post(param.args[0]) + if post is not None: + param.setResult(None) + button.open_comments(post) + except Exception as exc: + diag.error("comments.press", exc) + + self.handles += hook_methods( + self.plugin, DELEGATE, "didPressCommentButton", BeforePress() + ) + + class BeforeSend(MethodHook): + def before_hooked_method(self, param): + try: + button.answer_locally(param) + except Exception as exc: + diag.error("comments.request", exc) + + self.handles += hook_methods( + self.plugin, CONNECTIONS, "sendRequestInternal", BeforeSend() + ) + + class AfterOverlay(MethodHook): + def after_hooked_method(self, param): + try: + chat = param.thisObject + if int(chat.getClassGuid()) in comments_view.read_only: + comments_view.make_read_only(chat) + except Exception as exc: + diag.error("comments.overlay", exc) + + self.handles += hook_methods( + self.plugin, "org.telegram.ui.ChatActivity", "updateBottomOverlay", AfterOverlay() + ) + + def answer_locally(self, param): + """A thread opened by views/comments.py asks for its replies: answer + from the logged comments instead of Telegram (the thread is gone).""" + request = param.args[0] + if request is None or type(request).__name__ != "TLRPC$TL_messages_getReplies": + return + account = int(field(param.thisObject, "currentAccount") or 0) + response = comments_view.answer(account, request) + if response is None: + return + 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)) + + def deliver(): + if delegate is not None: + delegate.run(response, None) + elif timestamped is not None: + timestamped.run(response, None, 0) + + in_background(deliver) + + def uninstall(self): + for handle in self.handles: + try: + self.plugin.unhook_method(handle) + except Exception: + pass + self.handles = [] diff --git a/src/main.py b/src/main.py index 806ae09..f897222 100644 --- a/src/main.py +++ b/src/main.py @@ -17,11 +17,13 @@ from config import ( SERVER_URL, ) from hooks.chat import EDIT_UPDATES, ChatSync +from hooks.comments import CommentsButton from hooks.deletes import UPDATES, Deletes from hooks.links import Links from hooks.menu import Menu from hooks.render import Render from jutil import in_background, on_ui +from views.comments import Comments from views.history import History @@ -43,7 +45,9 @@ class MessageLoggerPlugin(BasePlugin): self.deletes = Deletes(self, self.api, self.chats) self.links = Links(self) self.menu = Menu(self, self.history.open) - for part in (self.render, self.chats, self.deletes, self.links, self.menu): + self.comments = CommentsButton(self, Comments(self, self.api).open) + parts = (self.render, self.chats, self.deletes, self.links, self.menu, self.comments) + for part in parts: try: part.install() except Exception as exc: @@ -62,7 +66,7 @@ class MessageLoggerPlugin(BasePlugin): store.purge_old(CACHE_DAYS_CHOICES[index][1], force=force) def on_plugin_unload(self): - for part in ("render", "chats", "deletes", "links", "menu"): + for part in ("render", "chats", "deletes", "links", "menu", "comments"): if hasattr(self, part): getattr(self, part).uninstall() diag.log("unloaded") diff --git a/src/views/comments.py b/src/views/comments.py new file mode 100644 index 0000000..458761c --- /dev/null +++ b/src/views/comments.py @@ -0,0 +1,323 @@ +"""Comments of a deleted channel post, shown as the app's own comments +section: the discussion group's ChatActivity in thread mode, with names, +avatars, replies and media as for a post that still exists. + +Telegram deletes a post's discussion thread with it (the comments come back +empty, getDiscussionMessage fails with MSG_ID_INVALID), so the comments come +from the logger: copies in the activity group's comments topic (each starting +with a header, stripped here), read through an account that can (else the +server's text, media marked unavailable). + +The thread is opened like ChatActivity.openDiscussionMessageChat does +(setThreadMessages with the post as its start message); the history request +it then sends (messages.getReplies of the thread) is answered locally from +the logged comments (`answer`, hooked in ConnectionsManager by +hooks/comments.py), so the app builds the section itself. +""" + +import threading + +import accounts +import diag +import header +import peers +import tg +from api import ApiError +from jutil import in_background, java_list, on_ui +from views.history import History, _bulletin, _epoch + +# thread id -> (account, discussion channel id, [TL messages], [users], [chats]) +_threads = {} +# classGuids of the comment screens opened here: read-only (no input bar) +read_only = set() +_lock = threading.Lock() + + +def make_read_only(chat): + """Swaps a comment screen's input bar for a read-only notice (the app's + own bottom overlay, as in chats one can't write to). UI thread.""" + from android.view import View + + from jutil import field + + enter = field(chat, "chatActivityEnterView") + if enter is not None: + enter.setVisibility(View.GONE) + overlay = field(chat, "bottomOverlay") + text = field(chat, "bottomOverlayText") + if overlay is None or text is None: + return + for name in ("bottomOverlayChatText", "bottomOverlayStartButton", "bottomOverlayProgress"): + other = field(chat, name) + if other is not None: + other.setVisibility(View.GONE) + # The app animates the overlay out when the thread has an input bar: + # stop that, or it ends invisible (alpha 0) after this. + animation = field(chat, "bottomOverlayAnimation") + if animation is not None: + animation.cancel() + text.setText("Viewing deleted comments") + text.setVisibility(View.VISIBLE) + overlay.setAlpha(1.0) + overlay.setVisibility(View.VISIBLE) + + +def _peer(controller, marked): + """TLRPC peer of a Telethon sender id.""" + from hooks.chat import _android_peer_id + + return controller.getPeer(_android_peer_id(int(marked))) + + +def _sender_object(controller, marked, name): + """The sender's User/Chat from the account's cache, else a stand-in with + the logged name (avatar drawn from its initials).""" + from hooks.chat import _android_peer_id + from org.telegram.tgnet import TLRPC + + peer_id = _android_peer_id(int(marked)) + if peer_id > 0: + user = controller.getUser(peer_id) + if user is None: + user = TLRPC.TL_user() + user.id = peer_id + user.first_name = name or "Deleted" + user.flags |= 2 + return user, None + chat = controller.getChat(-peer_id) + if chat is None: + chat = TLRPC.TL_channel() + chat.id = -peer_id + chat.title = name or "Channel" + chat.broadcast = True + return None, chat + + +class Comments(History): + def open(self, message): + """Shows the logged comments of a channel post (MessageObject).""" + if message is None: + return + if not self.api.configured: + _bulletin("Message Logger: set the server URL and API token first", True) + return + account = int(message.currentAccount) + chat_id = peers.marked_chat_id(account, int(message.getDialogId())) + replies = message.messageOwner.replies + if chat_id is None or int(message.getId()) <= 0 or replies is None: + _bulletin("No comments logged for this post") + return + discussion = int(replies.channel_id) + in_background(lambda: self._load(message, account, chat_id, discussion)) + + def _load(self, original, account, chat_id, discussion): + try: + result = self.api.comments( + peers.own_user_id(account), chat_id, int(original.getId()) + ) + except ApiError as exc: + _bulletin(f"Message Logger: {exc}", True) + return + items = (result or {}).get("comments") or [] + if not items: + _bulletin("The post was deleted; no comments were logged for it") + return + ids = [int(c["message_id"]) for c in items if c.get("message_id")] + reader = None + if result.get("chat_id") and ids: + channel_id = peers.channel_id_from_marked(result["chat_id"]) + readers = accounts.with_access( + -channel_id, + prefer=(account, accounts.slot_of(result.get("master_tg_id"))), + ) + reader = readers[0] if readers else None + if reader is None: + on_ui(lambda: self._open(original, items, None, {}, discussion)) + return + + def done(found, error): + if found is None: + diag.log(f"comments: activity group unreadable: {error}") + found = {} + on_ui(lambda: self._open(original, items, reader, found, discussion)) + + tg.get_messages(reader, -channel_id, ids, done) + + # the thread + + def _open(self, original, items, reader, found, discussion): + from java import jint + from org.telegram.messenger import MessageObject, MessagesController + from org.telegram.ui import ChatActivity + from android.os import Bundle + from client_utils import get_last_fragment + + from views import open_as + + # Drawn as the account that read the copies (their media and the + # senders it saw) when it knows the discussion group, else as the + # viewer. + viewer = int(original.currentAccount) + account = viewer + if reader is not None and MessagesController.getInstance(reader).getChat( + discussion + ) is not None: + account = reader + controller = MessagesController.getInstance(account) + group = controller.getChat(discussion) + if group is None: + _bulletin("The post's discussion group isn't known to this account", True) + return + sources = {int(c["source_message_id"]) for c in items} + # Comments answering the post itself reply to its (deleted) copy in + # the discussion group: that id is the thread's. + tops = [ + int(c["reply_to"]) + for c in items + if c.get("reply_to") and int(c["reply_to"]) not in sources + ] + top = max(set(tops), key=tops.count) if tops else min(sources) - 1 + + messages, users, chats = [], {}, {group.id: group} + channel = controller.getChat(-int(original.getDialogId())) + if channel is not None: + chats[channel.id] = channel + for item in items: + copy = found.get(int(item.get("message_id") or 0)) if reader is not None else None + msg, _drawn_as = self._message_for(original, item, copy, reader) + self._into_thread(msg, item, discussion, top, sources) + if item.get("sender_id"): + user, chat = _sender_object( + controller, item["sender_id"], item.get("sender_name") + ) + if user is not None: + users[user.id] = user + if chat is not None: + chats[chat.id] = chat + messages.append(msg) + with _lock: + _threads[top] = (account, discussion, messages, list(users.values()), list(chats.values())) + + start = MessageObject(account, self._start_message(original, discussion, top, len(items)), False, False) + args = Bundle() + args.putLong("chat_id", discussion) + thread = ChatActivity(args) + thread.setCurrentAccount(account) + from java.util import ArrayList + + starts = ArrayList() + starts.add(start) + thread.setThreadMessages(starts, group, jint(top), jint(0), jint(0), None) + fragment = get_last_fragment() + if fragment is None: + return + read_only.add(int(thread.getClassGuid())) + fragment.presentFragment(thread) + make_read_only(thread) + # Again once the screen settled (its first layout re-runs the + # overlay animation). + on_ui(lambda: make_read_only(thread), 600) + open_as._opened_as(thread, account) + if reader is None: + on_ui( + lambda: _bulletin("Media unavailable: sign in an account of the logger's groups"), + 600, + ) + + def _into_thread(self, msg, item, discussion, top, sources): + """Makes a mock comment a message of the discussion thread.""" + from org.telegram.tgnet import TLRPC + + msg.id = int(item["source_message_id"]) + msg.dialog_id = -int(discussion) + msg.peer_id = TLRPC.TL_peerChannel() + msg.peer_id.channel_id = int(discussion) + reply = TLRPC.TL_messageReplyHeader() + target = int(item.get("reply_to") or 0) + reply.reply_to_msg_id = target if target in sources else top + reply.reply_to_top_id = top + reply.flags |= 2 + msg.reply_to = reply + msg.flags |= 8 + + def _start_message(self, original, discussion, top, count): + """The post as its discussion copy (forwarded from the channel), the + thread's start message.""" + from org.telegram.tgnet import TLRPC + + owner = original.messageOwner + msg = TLRPC.TL_message() + msg.id = top + msg.dialog_id = -int(discussion) + msg.peer_id = TLRPC.TL_peerChannel() + msg.peer_id.channel_id = int(discussion) + msg.from_id = owner.peer_id + msg.flags |= 256 + fwd = TLRPC.TL_messageFwdHeader() + fwd.from_id = owner.peer_id + fwd.channel_post = int(original.getId()) + fwd.saved_from_peer = owner.peer_id + fwd.saved_from_msg_id = int(original.getId()) + fwd.date = int(owner.date) + fwd.flags |= 1 | 4 | 16 + msg.fwd_from = fwd + msg.flags |= 4 + msg.date = int(owner.date) + msg.message = owner.message or "" + msg.entities = owner.entities + if owner.media is not None: + msg.media = owner.media + msg.flags |= 512 + replies = TLRPC.TL_messageReplies() + replies.replies = int(count) + msg.replies = replies + msg.flags |= 1 << 23 + return msg + + # one comment's content + + def _base(self, original, item): + from org.telegram.messenger import MessagesController + from org.telegram.tgnet import TLRPC + + owner = original.messageOwner + msg = TLRPC.TL_message() + msg.id = -int(item.get("source_message_id") or 1) + msg.dialog_id = int(original.getDialogId()) + msg.peer_id = owner.peer_id + sender = item.get("sender_id") + controller = MessagesController.getInstance(int(original.currentAccount)) + msg.from_id = _peer(controller, sender) if sender else owner.peer_id + msg.flags |= 256 + msg.out = False + msg.date = _epoch(item.get("date")) or int(owner.date) + return msg + + def _from_copy(self, original, item, copy): + """The copy without its header (From, links...).""" + from java.util import ArrayList + + from hooks.chat import _rich_plain_text, _strip_rich_header + + msg = super()._from_copy(original, item, copy) + rich = getattr(copy, "rich_message", None) + if rich is not None: + _strip_rich_header(rich) + plain = _rich_plain_text(rich) + if plain is not None: # text only: a normal bubble + msg.message = plain + msg.entities = ArrayList() + msg.rich_message = None + msg.flags2 &= ~(1 << 12) + return msg + text, entities = header.strip( + msg.message or "", + [(int(e.offset), int(e.length), e) for e in java_list(msg.entities)], + ) + msg.message = text + msg.entities = ArrayList() + for offset, length, entity in entities: + entity.offset = offset + entity.length = length + msg.entities.add(entity) + return msg diff --git a/src/views/history.py b/src/views/history.py index f049123..41ffb5f 100644 --- a/src/views/history.py +++ b/src/views/history.py @@ -217,6 +217,25 @@ class History: # the sheet def _show(self, original, versions, reader, found, topic): + entries = [] + last = len(versions) - 1 + for index, version in enumerate(versions): + label = "Original" if version["version"] == 0 else f"Edit {version['version']}" + if index == last: + label += " · current" + when = _when(version.get("edit_date")) + entries.append((f"{label} · {when}" if when else label, version)) + self._sheet(original, "Edit history", entries, reader, found, topic) + + def _message_for(self, original, item, copy, reader): + """(TL message, account to draw it as) of an entry: from its copy in + the activity group when readable, else from the server's text.""" + if copy is not None and not isinstance(copy, _message_empty()): + return self._from_copy(original, item, copy), reader + return self._from_text(original, item), int(original.currentAccount) + + def _sheet(self, original, title_text, entries, reader, found, topic): + """The mock chat: a service pill (label) and a bubble per entry.""" from android.view import Gravity from android.widget import LinearLayout, ScrollView, TextView from client_utils import get_last_fragment @@ -232,21 +251,17 @@ class History: column = LinearLayout(context) column.setOrientation(LinearLayout.VERTICAL) column.setPadding(0, dp(8), 0, dp(12)) - last = len(versions) - 1 - for index, version in enumerate(versions): - label = "Original" if version["version"] == 0 else f"Edit {version['version']}" - if index == last: - label += " · current" - when = _when(version.get("edit_date")) - column.addView(self._service(context, f"{label} · {when}" if when else label)) - copy = found.get(int(version.get("message_id") or 0)) if reader is not None else None - if copy is not None and not isinstance(copy, _message_empty()): - msg, account = self._from_copy(original, version, copy), reader - else: - msg, account = self._from_text(original, version), int(original.currentAccount) + for label, item in entries: + column.addView(self._service(context, label)) + copy = ( + found.get(int(item.get("message_id") or 0)) if reader is not None else None + ) try: + msg, account = self._message_for(original, item, copy, reader) column.addView( - self._bubble(context, account, self._message_object(account, original, msg), version) + self._bubble( + context, account, self._message_object(account, original, msg), item + ) ) except Exception as exc: diag.error("history.bubble", exc) @@ -265,7 +280,7 @@ class History: header.setOrientation(LinearLayout.HORIZONTAL) header.setGravity(Gravity.CENTER_VERTICAL) title = TextView(context) - title.setText("Edit history" + ("" if reader is not None else " · media unavailable")) + title.setText(title_text + ("" if reader is not None else " · media unavailable")) title.setTextSize(17) title.setTextColor(jint(Theme.getColor(Theme.key_dialogTextBlack))) title.setPadding(dp(20), dp(14), dp(12), dp(12))