Message Logger: comments of deleted channel posts
Publish plugin / publish (push) Successful in 4s

The comments button of a deleted post opens the app's own comments section
(discussion thread), its history request answered locally from the logged
comments; the input bar is replaced by a "Viewing deleted comments" notice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lyuksovannyyandClaude Opus 5.5 committed 2026-10-09 13:46:11 +02:00
1 parent 9b2f26a845
commit a2e4075a03
7 files changed
+499 -18

No files matched your search

+323
View File
@@ -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