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

+3
View File
@@ -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.
+4 -2
View File
@@ -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"
]
}
+13
View File
@@ -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
+121
View File
@@ -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 = []
+6 -2
View File
@@ -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")
+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
+29 -14
View File
@@ -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))