generated from exteraGramPlugins/pluginBase
Message Logger: hidden messages on the server, removed chats, old chat photos
Publish plugin / publish (push) Successful in 6s
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>
This commit is contained in:
1 parent
0176f7099d
commit
362b358d8d
15 files changed
+1821
-125
No files matched your search
+67
@@ -152,3 +152,70 @@ class Api:
|
||||
for _token, result in self._each("POST", "/deletions", json=body):
|
||||
marked += int((result or {}).get("marked", 0))
|
||||
return marked
|
||||
|
||||
def hidden(self, account_id, chat_id, message_ids, hidden):
|
||||
"""Hide messages from the chat for every client (or show them again);
|
||||
True once every master took it."""
|
||||
ids = sorted(set(int(i) for i in message_ids))
|
||||
for start in range(0, len(ids), 500):
|
||||
body = {
|
||||
"account_id": account_id,
|
||||
"chat_id": chat_id,
|
||||
"message_ids": ids[start : start + 500],
|
||||
"hidden": bool(hidden),
|
||||
}
|
||||
for _token, _result in self._each("POST", "/hidden", json=body):
|
||||
pass
|
||||
return True
|
||||
|
||||
# removed chats
|
||||
|
||||
def chat_removed(self, account_id, chat_id, own):
|
||||
body = {"account_id": account_id, "chat_id": chat_id, "own": bool(own)}
|
||||
known = False
|
||||
for _token, result in self._each("POST", "/chats/removed", json=body):
|
||||
known = known or bool((result or {}).get("known"))
|
||||
return known
|
||||
|
||||
def chat_hidden(self, account_id, chat_id, hidden):
|
||||
body = {"account_id": account_id, "chat_id": chat_id, "hidden": bool(hidden)}
|
||||
for _token, _result in self._each("POST", "/chats/hidden", json=body):
|
||||
pass
|
||||
|
||||
def removed_chats(self, account_id):
|
||||
"""The account's removed chats, each master's answer merged (the
|
||||
first one listing a chat wins), or None when no master answered."""
|
||||
chats, any_result = {}, False
|
||||
for _token, result in self._each(
|
||||
"GET", "/chats/removed", params={"account_id": account_id}
|
||||
):
|
||||
if result is None:
|
||||
continue
|
||||
any_result = True
|
||||
for chat in result.get("chats") or []:
|
||||
chat["master_tg_id"] = result.get("master_tg_id")
|
||||
chats.setdefault(int(chat["chat_id"]), chat)
|
||||
return list(chats.values()) if any_result else None
|
||||
|
||||
def history(self, account_id, chat_id, before=0, limit=100):
|
||||
"""Logged messages of a chat older than `before`, newest first: the
|
||||
first master that has any."""
|
||||
params = {"account_id": account_id, "before": int(before), "limit": int(limit)}
|
||||
for _token, result in self._each(
|
||||
"GET", f"/chats/{chat_id}/history", params=params
|
||||
):
|
||||
if result and result.get("messages"):
|
||||
return result
|
||||
return None
|
||||
|
||||
def avatars(self, account_id, chat_id):
|
||||
"""Chat photos logged for a chat, newest first, every master's."""
|
||||
out = []
|
||||
for _token, result in self._each(
|
||||
"GET", f"/chats/{chat_id}/avatars", params={"account_id": account_id}
|
||||
):
|
||||
for avatar in (result or {}).get("avatars") or []:
|
||||
avatar["master_tg_id"] = (result or {}).get("master_tg_id")
|
||||
out.append(avatar)
|
||||
out.sort(key=lambda a: a.get("date") or "", reverse=True)
|
||||
return out
|
||||
@@ -7,6 +7,7 @@ RESTORE_MISSING = "restore_missing"
|
||||
REPORT_DELETIONS = "report_deletions"
|
||||
DELETED_ALPHA = "deleted_alpha"
|
||||
CACHE_DAYS = "cache_days"
|
||||
SHOW_REMOVED_CHATS = "show_removed_chats"
|
||||
|
||||
DEFAULTS = {
|
||||
SERVER_URL: "",
|
||||
@@ -16,6 +17,7 @@ DEFAULTS = {
|
||||
REPORT_DELETIONS: True,
|
||||
DELETED_ALPHA: 0, # index into ALPHA_CHOICES
|
||||
CACHE_DAYS: 2, # index into CACHE_DAYS_CHOICES
|
||||
SHOW_REMOVED_CHATS: True,
|
||||
}
|
||||
|
||||
# Opacity of deleted messages: well visible, marked by the trash icon.
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
"""Chat photos the logger posted (service posts in the chat's log topic, see
|
||||
telegram-panel `avatars`), read from those posts.
|
||||
|
||||
- A removed chat shows its latest logged photo (hooks/removed_chats.py).
|
||||
- A chat's photos (MessagesController.getDialogPhotos: DialogPhotos `photos`,
|
||||
shown by the profile's gallery and the photo viewer, both redrawn on
|
||||
NotificationCenter.dialogPhotosUpdate) get the logged photos the chat no
|
||||
longer has, after its own. The photo viewer's thumbnails of those
|
||||
(GroupedPhotosListView.onDraw) are translucent like deleted messages, with
|
||||
a red trash icon.
|
||||
|
||||
A post is read and its photo downloaded through an account that can read
|
||||
the log group; the app's file cache is shared by its accounts, so the photo
|
||||
then shows for any of them.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
from base_plugin import MethodHook
|
||||
|
||||
import accounts
|
||||
import diag
|
||||
import peers
|
||||
from api import ApiError
|
||||
from jutil import field, hook_methods, in_background, java_list, on_ui
|
||||
from views.history import _epoch
|
||||
|
||||
CONTROLLER = "org.telegram.messenger.MessagesController"
|
||||
THUMBS = "org.telegram.ui.Components.GroupedPhotosListView"
|
||||
NOTIFICATIONS = "org.telegram.messenger.NotificationCenter"
|
||||
_DOWNLOAD_SECONDS = 20
|
||||
# A logged photo whose id isn't Telegram's (logged by a bot) is the chat's
|
||||
# own when one of the chat's photos was set this close to its post.
|
||||
_SAME_PHOTO_SECONDS = 10
|
||||
|
||||
|
||||
def _photo_of(copy):
|
||||
"""The photo of a logger post (a photo message, or the first photo of a
|
||||
rich message)."""
|
||||
if copy is None:
|
||||
return None
|
||||
photo = getattr(getattr(copy, "media", None), "photo", None)
|
||||
if photo is not None and getattr(photo, "sizes", None) is not None:
|
||||
return photo
|
||||
rich = getattr(copy, "rich_message", None)
|
||||
photos = getattr(rich, "photos", None) if rich is not None else None
|
||||
if photos is not None and photos.size():
|
||||
return photos.get(0)
|
||||
return None
|
||||
|
||||
|
||||
def logged_photo(account, avatar):
|
||||
"""(photo, reader slot, post) of a logged chat photo, else None (blocking)."""
|
||||
from views.removed_chat import _fetch
|
||||
|
||||
logged = avatar.get("logged") or {}
|
||||
if not avatar.get("has_photo") or not logged.get("chat_id"):
|
||||
return None
|
||||
channel_id = peers.channel_id_from_marked(logged["chat_id"])
|
||||
readers = accounts.with_access(
|
||||
-channel_id, prefer=(account, accounts.slot_of(avatar.get("master_tg_id")))
|
||||
)
|
||||
if not readers:
|
||||
return None
|
||||
message_id = int(logged["message_id"])
|
||||
copy = _fetch(readers[0], -channel_id, [message_id]).get(message_id)
|
||||
photo = _photo_of(copy)
|
||||
return (photo, readers[0], copy) if photo is not None else None
|
||||
|
||||
|
||||
def _size(photo, side):
|
||||
"""The smallest downloadable size at least `side` px wide (else the
|
||||
largest): inline thumbnails have no file."""
|
||||
sizes = [
|
||||
size
|
||||
for size in java_list(photo.sizes)
|
||||
if getattr(size, "location", None) is not None and int(size.w or 0) > 0
|
||||
]
|
||||
if not sizes:
|
||||
return None
|
||||
bigger = [size for size in sizes if max(int(size.w), int(size.h)) >= side]
|
||||
if bigger:
|
||||
return min(bigger, key=lambda size: int(size.w))
|
||||
return max(sizes, key=lambda size: int(size.w))
|
||||
|
||||
|
||||
def download(reader, photo, copy, side):
|
||||
"""Downloads the photo's size closest to `side` px through `reader` into
|
||||
the shared cache; its path, or None (blocking)."""
|
||||
from java import jint
|
||||
from org.telegram.messenger import FileLoader, ImageLocation, MessageObject
|
||||
|
||||
size = _size(photo, side)
|
||||
if size is None:
|
||||
return None
|
||||
loader = FileLoader.getInstance(reader)
|
||||
path = loader.getPathToAttach(size, True)
|
||||
if not path.exists():
|
||||
parent = MessageObject(reader, copy, False, False)
|
||||
loader.loadFile(
|
||||
ImageLocation.getForPhoto(size, photo),
|
||||
parent,
|
||||
"jpg",
|
||||
jint(FileLoader.PRIORITY_HIGH),
|
||||
jint(1),
|
||||
)
|
||||
deadline = time.monotonic() + _DOWNLOAD_SECONDS
|
||||
while not path.exists() and time.monotonic() < deadline:
|
||||
time.sleep(0.25)
|
||||
return str(path.getAbsolutePath()) if path.exists() else None
|
||||
|
||||
|
||||
class Avatars:
|
||||
def __init__(self, plugin, api):
|
||||
self.plugin = plugin
|
||||
self.api = api
|
||||
self.handles = []
|
||||
self._lock = threading.Lock()
|
||||
self._recorded = {} # (account, dialog) -> [(photo, original id, date)]
|
||||
self._ids = set() # ids of those photos: drawn with the trash icon
|
||||
self._loading = set()
|
||||
self._updates = {} # (account, dialog) -> args of its last dialogPhotosUpdate
|
||||
self._icon = None
|
||||
|
||||
def install(self):
|
||||
avatars = self
|
||||
|
||||
class AfterPhotos(MethodHook):
|
||||
def after_hooked_method(self, param):
|
||||
try:
|
||||
avatars.photos_wanted(param.thisObject, param.args[0], param.getResult())
|
||||
except Exception as exc:
|
||||
diag.error("avatars.photos", exc)
|
||||
|
||||
class AfterThumbs(MethodHook):
|
||||
def after_hooked_method(self, param):
|
||||
try:
|
||||
avatars.draw_thumbs(param.thisObject, param.args[0])
|
||||
except Exception as exc:
|
||||
diag.error("avatars.thumbs", exc)
|
||||
|
||||
class BeforeNotify(MethodHook):
|
||||
def before_hooked_method(self, param):
|
||||
try:
|
||||
avatars.photos_updated(param)
|
||||
except Exception as exc:
|
||||
diag.error("avatars.update", exc)
|
||||
|
||||
self.handles += hook_methods(self.plugin, CONTROLLER, "getDialogPhotos", AfterPhotos())
|
||||
self.handles += hook_methods(self.plugin, THUMBS, "onDraw", AfterThumbs())
|
||||
self.handles += hook_methods(
|
||||
self.plugin, NOTIFICATIONS, "postNotificationName", BeforeNotify()
|
||||
)
|
||||
|
||||
def uninstall(self):
|
||||
for handle in self.handles:
|
||||
try:
|
||||
self.plugin.unhook_method(handle)
|
||||
except Exception:
|
||||
pass
|
||||
self.handles = []
|
||||
|
||||
# loading (background)
|
||||
|
||||
def photos_wanted(self, controller, dialog_id, holder):
|
||||
"""getDialogPhotos: the chat's logged photos are loaded once, then
|
||||
put into its photos (again after the app reloaded them)."""
|
||||
if not self.api.configured or holder is None:
|
||||
return
|
||||
account = int(field(controller, "currentAccount") or 0)
|
||||
key = (account, int(dialog_id))
|
||||
with self._lock:
|
||||
if key in self._recorded:
|
||||
self._add_to(*key, holder)
|
||||
return
|
||||
if key in self._loading:
|
||||
return
|
||||
self._loading.add(key)
|
||||
in_background(lambda: self._load(*key))
|
||||
|
||||
def _load(self, account, dialog_id):
|
||||
try:
|
||||
chat_id = peers.marked_chat_id(account, dialog_id)
|
||||
if chat_id is None:
|
||||
return
|
||||
try:
|
||||
logged = self.api.avatars(peers.own_user_id(account), chat_id)
|
||||
except ApiError as exc:
|
||||
diag.log(f"avatars {chat_id}: {exc}")
|
||||
return
|
||||
recorded = []
|
||||
for avatar in logged:
|
||||
found = logged_photo(account, avatar)
|
||||
if found is None:
|
||||
continue
|
||||
photo, reader, copy = found
|
||||
download(reader, photo, copy, 160)
|
||||
if download(reader, photo, copy, 800) is None:
|
||||
continue
|
||||
original = avatar.get("photo_id")
|
||||
recorded.append(
|
||||
(photo, int(original) if original else None, _epoch(avatar.get("date")))
|
||||
)
|
||||
with self._lock:
|
||||
self._recorded[(account, dialog_id)] = recorded
|
||||
self._ids.update(int(p.id) for p, _o, _d in recorded)
|
||||
if recorded:
|
||||
diag.log(f"avatars {chat_id}: {len(recorded)} logged photos")
|
||||
on_ui(lambda: self._append(account, dialog_id))
|
||||
finally:
|
||||
with self._lock:
|
||||
self._loading.discard((account, dialog_id))
|
||||
|
||||
# the gallery (UI thread)
|
||||
|
||||
def _gone(self, photos, original, date):
|
||||
"""Whether a logged photo is not one the chat still has."""
|
||||
for photo in photos:
|
||||
if photo is None or int(photo.id) in self._ids:
|
||||
continue
|
||||
if original is not None and int(photo.id) == original:
|
||||
return False
|
||||
if original is None and abs(int(photo.date) - date) <= _SAME_PHOTO_SECONDS:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _add_to(self, account, dialog_id, holder):
|
||||
"""Appends the logged photos the chat no longer has; whether any."""
|
||||
photos = field(holder, "photos")
|
||||
if photos is None:
|
||||
return False
|
||||
current = java_list(photos)
|
||||
shown = {int(p.id) for p in current if p is not None}
|
||||
added = False
|
||||
for photo, original, date in self._recorded.get((account, dialog_id), []):
|
||||
if int(photo.id) in shown or not self._gone(current, original, date):
|
||||
continue
|
||||
photos.add(photo)
|
||||
shown.add(int(photo.id))
|
||||
added = True
|
||||
return added
|
||||
|
||||
def _append(self, account, dialog_id):
|
||||
from org.telegram.messenger import MessagesController, NotificationCenter
|
||||
|
||||
holder = MessagesController.getInstance(account).getDialogPhotos(dialog_id)
|
||||
if holder is None or not self._add_to(account, dialog_id, holder):
|
||||
return
|
||||
args = self._updates.get((account, dialog_id))
|
||||
if args is None:
|
||||
from java import jarray
|
||||
from java.lang import Object
|
||||
|
||||
args = jarray(Object)([holder])
|
||||
NotificationCenter.getInstance(account).postNotificationName(
|
||||
NotificationCenter.dialogPhotosUpdate, args
|
||||
)
|
||||
|
||||
def photos_updated(self, param):
|
||||
"""dialogPhotosUpdate: the logged photos are put back into the
|
||||
(reloaded) list before the gallery redraws."""
|
||||
from org.telegram.messenger import NotificationCenter
|
||||
|
||||
if int(param.args[0]) != int(NotificationCenter.dialogPhotosUpdate):
|
||||
return
|
||||
extra = param.args[1]
|
||||
holder = next(
|
||||
(a for a in (extra or []) if a is not None and hasattr(a, "photos")), None
|
||||
)
|
||||
if holder is None:
|
||||
return
|
||||
account = int(field(param.thisObject, "currentAccount") or 0)
|
||||
dialog_id = int(field(holder, "dialogId") or 0)
|
||||
self._updates[(account, dialog_id)] = extra
|
||||
if (account, dialog_id) in self._recorded:
|
||||
self._add_to(account, dialog_id, holder)
|
||||
|
||||
# the trash icon
|
||||
|
||||
def _logged_at(self, strip, index):
|
||||
"""Whether the strip's item `index` is a logged photo."""
|
||||
for name in ("currentObjects", "currentPhotos"):
|
||||
items = field(strip, name)
|
||||
if items is None or not 0 <= index < items.size():
|
||||
continue
|
||||
item = items.get(index)
|
||||
photo_id = getattr(item, "photoId", None) if name == "currentPhotos" else None
|
||||
if photo_id is None:
|
||||
photo_id = getattr(item, "id", None)
|
||||
if photo_id is not None and int(photo_id) in self._ids:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _alpha(self):
|
||||
from config import ALPHA_CHOICES, DEFAULTS, DELETED_ALPHA
|
||||
|
||||
index = int(self.plugin.get_setting(DELETED_ALPHA, DEFAULTS[DELETED_ALPHA]) or 0)
|
||||
return ALPHA_CHOICES[max(0, min(index, len(ALPHA_CHOICES) - 1))][1]
|
||||
|
||||
def draw_thumbs(self, strip, canvas):
|
||||
"""The photo viewer's thumbnails of logged photos: translucent, with a
|
||||
small trash icon (GroupedPhotosListView: `imagesToDraw`, each
|
||||
receiver's param its index)."""
|
||||
if not self._ids:
|
||||
return
|
||||
receivers = field(strip, "imagesToDraw")
|
||||
if receivers is None:
|
||||
return
|
||||
from org.telegram.messenger import AndroidUtilities
|
||||
|
||||
from android.graphics import Paint
|
||||
from java import jint
|
||||
|
||||
icon, paint = self._trash()
|
||||
# The strip draws its thumbnails opaque (it sets their alpha itself)
|
||||
# over the viewer's black: black at the remaining opacity on top
|
||||
# looks the same as the deleted message opacity.
|
||||
shade = Paint()
|
||||
shade.setColor(jint(int((1.0 - self._alpha()) * 255) << 24))
|
||||
dp = AndroidUtilities.dp
|
||||
size = dp(14)
|
||||
for receiver in java_list(receivers):
|
||||
if receiver is None or not self._logged_at(strip, int(receiver.getParam())):
|
||||
continue
|
||||
canvas.drawRect(
|
||||
float(receiver.getImageX()),
|
||||
float(receiver.getImageY()),
|
||||
float(receiver.getImageX2()),
|
||||
float(receiver.getImageY2()),
|
||||
shade,
|
||||
)
|
||||
right = int(receiver.getImageX2()) - dp(2)
|
||||
bottom = int(receiver.getImageY2()) - dp(2)
|
||||
paint.setAlpha(0x99)
|
||||
canvas.drawCircle(right - size / 2.0, bottom - size / 2.0, size * 0.8, paint)
|
||||
icon.setAlpha(255)
|
||||
icon.setBounds(right - size, bottom - size, right, bottom)
|
||||
icon.draw(canvas)
|
||||
|
||||
def _trash(self):
|
||||
if self._icon is None:
|
||||
from android.graphics import Paint, PorterDuff, PorterDuffColorFilter
|
||||
from java import jint
|
||||
from org.telegram.messenger import R
|
||||
from org.telegram.ui.ActionBar import Theme
|
||||
|
||||
from hooks.render import _trash_drawable
|
||||
|
||||
icon = _trash_drawable(R.drawable.msg_delete)
|
||||
red = jint(Theme.getColor(Theme.key_text_RedBold))
|
||||
icon.setColorFilter(PorterDuffColorFilter(red, PorterDuff.Mode.SRC_IN))
|
||||
paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
paint.setColor(jint(0x99000000 - (1 << 32))) # ARGB as a signed int
|
||||
self._icon = (icon, paint)
|
||||
return self._icon
|
||||
+90
-54
@@ -167,6 +167,59 @@ def _rich_plain_text(rich):
|
||||
return "\n".join(_rich_text(b.text) for b in blocks)
|
||||
|
||||
|
||||
def build_from_copy(account, dialog_id, message_id, state, copy, strip):
|
||||
"""A deleted message rebuilt from its logged copy (`strip`: the copy
|
||||
starts with the logger's header) with the server's `state`."""
|
||||
from java.util import ArrayList
|
||||
from org.telegram.messenger import MessageObject, MessagesController
|
||||
from org.telegram.tgnet import TLRPC
|
||||
|
||||
controller = MessagesController.getInstance(account)
|
||||
msg = TLRPC.TL_message()
|
||||
msg.id = message_id
|
||||
msg.dialog_id = dialog_id
|
||||
msg.peer_id = controller.getPeer(dialog_id)
|
||||
sender = state.get("sender_id")
|
||||
msg.from_id = (
|
||||
controller.getPeer(_android_peer_id(sender)) if sender else msg.peer_id
|
||||
)
|
||||
msg.flags |= 256
|
||||
msg.date = _epoch(state.get("source_date")) or int(copy.date)
|
||||
if int(state.get("edit_count") or 0) > 0:
|
||||
msg.edit_date = _epoch(state.get("edit_date")) or msg.date
|
||||
msg.flags |= 32768 # edited: the app draws its pencil
|
||||
text = copy.message or ""
|
||||
entities = [(int(e.offset), int(e.length), e) for e in java_list(copy.entities)]
|
||||
if strip:
|
||||
text, entities = header.strip(text, entities)
|
||||
msg.message = text
|
||||
msg.entities = ArrayList()
|
||||
for offset, length, entity in entities:
|
||||
entity.offset = offset
|
||||
entity.length = length
|
||||
msg.entities.add(entity)
|
||||
media = getattr(copy, "media", None)
|
||||
if media is not None and not isinstance(media, TLRPC.TL_messageMediaEmpty):
|
||||
msg.media = media
|
||||
msg.flags |= 512
|
||||
rich = getattr(copy, "rich_message", None)
|
||||
if rich is not None:
|
||||
if strip:
|
||||
_strip_rich_header(rich)
|
||||
plain = _rich_plain_text(rich)
|
||||
if plain: # never a blank bubble: else it stays rich
|
||||
# Text only: a normal bubble, as the original was.
|
||||
msg.message = plain
|
||||
msg.entities = ArrayList()
|
||||
else:
|
||||
msg.rich_message = rich
|
||||
msg.flags2 |= 1 << 12
|
||||
own = peers.own_user_id(account)
|
||||
msg.out = sender is not None and int(sender) == own
|
||||
msg.unread = False
|
||||
return MessageObject(account, msg, True, True)
|
||||
|
||||
|
||||
class ChatSync:
|
||||
def __init__(self, plugin, api):
|
||||
self.plugin = plugin
|
||||
@@ -227,6 +280,8 @@ class ChatSync:
|
||||
if chat_id is None:
|
||||
return
|
||||
key = (account, dialog_id)
|
||||
if store.is_removed_dialog(account, dialog_id):
|
||||
return # its history brings its state (hooks/removed_chats.py)
|
||||
self._fragments[key] = fragment
|
||||
if key in self._inflight:
|
||||
return
|
||||
@@ -247,9 +302,10 @@ class ChatSync:
|
||||
# cached answers (they were never in the app's own database).
|
||||
if self.plugin.get_setting(RESTORE_MISSING, True):
|
||||
present = set(ids)
|
||||
revealed = store.is_revealed(account, dialog_id)
|
||||
gone = [
|
||||
s
|
||||
for s in store.cached_deleted(account, dialog_id, low, high)
|
||||
for s in store.cached_deleted(account, dialog_id, low, high, revealed)
|
||||
if int(s["message_id"]) not in present
|
||||
]
|
||||
if gone:
|
||||
@@ -355,10 +411,11 @@ class ChatSync:
|
||||
# restoring (background -> UI)
|
||||
|
||||
def _restore(self, fragment, account, dialog_id, chat_id, me, states):
|
||||
revealed = store.is_revealed(account, dialog_id)
|
||||
for state in states:
|
||||
message_id = int(state["message_id"])
|
||||
if store.is_hidden(account, dialog_id, message_id):
|
||||
continue # the user removed it from view
|
||||
if not revealed and store.is_hidden(account, dialog_id, message_id):
|
||||
continue # removed from view
|
||||
key = (account, dialog_id, message_id)
|
||||
if key in self._restoring:
|
||||
continue
|
||||
@@ -396,56 +453,6 @@ class ChatSync:
|
||||
|
||||
tg.get_messages(account, -channel_id, [copy_id], done)
|
||||
|
||||
def _build(self, account, dialog_id, message_id, state, copy, strip):
|
||||
from java.util import ArrayList
|
||||
from org.telegram.messenger import MessageObject, MessagesController
|
||||
from org.telegram.tgnet import TLRPC
|
||||
|
||||
controller = MessagesController.getInstance(account)
|
||||
msg = TLRPC.TL_message()
|
||||
msg.id = message_id
|
||||
msg.dialog_id = dialog_id
|
||||
msg.peer_id = controller.getPeer(dialog_id)
|
||||
sender = state.get("sender_id")
|
||||
msg.from_id = (
|
||||
controller.getPeer(_android_peer_id(sender)) if sender else msg.peer_id
|
||||
)
|
||||
msg.flags |= 256
|
||||
msg.date = _epoch(state.get("source_date")) or int(copy.date)
|
||||
if int(state.get("edit_count") or 0) > 0:
|
||||
msg.edit_date = _epoch(state.get("edit_date")) or msg.date
|
||||
msg.flags |= 32768 # edited: the app draws its pencil
|
||||
text = copy.message or ""
|
||||
entities = [(int(e.offset), int(e.length), e) for e in java_list(copy.entities)]
|
||||
if strip:
|
||||
text, entities = header.strip(text, entities)
|
||||
msg.message = text
|
||||
msg.entities = ArrayList()
|
||||
for offset, length, entity in entities:
|
||||
entity.offset = offset
|
||||
entity.length = length
|
||||
msg.entities.add(entity)
|
||||
media = getattr(copy, "media", None)
|
||||
if media is not None and not isinstance(media, TLRPC.TL_messageMediaEmpty):
|
||||
msg.media = media
|
||||
msg.flags |= 512
|
||||
rich = getattr(copy, "rich_message", None)
|
||||
if rich is not None:
|
||||
if strip:
|
||||
_strip_rich_header(rich)
|
||||
plain = _rich_plain_text(rich)
|
||||
if plain: # never a blank bubble: else it stays rich
|
||||
# Text only: a normal bubble, as the original was.
|
||||
msg.message = plain
|
||||
msg.entities = ArrayList()
|
||||
else:
|
||||
msg.rich_message = rich
|
||||
msg.flags2 |= 1 << 12
|
||||
own = peers.own_user_id(account)
|
||||
msg.out = sender is not None and int(sender) == own
|
||||
msg.unread = False
|
||||
return MessageObject(account, msg, True, True)
|
||||
|
||||
def _insert(self, fragment, account, dialog_id, message_id, state, copy, strip):
|
||||
"""Hands the rebuilt message to the chat the way new messages arrive
|
||||
(ChatActivity.processNewMessages), so the app places it and keeps its
|
||||
@@ -461,7 +468,7 @@ class ChatSync:
|
||||
from java import jboolean
|
||||
from java.util import ArrayList
|
||||
|
||||
obj = self._build(account, dialog_id, message_id, state, copy, strip)
|
||||
obj = build_from_copy(account, dialog_id, message_id, state, copy, strip)
|
||||
store.mark_deleted(account, dialog_id, [message_id])
|
||||
batch = ArrayList()
|
||||
batch.add(obj)
|
||||
@@ -552,6 +559,35 @@ class ChatSync:
|
||||
on_ui(redraw)
|
||||
return removed
|
||||
|
||||
def restore_hidden(self, fragment):
|
||||
"""The chat reveals its hidden messages: those deleted in the loaded
|
||||
range come back from the logger (UI thread). Returns how many."""
|
||||
account = int(fragment.getCurrentAccount())
|
||||
dialog_id = int(fragment.getDialogId())
|
||||
chat_id = peers.marked_chat_id(account, dialog_id)
|
||||
ids = [
|
||||
int(m.getId())
|
||||
for m in java_list(field(fragment, "messages"))
|
||||
if m is not None and int(m.getId()) > 0
|
||||
]
|
||||
if chat_id is None or not ids:
|
||||
return 0
|
||||
low = 1 if _end_reached(fragment, "endReached") else min(ids)
|
||||
high = _MAX_ID if _end_reached(fragment, "forwardEndReached") else max(ids)
|
||||
present = set(ids)
|
||||
hidden = set(store.hidden_deleted(account, dialog_id))
|
||||
gone = [
|
||||
s
|
||||
for s in store.cached_deleted(account, dialog_id, low, high, True)
|
||||
if int(s["message_id"]) in hidden and int(s["message_id"]) not in present
|
||||
]
|
||||
if gone:
|
||||
me = peers.own_user_id(account)
|
||||
in_background(
|
||||
lambda: self._restore(fragment, account, dialog_id, chat_id, me, gone)
|
||||
)
|
||||
return len(gone)
|
||||
|
||||
def drop_from_view(self, account, dialog_id, ids):
|
||||
"""Takes messages out of the open chat's list (UI thread)."""
|
||||
fragment = self._open_chat(account, dialog_id)
|
||||
|
||||
+17
-12
@@ -5,10 +5,10 @@
|
||||
filtered out of MessagesStorage.markMessagesAsDeleted and the
|
||||
messagesDeleted notification; the server's echo then counts as someone
|
||||
else's deletion, see hooks/deletes.py). Unticked (default), it is deleted
|
||||
as usual and remembered as hidden, so it is never restored from the
|
||||
logger here;
|
||||
as usual and hidden (on the server, for every client), so it is not
|
||||
restored from the logger;
|
||||
- already deleted (kept) messages get a plain dialog without checkboxes
|
||||
whose button removes them from view (locally only) and hides them.
|
||||
whose button removes them from this device's view and hides them.
|
||||
|
||||
AlertsCreator.createDeleteMessagesAlert(fragment, user, chat, encryptedChat,
|
||||
chatInfo, mergeDialogId, selectedMessage, selectedMessages[2], group,
|
||||
@@ -53,9 +53,10 @@ def _deleted(message):
|
||||
|
||||
|
||||
class DeleteDialog:
|
||||
def __init__(self, plugin, chats):
|
||||
def __init__(self, plugin, chats, hidden):
|
||||
self.plugin = plugin
|
||||
self.chats = chats # hooks/chat.ChatSync
|
||||
self.hidden = hidden # hooks/hidden.Hidden
|
||||
self.handles = []
|
||||
self._choice = None # [checked] of the open dialog
|
||||
self._keeping = {} # (account, id) -> monotonic time to keep until
|
||||
@@ -169,12 +170,14 @@ class DeleteDialog:
|
||||
|
||||
count = len(targets)
|
||||
builder = AlertDialogBuilder(fragment.getParentActivity())
|
||||
builder.set_title("Remove deleted message" + ("s" if count > 1 else ""))
|
||||
builder.set_title("Hide deleted message" + ("s" if count > 1 else ""))
|
||||
builder.set_message(
|
||||
("These messages were" if count > 1 else "This message was")
|
||||
+ " already deleted. Remove "
|
||||
+ " already deleted. Hide "
|
||||
+ ("them" if count > 1 else "it")
|
||||
+ " from view on this device?"
|
||||
+ " from this chat? The logger keeps "
|
||||
+ ("them" if count > 1 else "it")
|
||||
+ ": the chat menu reveals hidden messages."
|
||||
)
|
||||
|
||||
def remove(_builder, _which):
|
||||
@@ -188,14 +191,15 @@ class DeleteDialog:
|
||||
if hide_dim is not None:
|
||||
hide_dim.run()
|
||||
|
||||
builder.set_positive_button("Remove", remove)
|
||||
builder.set_positive_button("Hide", remove)
|
||||
builder.set_negative_button("Back")
|
||||
builder.make_button_red(AlertDialogBuilder.BUTTON_POSITIVE)
|
||||
builder.set_on_dismiss_listener(dismissed)
|
||||
builder.show()
|
||||
|
||||
def _remove_from_view(self, targets):
|
||||
"""Removes already deleted messages from this device only."""
|
||||
"""Hides already deleted messages (on the server) and takes them out
|
||||
of this device's chat."""
|
||||
from java import jboolean, jint, jlong
|
||||
from java.lang import Object
|
||||
from java.util import ArrayList
|
||||
@@ -207,8 +211,9 @@ class DeleteDialog:
|
||||
account, dialog_id, message_id = _key(message)
|
||||
by_chat.setdefault((account, dialog_id), []).append(message_id)
|
||||
for (account, dialog_id), ids in by_chat.items():
|
||||
store.hide(account, dialog_id, ids)
|
||||
store.forget_deleted(account, dialog_id, ids)
|
||||
# Its cached state stays: revealing the chat's hidden messages
|
||||
# restores it from there.
|
||||
self.hidden.hide(account, dialog_id, ids)
|
||||
channel_id = 0
|
||||
if dialog_id < 0:
|
||||
chat = MessagesController.getInstance(account).getChat(-dialog_id)
|
||||
@@ -249,7 +254,7 @@ class DeleteDialog:
|
||||
store.mark_deleted(account, dialog_id, ids)
|
||||
diag.log(f"keeping own deletion {dialog_id}: {ids}")
|
||||
return list(ids)
|
||||
store.hide(account, dialog_id, ids)
|
||||
self.hidden.hide(account, dialog_id, ids)
|
||||
return []
|
||||
|
||||
def filter_storage(self, param):
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Deleted messages removed from view ("hidden"), marked on the server so
|
||||
every client skips them; a chat can reveal its hidden messages again.
|
||||
|
||||
- `flush` sends the marks waiting in the store to the server;
|
||||
- the chat menu toggles revealing (per chat, until the app restarts): the
|
||||
hidden deleted messages come back from the logger, then go again;
|
||||
- a revealed hidden message has "Unhide" in its menu (hooks/menu.py), and
|
||||
while revealing, the selection bar gets an Unhide button for those
|
||||
selected (ChatActivity.createActionMode adds the bar's items,
|
||||
updateActionModeTitle runs on every selection change; probed on 12.10.6).
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
||||
from base_plugin import MethodHook
|
||||
|
||||
import diag
|
||||
import peers
|
||||
import store
|
||||
from api import ApiError
|
||||
from jutil import field, hook_methods, in_background, on_ui
|
||||
|
||||
CHAT = "org.telegram.ui.ChatActivity"
|
||||
_UNHIDE_ID = 0x4D4C01 # the selection bar item
|
||||
|
||||
|
||||
def _bulletin(text, error=False):
|
||||
def show():
|
||||
from ui.bulletin import BulletinHelper
|
||||
|
||||
(BulletinHelper.show_error if error else BulletinHelper.show_info)(text)
|
||||
|
||||
on_ui(show)
|
||||
|
||||
|
||||
def _selected(chat):
|
||||
"""MessageObjects selected in the chat (both halves of a merged chat)."""
|
||||
out = []
|
||||
for array in field(chat, "selectedMessagesIds") or []:
|
||||
if array is None:
|
||||
continue
|
||||
for index in range(array.size()):
|
||||
message = array.valueAt(index)
|
||||
if message is not None:
|
||||
out.append(message)
|
||||
return out
|
||||
|
||||
|
||||
def is_revealed_hidden(message):
|
||||
"""A hidden deleted message shown because its chat reveals them."""
|
||||
if message is None or int(message.getId()) <= 0:
|
||||
return False
|
||||
account, dialog_id = int(message.currentAccount), int(message.getDialogId())
|
||||
message_id = int(message.getId())
|
||||
return (
|
||||
store.is_revealed(account, dialog_id)
|
||||
and store.is_deleted(account, dialog_id, message_id)
|
||||
and store.is_hidden(account, dialog_id, message_id)
|
||||
)
|
||||
|
||||
|
||||
class Hidden:
|
||||
def __init__(self, plugin, api, chats):
|
||||
self.plugin = plugin
|
||||
self.api = api
|
||||
self.chats = chats # hooks/chat.ChatSync
|
||||
self.handles = []
|
||||
self._flushing = threading.Lock()
|
||||
self._items = {} # ChatActivity classGuid -> the bar's Unhide item
|
||||
|
||||
def install(self):
|
||||
hidden = self
|
||||
|
||||
class AfterActionMode(MethodHook):
|
||||
def after_hooked_method(self, param):
|
||||
try:
|
||||
hidden.add_bar_item(param.thisObject)
|
||||
except Exception as exc:
|
||||
diag.error("hidden.bar", exc)
|
||||
|
||||
class AfterTitle(MethodHook):
|
||||
def after_hooked_method(self, param):
|
||||
try:
|
||||
hidden.update_bar_item(param.thisObject)
|
||||
except Exception as exc:
|
||||
diag.error("hidden.title", exc)
|
||||
|
||||
self.handles += hook_methods(self.plugin, CHAT, "createActionMode", AfterActionMode())
|
||||
self.handles += hook_methods(self.plugin, CHAT, "updateActionModeTitle", AfterTitle())
|
||||
in_background(self.flush)
|
||||
|
||||
def uninstall(self):
|
||||
for handle in self.handles:
|
||||
try:
|
||||
self.plugin.unhook_method(handle)
|
||||
except Exception:
|
||||
pass
|
||||
self.handles = []
|
||||
|
||||
# the server
|
||||
|
||||
def flush(self):
|
||||
"""Sends hidden marks waiting in the store (background)."""
|
||||
if not self.api.configured or not self._flushing.acquire(blocking=False):
|
||||
return
|
||||
try:
|
||||
for (account, dialog_id, hidden), ids in store.pending_hidden().items():
|
||||
me = peers.own_user_id(account)
|
||||
chat_id = peers.marked_chat_id(account, dialog_id)
|
||||
if not me or chat_id is None:
|
||||
continue
|
||||
try:
|
||||
self.api.hidden(me, chat_id, ids, hidden)
|
||||
except ApiError as exc:
|
||||
diag.log(f"hidden {chat_id}: {exc}")
|
||||
continue
|
||||
store.hidden_sent(account, dialog_id, ids, hidden)
|
||||
diag.log(f"{'hid' if hidden else 'unhid'} {chat_id}: {ids}")
|
||||
finally:
|
||||
self._flushing.release()
|
||||
|
||||
def hide(self, account, dialog_id, ids, hidden=True):
|
||||
store.set_hidden(account, dialog_id, ids, hidden)
|
||||
in_background(self.flush)
|
||||
|
||||
# revealing (chat menu)
|
||||
|
||||
def toggle_reveal(self, fragment):
|
||||
account = int(fragment.getCurrentAccount())
|
||||
dialog_id = int(fragment.getDialogId())
|
||||
on = not store.is_revealed(account, dialog_id)
|
||||
store.reveal(account, dialog_id, on)
|
||||
if on:
|
||||
count = self.chats.restore_hidden(fragment)
|
||||
# And the server's answer, for those not cached.
|
||||
store.expire_coverage(account, dialog_id)
|
||||
self.chats.schedule(fragment)
|
||||
_bulletin(
|
||||
f"Message Logger: revealing hidden deleted messages ({count} loaded)"
|
||||
if count
|
||||
else "Message Logger: revealing hidden deleted messages"
|
||||
)
|
||||
else:
|
||||
ids = store.hidden_deleted(account, dialog_id)
|
||||
self.chats.drop_from_view(account, dialog_id, ids)
|
||||
_bulletin("Message Logger: hidden deleted messages are hidden again")
|
||||
|
||||
# unhiding
|
||||
|
||||
def unhide(self, messages):
|
||||
by_chat = {}
|
||||
for message in messages:
|
||||
if is_revealed_hidden(message):
|
||||
key = (int(message.currentAccount), int(message.getDialogId()))
|
||||
by_chat.setdefault(key, []).append(int(message.getId()))
|
||||
for (account, dialog_id), ids in by_chat.items():
|
||||
self.hide(account, dialog_id, ids, hidden=False)
|
||||
self.chats.refresh_dialog(account, dialog_id, ids)
|
||||
count = sum(len(ids) for ids in by_chat.values())
|
||||
if count:
|
||||
_bulletin(f"Message Logger: {count} message{'s' if count > 1 else ''} unhidden")
|
||||
|
||||
# the selection bar
|
||||
|
||||
def add_bar_item(self, chat):
|
||||
"""The bar's Unhide item (also for a chat opened before the plugin
|
||||
loaded: its bar exists already)."""
|
||||
bar = chat.getActionBar()
|
||||
menu = bar.createActionMode() if bar is not None else None
|
||||
if menu is None:
|
||||
return None
|
||||
from android.view import View
|
||||
from android_utils import OnClickListener
|
||||
from org.telegram.messenger import AndroidUtilities, R
|
||||
|
||||
item = menu.getItem(_UNHIDE_ID)
|
||||
if item is None:
|
||||
item = menu.addItemWithWidth(
|
||||
_UNHIDE_ID, R.drawable.msg_views, AndroidUtilities.dp(54)
|
||||
)
|
||||
item.setContentDescription("Unhide")
|
||||
item.setVisibility(View.GONE)
|
||||
|
||||
def chosen(_view):
|
||||
self.unhide(_selected(chat))
|
||||
chat.clearSelectionMode()
|
||||
|
||||
item.setOnClickListener(OnClickListener(chosen))
|
||||
self._items[int(chat.getClassGuid())] = item
|
||||
return item
|
||||
|
||||
def update_bar_item(self, chat):
|
||||
item = self._items.get(int(chat.getClassGuid())) or self.add_bar_item(chat)
|
||||
if item is None:
|
||||
return
|
||||
from android.view import View
|
||||
|
||||
account, dialog_id = int(chat.getCurrentAccount()), int(chat.getDialogId())
|
||||
show = store.is_revealed(account, dialog_id) and any(
|
||||
is_revealed_hidden(m) for m in _selected(chat)
|
||||
)
|
||||
item.setVisibility(View.VISIBLE if show else View.GONE)
|
||||
+32
-18
@@ -1,4 +1,5 @@
|
||||
""""Edit history" in the message menu itself, right above Forward.
|
||||
""""Edit history" (and "Unhide" for a revealed hidden message, see
|
||||
hooks/hidden.py) in the message menu itself, right above Forward.
|
||||
|
||||
Plugin menu items (MenuItemData) only go into the menu's Plugins folder, and
|
||||
exteraGram drops option ids it doesn't know from fillMessageMenu's lists, so
|
||||
@@ -12,10 +13,12 @@ from base_plugin import MethodHook
|
||||
|
||||
import diag
|
||||
import store
|
||||
from hooks.hidden import is_revealed_hidden
|
||||
from jutil import field, hook_methods, java_class
|
||||
|
||||
CHAT = "org.telegram.ui.ChatActivity"
|
||||
_LABEL = "Edit history"
|
||||
_HISTORY = "Edit history"
|
||||
_UNHIDE = "Unhide"
|
||||
|
||||
|
||||
def _worth_it(message):
|
||||
@@ -58,9 +61,10 @@ def _find_item(view, text):
|
||||
|
||||
|
||||
class Menu:
|
||||
def __init__(self, plugin, open_history):
|
||||
def __init__(self, plugin, open_history, unhide):
|
||||
self.plugin = plugin
|
||||
self.open_history = open_history
|
||||
self.unhide = unhide # hooks/hidden.Hidden.unhide
|
||||
self.handles = []
|
||||
self._close = None
|
||||
|
||||
@@ -87,39 +91,49 @@ class Menu:
|
||||
def add_item(self, chat):
|
||||
message = field(chat, "selectedObject")
|
||||
popup = field(chat, "scrimPopupWindow")
|
||||
if popup is None or not _worth_it(message):
|
||||
if popup is None or message is None:
|
||||
return
|
||||
# The createMenu overloads call one another: add the row once.
|
||||
if _find_item(popup.getContentView(), _LABEL) is not None:
|
||||
return
|
||||
from org.telegram.messenger import LocaleController, R
|
||||
from org.telegram.messenger import R
|
||||
|
||||
rows = []
|
||||
if _worth_it(message):
|
||||
rows.append((_HISTORY, R.drawable.msg_edit, lambda: self.open_history(message)))
|
||||
if is_revealed_hidden(message):
|
||||
rows.append((_UNHIDE, R.drawable.msg_views, lambda: self.unhide([message])))
|
||||
# The createMenu overloads call one another: add the rows once.
|
||||
content = popup.getContentView()
|
||||
if not rows or _find_item(content, rows[0][0]) is not None:
|
||||
return
|
||||
from org.telegram.messenger import LocaleController
|
||||
|
||||
# Forward is a row in some chats and an icon in the bottom action bar
|
||||
# in others (groups): then the row goes first in the list.
|
||||
# in others (groups): then the rows go first in the list.
|
||||
anchor = _find_item(
|
||||
content, str(LocaleController.getString(R.string.Forward))
|
||||
) or _first_item(content)
|
||||
if anchor is None:
|
||||
diag.log("menu: no row to place Edit history by")
|
||||
diag.log("menu: no row to place the rows by")
|
||||
return
|
||||
forward = anchor
|
||||
for label, icon, action in rows:
|
||||
self._add_row(chat, anchor, label, icon, action)
|
||||
|
||||
def _add_row(self, chat, anchor, label, icon, action):
|
||||
from android_utils import OnClickListener
|
||||
from org.telegram.ui.ActionBar import ActionBarMenuSubItem
|
||||
|
||||
parent = forward.getParent()
|
||||
item = ActionBarMenuSubItem(forward.getContext(), False, False)
|
||||
item.setTextAndIcon(_LABEL, R.drawable.msg_edit)
|
||||
color = forward.getTextView().getCurrentTextColor()
|
||||
parent = anchor.getParent()
|
||||
item = ActionBarMenuSubItem(anchor.getContext(), False, False)
|
||||
item.setTextAndIcon(label, icon)
|
||||
color = anchor.getTextView().getCurrentTextColor()
|
||||
item.setColors(color, color)
|
||||
item.setMinimumWidth(forward.getMinimumWidth())
|
||||
item.setMinimumWidth(anchor.getMinimumWidth())
|
||||
|
||||
def chosen(_view):
|
||||
self._close_menu(chat)
|
||||
self.open_history(message)
|
||||
action()
|
||||
|
||||
item.setOnClickListener(OnClickListener(chosen))
|
||||
parent.addView(item, parent.indexOfChild(forward), forward.getLayoutParams())
|
||||
parent.addView(item, parent.indexOfChild(anchor), anchor.getLayoutParams())
|
||||
|
||||
def _close_menu(self, chat):
|
||||
if self._close is None:
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
"""Chats an account lost (left, kicked, deleted by it or the other side), kept
|
||||
by the logger: reported to the server, listed among the account's chats
|
||||
again, and opened read-only with their logged messages, all deleted.
|
||||
|
||||
- A chat leaves the list through MessagesController.deleteDialog(long did,
|
||||
int onlyHistory[, boolean revoke]) (the user deleting it, or the app when
|
||||
the account left or was kicked); onlyHistory 1 only clears the history.
|
||||
The removal is reported, `own` when the call came from the app's screens
|
||||
(the user deleted it: it is not listed again). Deleting a listed removed
|
||||
chat hides it.
|
||||
- The server's list (`/chats/removed`) is put into MessagesController
|
||||
(dialogs_dict, allDialogs, dialogMessage) before every sortDialogs, with
|
||||
the user/chat it needs (the app's own when it still has it, else a
|
||||
stand-in) and the chat's latest logged photo as its avatar
|
||||
(ImageReceiver.setForUserOrChat).
|
||||
- Opening one: its requests never reach Telegram (sendRequestInternal):
|
||||
getHistory and channels.getMessages are answered from the logged history
|
||||
(views/removed_chat.py), messages.search with nothing, the rest dropped;
|
||||
the input bar becomes a notice (ChatActivity.updateBottomOverlay).
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
|
||||
from base_plugin import MethodHook
|
||||
|
||||
import accounts
|
||||
import diag
|
||||
import peers
|
||||
import store
|
||||
from api import ApiError
|
||||
from config import DEFAULTS, SHOW_REMOVED_CHATS
|
||||
from hooks import avatars
|
||||
from jutil import field, hook_methods, in_background, java_list, on_ui
|
||||
from views import removed_chat
|
||||
from views.comments import make_read_only
|
||||
from views.history import _epoch
|
||||
|
||||
CONTROLLER = "org.telegram.messenger.MessagesController"
|
||||
CONNECTIONS = "org.telegram.tgnet.ConnectionsManager"
|
||||
CHAT = "org.telegram.ui.ChatActivity"
|
||||
IMAGES = "org.telegram.messenger.ImageReceiver"
|
||||
STORAGE = "org.telegram.messenger.MessagesStorage"
|
||||
_NOTICE = "Removed chat · kept by the logger"
|
||||
_REFRESH_SECONDS = 900
|
||||
_REPORT_AGAIN_SECONDS = 30
|
||||
_ANSWERED = (
|
||||
"TLRPC$TL_messages_getHistory",
|
||||
"TLRPC$TL_channels_getMessages",
|
||||
"TLRPC$TL_messages_search",
|
||||
)
|
||||
|
||||
|
||||
def _from_screens():
|
||||
"""Whether the running call came from the app's screens (a user action)."""
|
||||
from java.lang import Thread
|
||||
|
||||
return any(
|
||||
str(frame.getClassName()).startswith("org.telegram.ui.")
|
||||
for frame in Thread.currentThread().getStackTrace()
|
||||
)
|
||||
|
||||
|
||||
def _dialog_id(chat_id):
|
||||
from hooks.chat import _android_peer_id
|
||||
|
||||
return _android_peer_id(int(chat_id))
|
||||
|
||||
|
||||
def _target(request):
|
||||
"""The dialog a request is about (its peer/channel/user), else None."""
|
||||
for name in ("peer", "channel", "user_id", "id"):
|
||||
value = getattr(request, name, None)
|
||||
if value is None or isinstance(value, (bool, int, float, str)):
|
||||
continue
|
||||
kind = type(value).__name__
|
||||
if "nputPeerChannel" in kind or "nputChannel" in kind:
|
||||
return -int(value.channel_id)
|
||||
if "nputPeerChat" in kind:
|
||||
return -int(value.chat_id)
|
||||
if "nputPeerUser" in kind or kind.endswith("TL_inputUser"):
|
||||
return int(value.user_id)
|
||||
return None
|
||||
|
||||
|
||||
class RemovedChats:
|
||||
def __init__(self, plugin, api):
|
||||
self.plugin = plugin
|
||||
self.api = api
|
||||
self.handles = []
|
||||
self._lists = {} # account -> {dialog_id: server item}
|
||||
self._peers = {} # (account, dialog_id) -> User/Chat (cached or stand-in)
|
||||
# account -> {dialog_id: (Dialog, [MessageObject], its top message id)}
|
||||
self._injected = {}
|
||||
self._histories = {} # (account, dialog_id) -> views.removed_chat.History
|
||||
self._avatar_paths = {} # (account, dialog_id) -> local photo path
|
||||
self._reported = {} # (account, dialog_id) -> monotonic time
|
||||
self._lock = threading.Lock()
|
||||
self._timer = None
|
||||
|
||||
def _enabled(self):
|
||||
return bool(self.plugin.get_setting(SHOW_REMOVED_CHATS, DEFAULTS[SHOW_REMOVED_CHATS]))
|
||||
|
||||
def install(self):
|
||||
chats = self
|
||||
|
||||
def hook(class_name, name, before=None, after=None, arity=None):
|
||||
class Hook(MethodHook):
|
||||
def before_hooked_method(self, param):
|
||||
if before is not None:
|
||||
try:
|
||||
before(param)
|
||||
except Exception as exc:
|
||||
diag.error(f"removed_chats.{name}", exc)
|
||||
|
||||
def after_hooked_method(self, param):
|
||||
if after is not None:
|
||||
try:
|
||||
after(param)
|
||||
except Exception as exc:
|
||||
diag.error(f"removed_chats.{name}", exc)
|
||||
|
||||
chats.handles += hook_methods(chats.plugin, class_name, name, Hook(), arity)
|
||||
|
||||
hook(CONTROLLER, "deleteDialog", before=self.before_delete, arity=2)
|
||||
hook(CONTROLLER, "deleteDialog", before=self.before_delete, arity=3)
|
||||
hook(CONTROLLER, "sortDialogs", before=self.before_sort)
|
||||
hook(CONNECTIONS, "sendRequestInternal", before=self.before_request)
|
||||
hook(CHAT, "updateBottomOverlay", after=self.after_overlay)
|
||||
hook(STORAGE, "putMessages", before=self.before_store)
|
||||
hook(IMAGES, "setForUserOrChat", before=self.before_avatar, arity=6)
|
||||
in_background(self._start)
|
||||
|
||||
def _start(self):
|
||||
"""Lists from the last run at once (offline too), then the server's."""
|
||||
for account, _user in accounts.signed_in():
|
||||
cached = store.get_meta(f"removed_chats:{account}")
|
||||
if cached:
|
||||
self._set_list(account, json.loads(cached))
|
||||
on_ui(self.inject_all)
|
||||
self._schedule(5)
|
||||
|
||||
def uninstall(self):
|
||||
if self._timer is not None:
|
||||
self._timer.cancel()
|
||||
for handle in self.handles:
|
||||
try:
|
||||
self.plugin.unhook_method(handle)
|
||||
except Exception:
|
||||
pass
|
||||
self.handles = []
|
||||
for account in list(self._injected):
|
||||
self._lists[account] = {}
|
||||
try:
|
||||
self.inject(account)
|
||||
except Exception as exc:
|
||||
diag.error("removed_chats.uninstall", exc)
|
||||
|
||||
# the server's list (background)
|
||||
|
||||
def _schedule(self, seconds):
|
||||
self._timer = threading.Timer(seconds, self._tick)
|
||||
self._timer.daemon = True
|
||||
self._timer.start()
|
||||
|
||||
def _tick(self):
|
||||
try:
|
||||
self.refresh_all()
|
||||
finally:
|
||||
self._schedule(_REFRESH_SECONDS)
|
||||
|
||||
def refresh_all(self):
|
||||
if not self.api.configured or not self._enabled():
|
||||
return
|
||||
for account, _user in accounts.signed_in():
|
||||
self.refresh(account)
|
||||
|
||||
def refresh(self, account):
|
||||
me = peers.own_user_id(account)
|
||||
try:
|
||||
chats = self.api.removed_chats(me)
|
||||
except ApiError as exc:
|
||||
diag.log(f"removed chats: {exc}")
|
||||
return
|
||||
if chats is None:
|
||||
return
|
||||
store.put_meta(f"removed_chats:{account}", json.dumps(chats))
|
||||
self._set_list(account, chats)
|
||||
with self._lock:
|
||||
for key in [k for k in self._histories if k[0] == account]:
|
||||
del self._histories[key] # read again on next open
|
||||
on_ui(lambda: self.inject(account))
|
||||
for dialog_id, item in self._lists.get(account, {}).items():
|
||||
avatar = item.get("avatar") or {}
|
||||
if avatar.get("has_photo") and (account, dialog_id) not in self._avatar_paths:
|
||||
in_background(lambda d=dialog_id, a=avatar: self._load_avatar(account, d, a))
|
||||
|
||||
def _set_list(self, account, chats):
|
||||
lists = {}
|
||||
for item in chats:
|
||||
dialog_id = _dialog_id(item["chat_id"])
|
||||
lists[dialog_id] = item
|
||||
if (account, dialog_id) not in self._peers:
|
||||
self._peers[(account, dialog_id)] = self._peer(account, dialog_id, item)
|
||||
self._lists[account] = lists
|
||||
|
||||
def _peer(self, account, dialog_id, item):
|
||||
"""The app's user/chat (cache or storage), else a stand-in."""
|
||||
from org.telegram.messenger import MessagesController, MessagesStorage
|
||||
|
||||
import tg
|
||||
|
||||
controller = MessagesController.getInstance(account)
|
||||
if dialog_id > 0:
|
||||
user = controller.getUser(dialog_id)
|
||||
if user is None:
|
||||
try:
|
||||
user = MessagesStorage.getInstance(account).getUserSync(dialog_id)
|
||||
except Exception:
|
||||
user = None
|
||||
return user or removed_chat.stand_in_user(controller, dialog_id, item.get("title"))
|
||||
chat = tg.channel_chat(account, -dialog_id)
|
||||
return chat or removed_chat.stand_in_chat(
|
||||
controller, -dialog_id, item.get("title"), item.get("kind")
|
||||
)
|
||||
|
||||
def _load_avatar(self, account, dialog_id, avatar):
|
||||
found = avatars.logged_photo(account, avatar)
|
||||
if found is None:
|
||||
return
|
||||
photo, reader, copy = found
|
||||
path = avatars.download(reader, photo, copy, 160)
|
||||
if path is None:
|
||||
return
|
||||
self._avatar_paths[(account, dialog_id)] = path
|
||||
on_ui(lambda: self._redraw(account))
|
||||
|
||||
# the chat list (UI thread)
|
||||
|
||||
def inject_all(self):
|
||||
for account in list(self._lists):
|
||||
self.inject(account)
|
||||
|
||||
def inject(self, account):
|
||||
"""Puts the account's removed chats into the app's chat list (and
|
||||
takes out those no longer removed)."""
|
||||
from org.telegram.messenger import MessagesController
|
||||
|
||||
controller = MessagesController.getInstance(account)
|
||||
wanted = {}
|
||||
if self._enabled():
|
||||
wanted = {
|
||||
d: item
|
||||
for d, item in self._lists.get(account, {}).items()
|
||||
if not item.get("hidden_at") and item.get("top_message")
|
||||
}
|
||||
current = self._injected.setdefault(account, {})
|
||||
for dialog_id in list(current):
|
||||
if dialog_id not in wanted and self.is_injected(account, dialog_id):
|
||||
self._take_out(controller, dialog_id, current.pop(dialog_id)[0])
|
||||
store.set_removed_dialog(account, dialog_id, False)
|
||||
for dialog_id, item in wanted.items():
|
||||
if dialog_id in current or controller.dialogs_dict.get(dialog_id) is not None:
|
||||
continue # listed, or the app has the chat (it is back)
|
||||
current[dialog_id] = self._dialog(controller, account, dialog_id, item)
|
||||
store.set_removed_dialog(account, dialog_id, True)
|
||||
self._put(controller, account)
|
||||
controller.sortDialogs(None)
|
||||
self._redraw(account)
|
||||
|
||||
def _dialog(self, controller, account, dialog_id, item):
|
||||
from org.telegram.messenger import MessageObject
|
||||
from org.telegram.tgnet import TLRPC
|
||||
|
||||
peer = self._peers.get((account, dialog_id))
|
||||
if dialog_id > 0:
|
||||
controller.putUser(peer, True)
|
||||
else:
|
||||
controller.putChat(peer, True)
|
||||
top = item["top_message"]
|
||||
dialog = TLRPC.TL_dialog()
|
||||
dialog.id = dialog_id
|
||||
dialog.peer = controller.getPeer(dialog_id)
|
||||
dialog.top_message = int(top["message_id"])
|
||||
dialog.read_inbox_max_id = dialog.top_message
|
||||
dialog.read_outbox_max_id = dialog.top_message
|
||||
dialog.last_message_date = _epoch(top.get("source_date"))
|
||||
from java.util import ArrayList
|
||||
|
||||
message = removed_chat.from_text(account, dialog_id, top)
|
||||
store.put_server_states(account, dialog_id, [top])
|
||||
# dialogMessage holds the dialog's preview messages (an album's parts).
|
||||
preview = ArrayList()
|
||||
preview.add(MessageObject(account, message, False, False))
|
||||
return dialog, preview, int(dialog.top_message)
|
||||
|
||||
def _put(self, controller, account):
|
||||
"""Our dialogs in the controller's lists (they rebuild them)."""
|
||||
injected = self._injected.get(account) or {}
|
||||
dialogs = controller.dialogs_dict
|
||||
everything = controller.allDialogs
|
||||
messages = controller.dialogMessage
|
||||
for dialog_id, (dialog, message, _top) in list(injected.items()):
|
||||
if not self.is_injected(account, dialog_id):
|
||||
continue # back: the app's dialog now
|
||||
existing = dialogs.get(dialog_id)
|
||||
if existing is not None and not existing.equals(dialog):
|
||||
del injected[dialog_id] # the app has the chat again
|
||||
everything.remove(dialog)
|
||||
continue
|
||||
if existing is None:
|
||||
dialogs.put(dialog_id, dialog)
|
||||
if not everything.contains(dialog):
|
||||
everything.add(dialog)
|
||||
if messages.get(dialog_id) is None:
|
||||
messages.put(dialog_id, message)
|
||||
|
||||
def _take_out(self, controller, dialog_id, dialog):
|
||||
if controller.dialogs_dict.get(dialog_id) is not None and controller.dialogs_dict.get(
|
||||
dialog_id
|
||||
).equals(dialog):
|
||||
controller.dialogs_dict.remove(dialog_id)
|
||||
controller.dialogMessage.remove(dialog_id)
|
||||
controller.allDialogs.remove(dialog)
|
||||
|
||||
def _redraw(self, account):
|
||||
from java import jarray
|
||||
from java.lang import Object
|
||||
from org.telegram.messenger import NotificationCenter
|
||||
|
||||
NotificationCenter.getInstance(account).postNotificationName(
|
||||
NotificationCenter.dialogsNeedReload, jarray(Object)([])
|
||||
)
|
||||
|
||||
def before_sort(self, param):
|
||||
account = int(field(param.thisObject, "currentAccount") or 0)
|
||||
if self._injected.get(account):
|
||||
self._put(param.thisObject, account)
|
||||
|
||||
def is_injected(self, account, dialog_id):
|
||||
"""Whether the dialog is a removed chat listed here. One that got a
|
||||
new message is back: the app keeps the dialog, it is no longer ours."""
|
||||
account, dialog_id = int(account), int(dialog_id)
|
||||
entry = (self._injected.get(account) or {}).get(dialog_id)
|
||||
if entry is None:
|
||||
return False
|
||||
if int(entry[0].top_message) == entry[2]:
|
||||
return True
|
||||
with self._lock:
|
||||
(self._injected.get(account) or {}).pop(dialog_id, None)
|
||||
self._lists.get(account, {}).pop(dialog_id, None)
|
||||
self._histories.pop((account, dialog_id), None)
|
||||
store.set_removed_dialog(account, dialog_id, False)
|
||||
diag.log(f"removed chat {dialog_id} is back")
|
||||
return False
|
||||
|
||||
# removals
|
||||
|
||||
def before_delete(self, param):
|
||||
dialog_id, only_history = int(param.args[0]), int(param.args[1])
|
||||
account = int(field(param.thisObject, "currentAccount") or 0)
|
||||
if self.is_injected(account, dialog_id):
|
||||
param.setResult(None) # not a chat on Telegram
|
||||
if only_history != 1:
|
||||
self._hide(account, dialog_id)
|
||||
return
|
||||
if only_history == 1 or not self.api.configured:
|
||||
return
|
||||
key = (account, dialog_id)
|
||||
now = time.monotonic()
|
||||
if now - self._reported.get(key, -_REPORT_AGAIN_SECONDS) < _REPORT_AGAIN_SECONDS:
|
||||
return # the overloads call one another
|
||||
self._reported[key] = now
|
||||
chat_id = peers.marked_chat_id(account, dialog_id)
|
||||
if chat_id is None:
|
||||
return
|
||||
own = _from_screens()
|
||||
in_background(lambda: self._report(account, chat_id, own))
|
||||
|
||||
def _report(self, account, chat_id, own):
|
||||
try:
|
||||
known = self.api.chat_removed(peers.own_user_id(account), chat_id, own)
|
||||
except ApiError as exc:
|
||||
diag.log(f"chat removal {chat_id}: {exc}")
|
||||
return
|
||||
diag.log(f"chat {chat_id} removed{' by the user' if own else ''}, known={known}")
|
||||
if known:
|
||||
time.sleep(2)
|
||||
self.refresh(account)
|
||||
|
||||
def _hide(self, account, dialog_id):
|
||||
item = self._lists.get(account, {}).get(dialog_id)
|
||||
if item is None:
|
||||
return
|
||||
item["hidden_at"] = "now"
|
||||
on_ui(lambda: self.inject(account))
|
||||
chat_id = item["chat_id"]
|
||||
|
||||
def send():
|
||||
try:
|
||||
self.api.chat_hidden(peers.own_user_id(account), chat_id, True)
|
||||
except ApiError as exc:
|
||||
diag.log(f"chat hide {chat_id}: {exc}")
|
||||
|
||||
in_background(send)
|
||||
|
||||
# an opened removed chat
|
||||
|
||||
def after_overlay(self, param):
|
||||
chat = param.thisObject
|
||||
if self.is_injected(chat.getCurrentAccount(), chat.getDialogId()):
|
||||
make_read_only(chat, _NOTICE)
|
||||
# Again once the screen settled (a channel's bar comes back).
|
||||
on_ui(lambda: make_read_only(chat, _NOTICE), 600)
|
||||
|
||||
def before_store(self, param):
|
||||
"""putMessages(messages_Messages, dialogId, ...): a removed chat's
|
||||
logged messages are not stored as the app's own (they would make it
|
||||
a chat of the app's, read from its cache from then on)."""
|
||||
args = param.args
|
||||
if len(args) < 2 or not hasattr(args[0], "messages"):
|
||||
return
|
||||
account = int(field(param.thisObject, "currentAccount") or 0)
|
||||
if not self.is_injected(account, int(args[1])):
|
||||
return
|
||||
param.setResult(None)
|
||||
done = args[-1]
|
||||
if done is not None and hasattr(done, "run"):
|
||||
done.run()
|
||||
|
||||
def _history(self, account, dialog_id):
|
||||
with self._lock:
|
||||
history = self._histories.get((account, dialog_id))
|
||||
if history is None:
|
||||
item = self._lists.get(account, {}).get(dialog_id) or {}
|
||||
history = removed_chat.History(
|
||||
self.api, account, dialog_id, int(item.get("chat_id") or dialog_id)
|
||||
)
|
||||
self._histories[(account, dialog_id)] = history
|
||||
history.load()
|
||||
return history
|
||||
|
||||
def before_request(self, param):
|
||||
request = param.args[0]
|
||||
if request is None or not any(self._injected.values()):
|
||||
return
|
||||
account = int(field(param.thisObject, "currentAccount") or 0)
|
||||
dialog_id = _target(request)
|
||||
if dialog_id is None or not self.is_injected(account, dialog_id):
|
||||
return
|
||||
name = type(request).__name__
|
||||
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))
|
||||
if name not in _ANSWERED:
|
||||
return # Telegram has nothing for this chat
|
||||
|
||||
def deliver():
|
||||
history = self._history(account, dialog_id)
|
||||
if name == "TLRPC$TL_messages_getHistory":
|
||||
picked = history.page(
|
||||
int(request.offset_id), int(request.add_offset), int(request.limit)
|
||||
)
|
||||
elif name == "TLRPC$TL_channels_getMessages":
|
||||
picked = history.by_ids(int(m.id) for m in java_list(request.id))
|
||||
else:
|
||||
picked = []
|
||||
response = history.response(picked)
|
||||
if delegate is not None:
|
||||
delegate.run(response, None)
|
||||
elif timestamped is not None:
|
||||
timestamped.run(response, None, 0)
|
||||
|
||||
in_background(deliver)
|
||||
|
||||
def before_avatar(self, param):
|
||||
"""The latest logged photo of a removed chat as its avatar."""
|
||||
from org.telegram.tgnet import TLRPC
|
||||
|
||||
target = param.args[0]
|
||||
if target is None or not self._avatar_paths:
|
||||
return
|
||||
if isinstance(target, TLRPC.User):
|
||||
dialog_id = int(target.id)
|
||||
elif isinstance(target, TLRPC.Chat):
|
||||
dialog_id = -int(target.id)
|
||||
else:
|
||||
return
|
||||
receiver = param.thisObject
|
||||
account = int(field(receiver, "currentAccount") or 0)
|
||||
path = self._avatar_paths.get((account, dialog_id))
|
||||
if path is None or not self.is_injected(account, dialog_id):
|
||||
return
|
||||
from java import jint
|
||||
from org.telegram.messenger import ImageLocation
|
||||
|
||||
receiver.setImage(
|
||||
ImageLocation.getForPath(path), "50_50", param.args[1], None, target, jint(0)
|
||||
)
|
||||
param.setResult(None)
|
||||
+50
-2
@@ -15,13 +15,17 @@ from config import (
|
||||
REPORT_DELETIONS,
|
||||
RESTORE_MISSING,
|
||||
SERVER_URL,
|
||||
SHOW_REMOVED_CHATS,
|
||||
)
|
||||
from hooks.avatars import Avatars
|
||||
from hooks.chat import EDIT_UPDATES, ChatSync
|
||||
from hooks.comments import CommentsButton
|
||||
from hooks.delete_dialog import DeleteDialog
|
||||
from hooks.deletes import UPDATES, Deletes
|
||||
from hooks.hidden import Hidden
|
||||
from hooks.links import Links
|
||||
from hooks.menu import Menu
|
||||
from hooks.removed_chats import RemovedChats
|
||||
from hooks.render import Render
|
||||
from jutil import in_background, on_ui
|
||||
from views.comments import Comments
|
||||
@@ -43,19 +47,25 @@ class MessageLoggerPlugin(BasePlugin):
|
||||
self.history = History(self, self.api)
|
||||
self.render = Render(self)
|
||||
self.chats = ChatSync(self, self.api)
|
||||
self.delete_dialog = DeleteDialog(self, self.chats)
|
||||
self.hidden = Hidden(self, self.api, self.chats)
|
||||
self.delete_dialog = DeleteDialog(self, self.chats, self.hidden)
|
||||
self.deletes = Deletes(self, self.api, self.chats, self.delete_dialog)
|
||||
self.links = Links(self)
|
||||
self.menu = Menu(self, self.history.open)
|
||||
self.menu = Menu(self, self.history.open, self.hidden.unhide)
|
||||
self.comments = CommentsButton(self, Comments(self, self.api).open)
|
||||
self.avatars = Avatars(self, self.api)
|
||||
self.removed_chats = RemovedChats(self, self.api)
|
||||
parts = (
|
||||
self.render,
|
||||
self.chats,
|
||||
self.hidden,
|
||||
self.deletes,
|
||||
self.delete_dialog,
|
||||
self.links,
|
||||
self.menu,
|
||||
self.comments,
|
||||
self.avatars,
|
||||
self.removed_chats,
|
||||
)
|
||||
for part in parts:
|
||||
try:
|
||||
@@ -65,11 +75,21 @@ class MessageLoggerPlugin(BasePlugin):
|
||||
self.add_menu_item(
|
||||
MenuItemData(
|
||||
menu_type=MenuItemType.CHAT_ACTION_MENU,
|
||||
item_id="message_logger_reset",
|
||||
text="Reset deleted/edited marks",
|
||||
icon="msg_clear",
|
||||
on_click=self._reset_chat,
|
||||
)
|
||||
)
|
||||
self.add_menu_item(
|
||||
MenuItemData(
|
||||
menu_type=MenuItemType.CHAT_ACTION_MENU,
|
||||
item_id="message_logger_reveal",
|
||||
text="Reveal hidden messages",
|
||||
icon="msg_views",
|
||||
on_click=self._toggle_reveal,
|
||||
)
|
||||
)
|
||||
in_background(accounts.refresh)
|
||||
in_background(self.purge_cache)
|
||||
diag.log("loaded")
|
||||
@@ -89,6 +109,19 @@ class MessageLoggerPlugin(BasePlugin):
|
||||
except Exception as exc:
|
||||
diag.error("reset chat", exc)
|
||||
|
||||
def _toggle_reveal(self, context):
|
||||
"""Chat menu: show the chat's hidden deleted messages (or hide them
|
||||
again)."""
|
||||
try:
|
||||
fragment = context.get("fragment")
|
||||
if fragment is None:
|
||||
from client_utils import get_last_fragment
|
||||
|
||||
fragment = get_last_fragment()
|
||||
self.hidden.toggle_reveal(fragment)
|
||||
except Exception as exc:
|
||||
diag.error("reveal", exc)
|
||||
|
||||
def _reset_all(self, _view=None):
|
||||
"""Settings: forget every chat's cached marks."""
|
||||
def run():
|
||||
@@ -97,6 +130,10 @@ class MessageLoggerPlugin(BasePlugin):
|
||||
|
||||
in_background(run)
|
||||
|
||||
def _removed_chats_changed(self):
|
||||
on_ui(self.removed_chats.inject_all)
|
||||
in_background(self.removed_chats.refresh_all)
|
||||
|
||||
def purge_cache(self, force=False):
|
||||
"""Removes cached answers older than the configured lifetime; at most
|
||||
once a day (the day turns at local midnight) unless `force`."""
|
||||
@@ -110,11 +147,14 @@ class MessageLoggerPlugin(BasePlugin):
|
||||
for part in (
|
||||
"render",
|
||||
"chats",
|
||||
"hidden",
|
||||
"deletes",
|
||||
"delete_dialog",
|
||||
"links",
|
||||
"menu",
|
||||
"comments",
|
||||
"avatars",
|
||||
"removed_chats",
|
||||
):
|
||||
if hasattr(self, part):
|
||||
getattr(self, part).uninstall()
|
||||
@@ -192,6 +232,14 @@ class MessageLoggerPlugin(BasePlugin):
|
||||
subtext="Tell the server about deletions this client sees",
|
||||
icon="msg_report",
|
||||
),
|
||||
Switch(
|
||||
key=SHOW_REMOVED_CHATS,
|
||||
text="Show removed chats",
|
||||
default=DEFAULTS[SHOW_REMOVED_CHATS],
|
||||
subtext="Chats you left or lost stay in the chat list, read-only",
|
||||
icon="msg_archive",
|
||||
on_change=lambda _value: self._removed_chats_changed(),
|
||||
),
|
||||
Text(
|
||||
text="Reset deleted/edited marks",
|
||||
subtext="Forget all cached marks; chats are checked with the server again",
|
||||
|
||||
+162
-28
@@ -1,5 +1,5 @@
|
||||
"""What the plugin knows per message: server state (edits, deletion, copy
|
||||
pointers) and deletions this client saw itself.
|
||||
pointers, hidden marks) and deletions this client saw itself.
|
||||
|
||||
Both are cached in SQLite and loaded per chat on first use: a chat opened
|
||||
again shows its marks at once, before (or without) the server, and a
|
||||
@@ -11,6 +11,9 @@ renewed or expired. An expired chat keeps its cache (shown meanwhile) until
|
||||
the next successful check replaces it. Independently, cached answers older
|
||||
than the configured number of days are removed, checked once a day
|
||||
(`purge_old`).
|
||||
Messages removed from view (hidden) are marked on the server, so every
|
||||
client skips them; a change waits in `hidden_pending` until the server took
|
||||
it (and overrides the server's mark meanwhile).
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -30,6 +33,7 @@ class State:
|
||||
"history",
|
||||
"source_date",
|
||||
"sender_id",
|
||||
"hidden",
|
||||
"raw",
|
||||
)
|
||||
|
||||
@@ -40,6 +44,7 @@ class State:
|
||||
self.history = None
|
||||
self.source_date = None # ISO string
|
||||
self.sender_id = None
|
||||
self.hidden = False # removed from view (kept on the server)
|
||||
self.raw = None # the server's answer for it
|
||||
|
||||
@classmethod
|
||||
@@ -51,6 +56,7 @@ class State:
|
||||
state.history = data.get("history")
|
||||
state.source_date = data.get("source_date")
|
||||
state.sender_id = data.get("sender_id")
|
||||
state.hidden = data.get("hidden_at") is not None
|
||||
state.raw = data
|
||||
return state
|
||||
|
||||
@@ -92,10 +98,19 @@ def _conn():
|
||||
)
|
||||
_db.execute("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)")
|
||||
_db.execute(
|
||||
"CREATE TABLE IF NOT EXISTS hidden ("
|
||||
"account INTEGER, dialog_id INTEGER, message_id INTEGER, at REAL, "
|
||||
"PRIMARY KEY (account, dialog_id, message_id))"
|
||||
"CREATE TABLE IF NOT EXISTS hidden_pending ("
|
||||
"account INTEGER, dialog_id INTEGER, message_id INTEGER, hidden INTEGER, "
|
||||
"at REAL, PRIMARY KEY (account, dialog_id, message_id))"
|
||||
)
|
||||
# Before hidden marks were kept on the server: sent there once.
|
||||
if _db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='hidden'"
|
||||
).fetchone():
|
||||
_db.execute(
|
||||
"INSERT OR IGNORE INTO hidden_pending "
|
||||
"SELECT account, dialog_id, message_id, 1, at FROM hidden"
|
||||
)
|
||||
_db.execute("DROP TABLE hidden")
|
||||
_db.execute(
|
||||
"CREATE TABLE IF NOT EXISTS coverage ("
|
||||
"account INTEGER, dialog_id INTEGER, low INTEGER, high INTEGER, at REAL, "
|
||||
@@ -180,17 +195,17 @@ def put_server_states(account, dialog_id, items):
|
||||
diag.error("store.put_server_states", exc)
|
||||
|
||||
|
||||
def cached_deleted(account, dialog_id, low, high):
|
||||
def cached_deleted(account, dialog_id, low, high, hidden=False):
|
||||
"""Cached server answers of deleted messages with a logged copy in
|
||||
[low, high] (what a restore needs, without asking the server again)."""
|
||||
[low, high] (what a restore needs, without asking the server again);
|
||||
hidden ones only when `hidden`."""
|
||||
with _lock:
|
||||
_local_ids(account, dialog_id)
|
||||
hidden = _hidden_ids(account, dialog_id)
|
||||
return [
|
||||
state.raw
|
||||
for message_id, state in _states.get((account, dialog_id), {}).items()
|
||||
if low <= message_id <= high
|
||||
and message_id not in hidden
|
||||
and (hidden or not is_hidden(account, dialog_id, message_id))
|
||||
and state.raw is not None
|
||||
and state.raw.get("deleted_at")
|
||||
and state.raw.get("logged")
|
||||
@@ -323,45 +338,164 @@ def reset(account=None, dialog_id=None):
|
||||
return removed
|
||||
|
||||
|
||||
_hidden = {} # (account, dialog_id) -> set of ids the user removed from view
|
||||
_pending = None # (account, dialog_id, message_id) -> hidden, not yet on the server
|
||||
_revealed = set() # (account, dialog_id) showing their hidden messages
|
||||
|
||||
|
||||
def _hidden_ids(account, dialog_id):
|
||||
key = (account, dialog_id)
|
||||
if key not in _hidden:
|
||||
found = set()
|
||||
def _pending_marks():
|
||||
global _pending
|
||||
if _pending is None:
|
||||
found = {}
|
||||
try:
|
||||
rows = _conn().execute(
|
||||
"SELECT message_id FROM hidden WHERE account=? AND dialog_id=?", key
|
||||
).fetchall()
|
||||
found = {r[0] for r in rows}
|
||||
for account, dialog_id, message_id, hidden in _conn().execute(
|
||||
"SELECT account, dialog_id, message_id, hidden FROM hidden_pending"
|
||||
).fetchall():
|
||||
found[(account, dialog_id, message_id)] = bool(hidden)
|
||||
except Exception as exc:
|
||||
diag.error("store.hidden", exc)
|
||||
_hidden[key] = found
|
||||
return _hidden[key]
|
||||
diag.error("store.hidden_pending", exc)
|
||||
_pending = found
|
||||
return _pending
|
||||
|
||||
|
||||
def hide(account, dialog_id, message_ids):
|
||||
"""Messages the user deleted (or removed from view): never shown or
|
||||
restored here again."""
|
||||
def set_hidden(account, dialog_id, message_ids, hidden=True):
|
||||
"""Removes messages from view (or shows them again) here at once; the
|
||||
change waits for the server in `hidden_pending` (see `pending_hidden`)."""
|
||||
ids = [int(i) for i in message_ids]
|
||||
with _lock:
|
||||
_hidden_ids(account, dialog_id).update(ids)
|
||||
pending = _pending_marks()
|
||||
states = _states.get((account, dialog_id), {})
|
||||
for i in ids:
|
||||
pending[(account, dialog_id, i)] = bool(hidden)
|
||||
state = states.get(i)
|
||||
if state is not None:
|
||||
state.hidden = bool(hidden)
|
||||
try:
|
||||
db = _conn()
|
||||
now = time.time()
|
||||
db.executemany(
|
||||
"INSERT OR IGNORE INTO hidden VALUES (?, ?, ?, ?)",
|
||||
[(account, dialog_id, i, now) for i in ids],
|
||||
"INSERT OR REPLACE INTO hidden_pending VALUES (?, ?, ?, ?, ?)",
|
||||
[(account, dialog_id, i, int(bool(hidden)), now) for i in ids],
|
||||
)
|
||||
db.commit()
|
||||
except Exception as exc:
|
||||
diag.error("store.hide", exc)
|
||||
diag.error("store.set_hidden", exc)
|
||||
|
||||
|
||||
def pending_hidden():
|
||||
"""Hidden marks not on the server yet: {(account, dialog_id, hidden): [ids]}."""
|
||||
with _lock:
|
||||
out = {}
|
||||
for (account, dialog_id, message_id), hidden in _pending_marks().items():
|
||||
out.setdefault((account, dialog_id, hidden), []).append(message_id)
|
||||
return out
|
||||
|
||||
|
||||
def hidden_sent(account, dialog_id, message_ids, hidden):
|
||||
"""The server took these marks: they are its state now."""
|
||||
ids = [int(i) for i in message_ids]
|
||||
with _lock:
|
||||
pending = _pending_marks()
|
||||
sent = [i for i in ids if pending.get((account, dialog_id, i)) == bool(hidden)]
|
||||
for i in sent:
|
||||
del pending[(account, dialog_id, i)]
|
||||
states = _states.get((account, dialog_id), {})
|
||||
changed = []
|
||||
for i in sent:
|
||||
state = states.get(i)
|
||||
if state is not None and state.raw is not None:
|
||||
state.raw["hidden_at"] = (
|
||||
state.raw.get("hidden_at") or _now_iso() if hidden else None
|
||||
)
|
||||
state.hidden = bool(hidden)
|
||||
changed.append((account, dialog_id, i, json.dumps(state.raw)))
|
||||
try:
|
||||
db = _conn()
|
||||
db.executemany(
|
||||
"DELETE FROM hidden_pending WHERE account=? AND dialog_id=? AND message_id=?",
|
||||
[(account, dialog_id, i) for i in sent],
|
||||
)
|
||||
db.executemany(
|
||||
"UPDATE states SET data=? WHERE account=? AND dialog_id=? AND message_id=?",
|
||||
[(data, a, d, i) for a, d, i, data in changed],
|
||||
)
|
||||
db.commit()
|
||||
except Exception as exc:
|
||||
diag.error("store.hidden_sent", exc)
|
||||
|
||||
|
||||
def _now_iso():
|
||||
import datetime
|
||||
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
|
||||
|
||||
def is_hidden(account, dialog_id, message_id):
|
||||
with _lock:
|
||||
return int(message_id) in _hidden_ids(account, dialog_id)
|
||||
pending = _pending_marks().get((account, dialog_id, int(message_id)))
|
||||
if pending is not None:
|
||||
return pending
|
||||
state = _states.get((account, dialog_id), {}).get(int(message_id))
|
||||
return state is not None and state.hidden
|
||||
|
||||
|
||||
def hidden_deleted(account, dialog_id):
|
||||
"""Ids of the chat's deleted messages that are hidden (cached)."""
|
||||
with _lock:
|
||||
_local_ids(account, dialog_id)
|
||||
return [
|
||||
i
|
||||
for i, state in _states.get((account, dialog_id), {}).items()
|
||||
if state.deleted and is_hidden(account, dialog_id, i)
|
||||
]
|
||||
|
||||
|
||||
def reveal(account, dialog_id, on):
|
||||
with _lock:
|
||||
if on:
|
||||
_revealed.add((account, dialog_id))
|
||||
else:
|
||||
_revealed.discard((account, dialog_id))
|
||||
|
||||
|
||||
def is_revealed(account, dialog_id):
|
||||
return (account, dialog_id) in _revealed
|
||||
|
||||
|
||||
_removed_dialogs = set() # (account, dialog_id) of removed chats listed
|
||||
|
||||
|
||||
def set_removed_dialog(account, dialog_id, listed):
|
||||
"""A removed chat listed among the chats (hooks/removed_chats.py): its
|
||||
state comes with its history, the chat is not synced."""
|
||||
with _lock:
|
||||
if listed:
|
||||
_removed_dialogs.add((int(account), int(dialog_id)))
|
||||
else:
|
||||
_removed_dialogs.discard((int(account), int(dialog_id)))
|
||||
|
||||
|
||||
def is_removed_dialog(account, dialog_id):
|
||||
return (int(account), int(dialog_id)) in _removed_dialogs
|
||||
|
||||
|
||||
def get_meta(key):
|
||||
try:
|
||||
with _lock:
|
||||
row = _conn().execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone()
|
||||
return row[0] if row else None
|
||||
except Exception as exc:
|
||||
diag.error("store.get_meta", exc)
|
||||
return None
|
||||
|
||||
|
||||
def put_meta(key, value):
|
||||
try:
|
||||
with _lock:
|
||||
db = _conn()
|
||||
db.execute("INSERT OR REPLACE INTO meta VALUES (?, ?)", (key, value))
|
||||
db.commit()
|
||||
except Exception as exc:
|
||||
diag.error("store.put_meta", exc)
|
||||
|
||||
|
||||
def note_own_deletion(account, message_ids):
|
||||
|
||||
+38
-5
@@ -33,9 +33,9 @@ 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."""
|
||||
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
|
||||
@@ -47,7 +47,12 @@ def make_read_only(chat):
|
||||
text = field(chat, "bottomOverlayText")
|
||||
if overlay is None or text is None:
|
||||
return
|
||||
for name in ("bottomOverlayChatText", "bottomOverlayStartButton", "bottomOverlayProgress"):
|
||||
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)
|
||||
@@ -56,12 +61,40 @@ def make_read_only(chat):
|
||||
animation = field(chat, "bottomOverlayAnimation")
|
||||
if animation is not None:
|
||||
animation.cancel()
|
||||
text.setText("Viewing deleted comments")
|
||||
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
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
"""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 accounts
|
||||
import diag
|
||||
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 = {}
|
||||
for log_chat, ids in by_group.items():
|
||||
channel_id = peers.channel_id_from_marked(log_chat)
|
||||
readers = accounts.with_access(
|
||||
-channel_id, prefer=(self.account, accounts.slot_of(master))
|
||||
)
|
||||
if not readers:
|
||||
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(readers[0], -channel_id, chunk).items():
|
||||
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 {}
|
||||
Reference in new issue
Block a user