generated from exteraGramPlugins/pluginBase
Publish plugin / publish (push) Successful in 4s
- Deleted messages within the loaded part of a long chat are placed by the app among the others (not only at the chat's end), without counting as unread; restores no longer repeat every second. - Logger copies and their media are read through a member of the log group signed in here (this account, else the master); the shared file cache lets the current account show them. - Logged chat photos: asked for once per 10 minutes, all sizes downloaded (no waits on inline thumbnails), added once the photo viewer closed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
268 lines
9.2 KiB
Python
268 lines
9.2 KiB
Python
"""The history of a removed chat, rebuilt from the logger: the logged messages
|
|
(`/chats/{id}/history`), each from its copy in a log group when an account
|
|
here can read it (header stripped, media included), else from the server's
|
|
text with its media marked unavailable. All of them are deleted for the
|
|
account; they are cached as such (store), so they draw like any deleted
|
|
message.
|
|
|
|
`History` holds one chat's messages, newest first, and answers the app's
|
|
getHistory/getMessages for it (hooks/removed_chats.py).
|
|
"""
|
|
|
|
import threading
|
|
|
|
import diag
|
|
import media_share
|
|
import peers
|
|
import store
|
|
import tg
|
|
from api import ApiError
|
|
from views.history import _MEDIA_NAMES, _entities, _epoch, _utf16
|
|
|
|
_PAGES = 3 # history pages (of 100) loaded when the chat opens
|
|
_COPIES_PER_REQUEST = 100
|
|
|
|
|
|
def stand_in_user(controller, peer_id, name):
|
|
from org.telegram.tgnet import TLRPC
|
|
|
|
user = controller.getUser(int(peer_id))
|
|
if user is None:
|
|
user = TLRPC.TL_user()
|
|
user.id = int(peer_id)
|
|
user.first_name = name or "Deleted"
|
|
user.flags |= 2
|
|
return user
|
|
|
|
|
|
def stand_in_chat(controller, chat_id, title, kind):
|
|
"""The app's chat (cached or stored), else a stand-in of the right kind."""
|
|
from org.telegram.tgnet import TLRPC
|
|
|
|
chat = controller.getChat(int(chat_id))
|
|
if chat is not None:
|
|
return chat
|
|
if kind == "group":
|
|
chat = TLRPC.TL_chat()
|
|
else:
|
|
chat = TLRPC.TL_channel()
|
|
chat.broadcast = kind == "channel"
|
|
chat.megagroup = kind != "channel"
|
|
chat.id = int(chat_id)
|
|
chat.title = title or "Chat"
|
|
return chat
|
|
|
|
|
|
class History:
|
|
def __init__(self, api, account, dialog_id, chat_id):
|
|
self.api = api
|
|
self.account = account
|
|
self.dialog_id = dialog_id
|
|
self.chat_id = chat_id
|
|
self.messages = [] # TL messages, newest first
|
|
self.users = {}
|
|
self.chats = {}
|
|
self.loaded = False
|
|
self._lock = threading.Lock()
|
|
|
|
# loading (background)
|
|
|
|
def load(self):
|
|
"""Fetches and builds the chat's history once; later calls wait."""
|
|
with self._lock:
|
|
if self.loaded:
|
|
return
|
|
try:
|
|
self._load()
|
|
except Exception as exc:
|
|
diag.error("removed_chat.load", exc)
|
|
self.loaded = True
|
|
|
|
def _load(self):
|
|
me = peers.own_user_id(self.account)
|
|
items, before, master = [], 0, None
|
|
for _page in range(_PAGES):
|
|
try:
|
|
result = self.api.history(me, self.chat_id, before)
|
|
except ApiError as exc:
|
|
diag.log(f"removed chat {self.chat_id}: {exc}")
|
|
break
|
|
page = (result or {}).get("messages") or []
|
|
if not page:
|
|
break
|
|
master = result.get("master_tg_id")
|
|
items += page
|
|
before = min(int(m["message_id"]) for m in page)
|
|
if len(page) < 100:
|
|
break
|
|
revealed = store.is_revealed(self.account, self.dialog_id)
|
|
items = [i for i in items if revealed or not i.get("hidden_at")]
|
|
store.put_server_states(self.account, self.dialog_id, items)
|
|
copies = self._copies(items, master)
|
|
built = []
|
|
for item in items:
|
|
try:
|
|
built.append(self._message(item, copies.get(self._pointer(item))))
|
|
except Exception as exc:
|
|
diag.error("removed_chat.message", exc)
|
|
self.messages = sorted(built, key=lambda m: -int(m.id))
|
|
diag.log(f"removed chat {self.chat_id}: {len(self.messages)} messages")
|
|
|
|
@staticmethod
|
|
def _pointer(item):
|
|
logged = item.get("logged") or {}
|
|
if not logged.get("chat_id") or not logged.get("message_id"):
|
|
return None
|
|
return int(logged["chat_id"]), int(logged["message_id"])
|
|
|
|
def _copies(self, items, master):
|
|
"""Logged copies by (log chat, message), read through an account here
|
|
that can, per log group."""
|
|
by_group = {}
|
|
for item in items:
|
|
pointer = self._pointer(item)
|
|
if pointer is not None:
|
|
by_group.setdefault(pointer[0], set()).add(pointer[1])
|
|
found = {}
|
|
masters = [master, *self.api.masters()]
|
|
for log_chat, ids in by_group.items():
|
|
channel_id = peers.channel_id_from_marked(log_chat)
|
|
reader = media_share.reader_for(self.account, log_chat, masters)
|
|
if reader is None:
|
|
continue
|
|
ordered = sorted(ids)
|
|
for start in range(0, len(ordered), _COPIES_PER_REQUEST):
|
|
chunk = ordered[start : start + _COPIES_PER_REQUEST]
|
|
for message_id, copy in _fetch(reader, -channel_id, chunk).items():
|
|
if reader != self.account:
|
|
media_share.copy_media(reader, copy)
|
|
found[(log_chat, message_id)] = copy
|
|
return found
|
|
|
|
# building
|
|
|
|
def _message(self, item, copy):
|
|
from org.telegram.messenger import MessagesController
|
|
from org.telegram.tgnet import TLRPC
|
|
|
|
from hooks.chat import _android_peer_id, build_from_copy
|
|
|
|
controller = MessagesController.getInstance(self.account)
|
|
message_id = int(item["message_id"])
|
|
if copy is not None and not isinstance(copy, TLRPC.TL_messageEmpty):
|
|
msg = build_from_copy(
|
|
self.account, self.dialog_id, message_id, item, copy, True
|
|
).messageOwner
|
|
else:
|
|
msg = from_text(self.account, self.dialog_id, item)
|
|
sender = item.get("sender_id")
|
|
if sender:
|
|
peer_id = _android_peer_id(int(sender))
|
|
if peer_id > 0:
|
|
user = stand_in_user(controller, peer_id, item.get("sender_name"))
|
|
self.users[int(user.id)] = user
|
|
else:
|
|
chat = stand_in_chat(controller, -peer_id, item.get("sender_name"), "channel")
|
|
self.chats[int(chat.id)] = chat
|
|
if item.get("reply_to"):
|
|
reply = TLRPC.TL_messageReplyHeader()
|
|
reply.reply_to_msg_id = int(item["reply_to"])
|
|
reply.flags |= 16
|
|
msg.reply_to = reply
|
|
msg.flags |= 8
|
|
return msg
|
|
|
|
# answering the app's requests
|
|
|
|
def response(self, picked):
|
|
from java.util import ArrayList
|
|
from org.telegram.tgnet import TLRPC
|
|
|
|
response = TLRPC.TL_messages_messages()
|
|
response.messages = ArrayList()
|
|
for message in picked:
|
|
response.messages.add(message)
|
|
response.users = ArrayList()
|
|
for user in self.users.values():
|
|
response.users.add(user)
|
|
response.chats = ArrayList()
|
|
for chat in self.chats.values():
|
|
response.chats.add(chat)
|
|
return response
|
|
|
|
def page(self, offset_id, add_offset, limit):
|
|
"""getHistory's slice: newest first, from `offset_id` (exclusive; 0:
|
|
the newest) moved by `add_offset` (negative: newer ones too)."""
|
|
messages = self.messages
|
|
if offset_id:
|
|
start = next(
|
|
(i for i, m in enumerate(messages) if int(m.id) < offset_id),
|
|
len(messages),
|
|
)
|
|
else:
|
|
start = 0
|
|
start = max(0, start + add_offset)
|
|
return messages[start : start + max(1, limit)]
|
|
|
|
def by_ids(self, ids):
|
|
wanted = set(int(i) for i in ids)
|
|
return [m for m in self.messages if int(m.id) in wanted]
|
|
|
|
|
|
def from_text(account, dialog_id, item):
|
|
"""A logged message from the server's text; each media is marked
|
|
unavailable after it."""
|
|
from org.telegram.messenger import MessagesController
|
|
from org.telegram.tgnet import TLRPC
|
|
|
|
from hooks.chat import _android_peer_id
|
|
|
|
controller = MessagesController.getInstance(account)
|
|
msg = TLRPC.TL_message()
|
|
msg.id = int(item["message_id"])
|
|
msg.dialog_id = dialog_id
|
|
msg.peer_id = controller.getPeer(dialog_id)
|
|
sender = item.get("sender_id")
|
|
msg.from_id = (
|
|
controller.getPeer(_android_peer_id(int(sender))) if sender else msg.peer_id
|
|
)
|
|
msg.flags |= 256
|
|
msg.date = _epoch(item.get("source_date"))
|
|
if int(item.get("edit_count") or 0) > 0:
|
|
msg.edit_date = _epoch(item.get("edit_date")) or msg.date
|
|
msg.flags |= 32768
|
|
text = item.get("text") or ""
|
|
entities = _entities(item.get("entities") or [])
|
|
notes = [
|
|
f"🚫 {_MEDIA_NAMES.get(kind, 'Media')} unavailable"
|
|
for kind in item.get("media") or []
|
|
]
|
|
if notes:
|
|
note = "\n".join(notes)
|
|
start = _utf16(text) + (2 if text else 0)
|
|
text = (text + "\n\n" if text else "") + note
|
|
italic = TLRPC.TL_messageEntityItalic()
|
|
italic.offset = start
|
|
italic.length = _utf16(note)
|
|
entities.add(italic)
|
|
msg.message = text or "(empty)"
|
|
msg.entities = entities
|
|
msg.out = sender is not None and int(sender) == peers.own_user_id(account)
|
|
return msg
|
|
|
|
|
|
def _fetch(reader, dialog_id, ids):
|
|
"""Messages `ids` of a log group through `reader` (blocking)."""
|
|
done = threading.Event()
|
|
box = {}
|
|
|
|
def callback(found, error):
|
|
if found is None:
|
|
diag.log(f"removed chat copies unreadable: {error}")
|
|
box["found"] = found or {}
|
|
done.set()
|
|
|
|
tg.get_messages(reader, dialog_id, ids, callback)
|
|
done.wait(20)
|
|
return box.get("found") or {}
|