generated from exteraGramPlugins/pluginBase
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:
1 parent
9b2f26a845
commit
a2e4075a03
7 files changed
+499
-18
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user