generated from exteraGramPlugins/pluginBase
Publish plugin / publish (push) Successful in 6s
- Hidden deleted messages are marked on the server (local marks migrated); "Reveal hidden messages" in the chat menu, Unhide in the message menu and the selection bar. - Chats an account lost are reported, listed among its chats again and opened read-only with their logged messages, all deleted. - Logged chat photos: a removed chat's avatar, and in a chat's photos the ones it no longer has, translucent with a trash icon in the viewer's previews. - Fix: the deleted-post comments thread is answered locally again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
357 lines
13 KiB
Python
357 lines
13 KiB
Python
"""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
|