"""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, notice="Viewing deleted comments"): """Swaps a 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 ( "bottomOverlayChat", # a channel's bar (search, mute/join) "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(notice) text.setVisibility(View.VISIBLE) overlay.setAlpha(1.0) overlay.setVisibility(View.VISIBLE) def answer(account, request): """The response for a getReplies of a thread opened here, else None.""" with _lock: thread = _threads.get(int(request.msg_id)) if thread is None or thread[0] != int(account): return None _account, _discussion, messages, users, chats = thread from java.util import ArrayList from org.telegram.tgnet import TLRPC offset_id = int(request.offset_id or 0) add_offset = int(request.add_offset or 0) picked = [ m for m in messages if not offset_id or add_offset < 0 or int(m.id) < offset_id ] response = TLRPC.TL_messages_messages() response.messages = ArrayList() for message in sorted(picked, key=lambda m: -int(m.id)): response.messages.add(message) response.users = ArrayList() for user in users: response.users.add(user) response.chats = ArrayList() for chat in chats: response.chats.add(chat) return response 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: # 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