Message Logger: show logged edits and deletions from telegram-panel [no bump]
Publish plugin / publish (push) Successful in 4s

Keeps deleted messages (75% opacity, red icon-pack trash icon), restores
logged messages deleted while away, reports deletions, and shows edit
history as a mock chat (Edit history in the message menu, Open topic).
Message links and history open as any signed-in account with access.
Per-master API tokens; chat state synced per checked range with an hour's
validity, cached in SQLite with a configurable lifetime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lyuksovannyyandClaude Opus 5.5 committed 2026-10-08 23:55:23 +02:00
1 parent 80f1d5e55c
commit 9b2f26a845
26 files changed
+2928 -112

No files matched your search

+162
View File
@@ -0,0 +1,162 @@
"""Every signed-in account and the chats it has, cached client-side, to find
an account that can open a chat the current one isn't in.
Each account's dialogs come from its own database (`dialogs` table, read on
its storage queue); membership is confirmed against the chat itself before
use, since a dialog can outlive leaving a channel.
"""
import threading
import time
import diag
_REFRESH_SECONDS = 600
_lock = threading.Lock()
_dialogs = {} # account slot -> set of dialog ids
_loaded_at = 0.0
_Query = []
def signed_in():
"""[(slot, telegram user id)] of every signed-in account."""
from org.telegram.messenger import UserConfig
out = []
for slot in range(UserConfig.MAX_ACCOUNT_COUNT):
config = UserConfig.getInstance(slot)
if config.isClientActivated():
out.append((slot, int(config.getClientUserId())))
return out
def current_slot():
from client_utils import get_last_fragment
from org.telegram.messenger import UserConfig
fragment = get_last_fragment()
if fragment is not None:
return int(fragment.getCurrentAccount())
return int(UserConfig.selectedAccount)
def slot_of(tg_id):
"""The slot signed in as Telegram user `tg_id`, else None."""
for slot, user_id in signed_in():
if tg_id and user_id == int(tg_id):
return slot
return None
def name_of(slot):
from org.telegram.messenger import UserConfig, UserObject
user = UserConfig.getInstance(slot).getCurrentUser()
return UserObject.getUserName(user) if user is not None else f"account {slot + 1}"
def _query_class():
if not _Query:
from java import dynamic_proxy
from java.lang import Runnable
class Query(dynamic_proxy(Runnable)):
def __init__(self, storage, done):
super().__init__()
self.storage = storage
self.done = done
def run(self):
found = set()
try:
cursor = self.storage.getDatabase().queryFinalized(
"SELECT did FROM dialogs"
)
while cursor.next():
found.add(int(cursor.longValue(0)))
cursor.dispose()
except Exception as exc:
diag.error("accounts.query", exc)
self.done(found)
_Query.append(Query)
return _Query[0]
def refresh(wait=False):
"""Re-reads every account's dialogs (blocking when `wait`; never call
that from the UI or a storage thread)."""
global _loaded_at
from org.telegram.messenger import MessagesStorage
pending = []
for slot, _user in signed_in():
event = threading.Event()
def done(found, slot=slot, event=event):
with _lock:
_dialogs[slot] = found
event.set()
storage = MessagesStorage.getInstance(slot)
storage.getStorageQueue().postRunnable(_query_class()(storage, done))
pending.append(event)
_loaded_at = time.monotonic()
if wait:
for event in pending:
event.wait(5)
def _fresh():
if time.monotonic() - _loaded_at > _REFRESH_SECONDS:
refresh()
def has_access(slot, dialog_id):
"""Whether `slot` can open the dialog (a member, for groups/channels)."""
from org.telegram.messenger import ChatObject, MessagesController
dialog_id = int(dialog_id)
controller = MessagesController.getInstance(slot)
if dialog_id > 0:
return dialog_id in _dialogs.get(slot, ()) or (
controller.dialogs_dict.get(dialog_id) is not None
)
import tg
chat = tg.channel_chat(slot, -dialog_id)
return chat is not None and not ChatObject.isNotInChat(chat)
def with_access(dialog_id, prefer=()):
"""Signed-in slots that can open the dialog: `prefer` first, then any
whose chats include it."""
_fresh()
with _lock:
cached = [s for s, d in _dialogs.items() if int(dialog_id) in d]
order = [s for s in prefer if s is not None] + cached
seen, out = set(), []
for slot in order:
if slot in seen:
continue
seen.add(slot)
try:
if has_access(slot, dialog_id):
out.append(slot)
except Exception as exc:
diag.error("accounts.has_access", exc)
return out
def resolve_username(username):
"""Dialog id of a username known to any signed-in account, else None."""
from org.telegram.messenger import MessagesController
from org.telegram.tgnet import TLRPC
for slot, _user in signed_in():
found = MessagesController.getInstance(slot).getUserOrChat(username)
if isinstance(found, TLRPC.User):
return int(found.id)
if isinstance(found, TLRPC.Chat):
return -int(found.id)
return None
+141
View File
@@ -0,0 +1,141 @@
"""telegram-panel client API (`/api/v1/client/message-logger`).
Each token reads one master's logs (the account owning the logger groups);
the plugin holds one per master. A chat's state merges every master's answer;
a single message is looked up master by master.
Calls are blocking: run them off the UI thread (`jutil.in_background`).
"""
import re
import time
import diag
from config import API_TOKEN, HTTP_TIMEOUT_SECONDS, SERVER_URL
# A token the server refuses (403: unbound, no permission) is skipped this long.
_REFUSED_SECONDS = 600
class ApiError(Exception):
pass
class Api:
def __init__(self, plugin):
self.plugin = plugin
self._refused = {} # token -> monotonic time to retry at
@property
def configured(self):
return bool(self._base() and self.tokens())
def _base(self):
url = str(self.plugin.get_setting(SERVER_URL, "") or "").strip().rstrip("/")
return url + "/api/v1/client/message-logger" if url else ""
def tokens(self):
raw = str(self.plugin.get_setting(API_TOKEN, "") or "")
return [t for t in re.split(r"[\s,;]+", raw) if t]
def _call(self, token, method, path, **kwargs):
import requests
if not self._base():
raise ApiError("Server URL is not set")
try:
response = requests.request(
method,
self._base() + path,
headers={"Authorization": "Bearer " + token},
timeout=HTTP_TIMEOUT_SECONDS,
**kwargs,
)
except requests.RequestException as exc:
raise ApiError(f"Server unreachable: {exc}") from exc
if response.status_code == 404:
return None
if response.status_code >= 400:
try:
detail = response.json().get("detail")
except Exception:
detail = response.text[:200]
raise ApiError(f"{response.status_code}: {detail}")
return response.json()
def _each(self, method, path, **kwargs):
"""(token, result) for every usable token; a refused one is skipped."""
for token in self.tokens():
if time.monotonic() < self._refused.get(token, 0):
continue
try:
yield token, self._call(token, method, path, **kwargs)
except ApiError as exc:
if str(exc).startswith(("401", "403")):
diag.log(f"token {token[:10]}… refused: {exc}")
self._refused[token] = time.monotonic() + _REFUSED_SECONDS
continue
raise
def _first(self, method, path, **kwargs):
for _token, result in self._each(method, path, **kwargs):
if result is not None:
return result
return None
def me(self):
"""[(token prefix, /me or the error)] for every token."""
out = []
for token in self.tokens():
try:
out.append((token[:10], self._call(token, "GET", "/me")))
except ApiError as exc:
out.append((token[:10], exc))
return out
def sync(self, account_id, chat_id, min_id, max_id, present_ids):
body = {
"account_id": account_id,
"chat_id": chat_id,
"min_id": min_id,
"max_id": max_id,
"present_ids": list(present_ids)[:1000],
}
states, missing, any_result = {}, set(), False
for _token, result in self._each("POST", "/sync", json=body):
if result is None:
continue
any_result = True
for state in result.get("states") or []:
states.setdefault(int(state["message_id"]), state)
missing.update(int(i) for i in result.get("missing") or [])
if not any_result:
return None
return {"states": list(states.values()), "missing": sorted(missing)}
def message(self, account_id, chat_id, message_id):
return self._first(
"GET",
f"/messages/{chat_id}/{message_id}",
params={"account_id": account_id},
)
def versions(self, account_id, chat_id, message_id):
return self._first(
"GET",
f"/messages/{chat_id}/{message_id}/versions",
params={"account_id": account_id},
)
def deletions(self, account_id, chat_id, message_ids):
ids = sorted(set(int(i) for i in message_ids))
marked = 0
for start in range(0, len(ids), 500):
body = {
"account_id": account_id,
"chat_id": chat_id,
"message_ids": ids[start : start + 500],
}
for _token, result in self._each("POST", "/deletions", json=body):
marked += int((result or {}).get("marked", 0))
return marked
+36
View File
@@ -0,0 +1,36 @@
"""Setting keys and defaults."""
SERVER_URL = "server_url"
API_TOKEN = "api_token"
KEEP_DELETED = "keep_deleted"
RESTORE_MISSING = "restore_missing"
REPORT_DELETIONS = "report_deletions"
DELETED_ALPHA = "deleted_alpha"
CACHE_DAYS = "cache_days"
DEFAULTS = {
SERVER_URL: "",
API_TOKEN: "",
KEEP_DELETED: True,
RESTORE_MISSING: True,
REPORT_DELETIONS: True,
DELETED_ALPHA: 0, # index into ALPHA_CHOICES
CACHE_DAYS: 2, # index into CACHE_DAYS_CHOICES
}
# Opacity of deleted messages: well visible, marked by the trash icon.
ALPHA_CHOICES = [("75%", 0.75), ("90%", 0.9), ("50%", 0.5)]
# A chat's checked range of message ids stays valid this long: within it the
# server is asked again only after an edit/delete event in the chat.
COVERAGE_SECONDS = 3600
# Cached server answers older than this are removed (checked once a day).
CACHE_DAYS_CHOICES = [
("1 day", 1),
("7 days", 7),
("30 days", 30),
("90 days", 90),
("365 days", 365),
("Never", None),
]
HTTP_TIMEOUT_SECONDS = 15
+54
View File
@@ -0,0 +1,54 @@
"""Diagnostics mirrored to a file, since this device's logcat surfaces neither
Chaquopy stdout nor android_utils.log output.
The file lives under `get_files_dir()` (app external storage), so it is
readable over adb:
adb shell cat "/sdcard/Android/data/com.exteragram.messenger/files/exteraGram/exteraGram Files/message_logger/diag.log"
"""
import os
import time
import traceback
_MAX_BYTES = 256 * 1024
_path = None
def _file():
global _path
if _path is None:
try:
from file_utils import get_files_dir
directory = os.path.join(get_files_dir(), "message_logger")
os.makedirs(directory, exist_ok=True)
_path = os.path.join(directory, "diag.log")
except Exception:
_path = ""
return _path
def log(message):
line = time.strftime("%H:%M:%S ") + str(message)
try:
from android_utils import log as android_log
android_log("[message_logger] " + line)
except Exception:
pass
path = _file()
if not path:
return
try:
mode = "a"
if os.path.exists(path) and os.path.getsize(path) > _MAX_BYTES:
mode = "w"
with open(path, mode, encoding="utf-8") as handle:
handle.write(line + "\n")
except Exception:
pass
def error(where, exc=None):
detail = traceback.format_exc() if exc is not None else ""
log(f"ERROR {where}: {exc!r}\n{detail}")
-1
View File
@@ -1 +0,0 @@
from .template import render, TemplateError
-13
View File
@@ -1,13 +0,0 @@
from textutil import normalize
class TemplateError(ValueError):
pass
def render(template: str, name: str) -> str:
normalized = normalize(name)
try:
return template.format(name=normalized)
except (KeyError, IndexError, ValueError):
raise TemplateError(f"unsupported placeholder in {template!r}")
+41
View File
@@ -0,0 +1,41 @@
"""The logger's saved copy starts with a header (From, links, deletion
marker...) separated from the body by a divider, which plain messages render
as a blank line (telegram-panel render.compose / to_basic_html). Restored
messages show only the body.
Pure Python, so tools/verify_bundle.py can test it offline.
"""
_FIRST_LINE = ("✉️ From:", "✉ From:")
def utf16_len(text):
return len(text.encode("utf-16-le")) // 2
def body_start(text):
"""UTF-16 offset where the body begins, or 0 when there is no header."""
if not text or not text.startswith(_FIRST_LINE):
return 0
cut = text.find("\n\n")
if cut < 0:
return utf16_len(text) # header only (e.g. media without caption)
return utf16_len(text[: cut + 2])
def strip(text, entities):
"""`text` without its header, and `entities` [(offset, length, value)]
moved to match; entities inside the header are dropped."""
start = body_start(text)
if start == 0:
return text, list(entities)
units = text.encode("utf-16-le")
body = units[start * 2 :].decode("utf-16-le")
kept = []
for offset, length, value in entities:
end = offset + length
if end <= start:
continue
new_offset = max(offset, start) - start
kept.append((new_offset, end - max(offset, start), value))
return body, kept
View File
Whitespace-only changes.
+501
View File
@@ -0,0 +1,501 @@
"""Per open chat: sync the loaded window with the server, confirm and report
messages that vanished, and put deleted messages back from the logger.
ChatActivity (12.10.6, probed): updateMessagesVisiblePart(boolean) runs on
every scroll/load; `messages` is the loaded ArrayList<MessageObject>, newest
first; `chatListView`, `chatAdapter`.
The server is asked about a chat only when needed (store.coverage keeps the
range of ids already checked, per chat, for COVERAGE_SECONDS):
- the chat wasn't checked within the hour: everything loaded, from the oldest
loaded message on, and the answer replaces the chat's cached marks;
- history older than the checked range was loaded: that older part only;
- an edit or delete event in the chat (or a deletion this client reported):
the check is expired, so the next look asks again.
Answers hold at most 100 marks and 100 missing ids, newest first; a capped
answer covers down to its oldest mark, the rest is asked for next.
Restored messages go through ChatActivity.processNewMessages, like messages
that just arrived: the app places them and keeps `messagesDict` and its
adapter consistent (inserting into `messages` directly broke later arrivals),
and deleting one removes it the usual way.
"""
import time
from base_plugin import MethodHook
import diag
import header
import peers
import store
import tg
from api import ApiError
from config import COVERAGE_SECONDS, REPORT_DELETIONS, RESTORE_MISSING
from jutil import field, hook_methods, in_background, java_class, java_list, on_ui
CHAT = "org.telegram.ui.ChatActivity"
_DEBOUNCE_MS = 700
# The server logs an edit asynchronously; sync after it had time to.
_EDIT_DELAY_MS = 4000
EDIT_UPDATES = ("TL_updateEditMessage", "TL_updateEditChannelMessage")
def _epoch(iso):
if not iso:
return 0
from datetime import datetime
try:
return int(datetime.fromisoformat(iso).timestamp())
except ValueError:
return 0
_MAX_ID = 2**31 - 1
def _end_reached(fragment, name):
"""ChatActivity.endReached / forwardEndReached: boolean[2], index 0 is
the chat itself (1 is the merged migrated chat)."""
flags = field(fragment, name)
try:
return bool(flags[0])
except Exception:
return False
_process = []
def _process_new_messages():
"""ChatActivity.processNewMessages(ArrayList, boolean) (private)."""
if not _process:
from java.lang import Boolean
method = java_class(CHAT).getDeclaredMethod(
"processNewMessages", java_class("java.util.ArrayList"), Boolean.TYPE
)
method.setAccessible(True)
_process.append(method)
return _process[0]
def _plan(coverage, low, high, now):
"""(low, high, fresh) to ask the server about, or None when the loaded
range [low, high] is already checked."""
if coverage is None or now - coverage[2] >= COVERAGE_SECONDS:
return low, high, True
covered_low, covered_high, _at = coverage
if low < covered_low:
return low, covered_low - 1, False # older history was loaded
if high > covered_high:
return covered_high + 1, high, False # newer messages were loaded
return None
def _cover(account, dialog_id, low, high, fresh):
"""Records [low, high] as checked: a fresh check starts the hour, a
partial one extends the range it belongs to."""
current = store.coverage(account, dialog_id)
if fresh or current is None:
store.set_coverage(account, dialog_id, low, high, time.time())
return
covered_low, covered_high, at = current
store.set_coverage(
account, dialog_id, min(covered_low, low), max(covered_high, high), at
)
def _loaded(fragment, message_id):
"""Whether the app itself has `message_id` in this chat (messagesDict[0])."""
try:
return field(fragment, "messagesDict")[0].get(int(message_id)) is not None
except Exception:
return False
def _android_peer_id(marked):
"""Telethon sender id -> Android peer id (channels are -id)."""
marked = int(marked)
if marked <= -peers.CHANNEL_OFFSET:
return -(-marked - peers.CHANNEL_OFFSET)
return marked
def _strip_rich_header(rich):
"""A rich copy is header paragraphs, a divider, then the body
(telegram-panel render.compose): drop everything up to the divider."""
blocks = rich.blocks
for index in range(min(blocks.size(), 12)):
if blocks.get(index).getClass().getSimpleName() == "pageBlockDivider":
for _ in range(index + 1):
blocks.remove(0)
return
def _rich_text(node):
"""Plain text of a TL_iv RichText tree (textPlain/textConcat/styles...)."""
if node is None:
return ""
value = getattr(node, "text", None)
if isinstance(value, str):
return value
texts = getattr(node, "texts", None)
if texts is not None and hasattr(texts, "size"):
return "".join(_rich_text(t) for t in java_list(texts))
return _rich_text(value)
def _rich_plain_text(rich):
"""The body as plain text when it is only text paragraphs, else None."""
blocks = java_list(rich.blocks)
if not blocks or any(
b.getClass().getSimpleName() != "pageBlockParagraph" for b in blocks
):
return None
return "\n".join(_rich_text(b.text) for b in blocks)
class ChatSync:
def __init__(self, plugin, api):
self.plugin = plugin
self.api = api
self.handles = []
self._scheduled = set()
self._inflight = set() # (account, dialog) with a sync running
self._exists = {} # (account, dialog) -> ids Telegram still has
self._restoring = set() # (account, dialog, id) in flight
self._fragments = {} # (account, dialog) -> last ChatActivity seen
def install(self):
for name in EDIT_UPDATES:
self.plugin.add_hook(name)
sync = self
class AfterVisible(MethodHook):
def after_hooked_method(self, param):
try:
sync.schedule(param.thisObject)
except Exception as exc:
diag.error("chat.schedule", exc)
self.handles += hook_methods(
self.plugin, CHAT, "updateMessagesVisiblePart", AfterVisible()
)
def uninstall(self):
for name in EDIT_UPDATES:
try:
self.plugin.remove_hook(name)
except Exception:
pass
for handle in self.handles:
try:
self.plugin.unhook_method(handle)
except Exception:
pass
self.handles = []
# collecting the window (UI thread)
def schedule(self, fragment):
if not self.api.configured:
return
guid = int(fragment.getClassGuid())
if guid in self._scheduled:
return
self._scheduled.add(guid)
on_ui(lambda: self._collect(fragment, guid), _DEBOUNCE_MS)
def _collect(self, fragment, guid):
self._scheduled.discard(guid)
self.plugin.purge_cache() # once a day
account = int(fragment.getCurrentAccount())
dialog_id = int(fragment.getDialogId())
chat_id = peers.marked_chat_id(account, dialog_id)
if chat_id is None:
return
key = (account, dialog_id)
self._fragments[key] = fragment
if key in self._inflight:
return
ids = [
int(m.getId())
for m in java_list(field(fragment, "messages"))
if m is not None and int(m.getId()) > 0
]
if not ids:
return
# The oldest loaded message bounds every request; an end of the chat
# that is loaded is open (deleted past it still belongs to it).
low = 1 if _end_reached(fragment, "endReached") else min(ids)
high = _MAX_ID if _end_reached(fragment, "forwardEndReached") else max(ids)
request = _plan(store.coverage(account, dialog_id), low, high, time.time())
if request is None:
# Checked within the hour: deleted messages come back from the
# cached answers (they were never in the app's own database).
if self.plugin.get_setting(RESTORE_MISSING, True):
present = set(ids)
gone = [
s
for s in store.cached_deleted(account, dialog_id, low, high)
if 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
present = {i for i in ids if request[0] <= i <= request[1]}
self._inflight.add(key)
in_background(
lambda: self._sync(fragment, account, dialog_id, chat_id, request, present)
)
# server round trip (background)
def _sync(self, fragment, account, dialog_id, chat_id, request, present):
key = (account, dialog_id)
try:
self._sync_range(fragment, account, dialog_id, chat_id, request, present)
finally:
self._inflight.discard(key)
def _sync_range(self, fragment, account, dialog_id, chat_id, request, present):
low, high, fresh = request
me = peers.own_user_id(account)
started = time.monotonic()
try:
result = self.api.sync(me, chat_id, low, high, sorted(present))
except ApiError as exc:
diag.log(f"sync {chat_id}: {exc}")
return
if not result:
return
states = result.get("states") or []
capped = len(states) >= 100
diag.log(
f"sync {chat_id} [{low}..{high}]{' fresh' if fresh else ''} "
f"loaded={len(present)} marks={len(states)} "
f"missing={len(result.get('missing') or [])} "
f"{int((time.monotonic() - started) * 1000)}ms"
)
dropped = store.replace_window(
account, dialog_id, low, high, states, capped, full=fresh
)
# A capped answer is complete from its oldest mark up only.
covered_low = max(low, min(int(s["message_id"]) for s in states)) if capped else low
_cover(account, dialog_id, covered_low, high, fresh)
changed = {int(s["message_id"]) for s in states} | set(dropped)
if changed:
on_ui(lambda: self.refresh(fragment, changed))
known = self._exists.setdefault((account, dialog_id), set())
missing = [int(i) for i in result.get("missing") or [] if int(i) not in known]
if missing:
self._confirm(fragment, account, dialog_id, chat_id, me, missing[:100])
if self.plugin.get_setting(RESTORE_MISSING, True):
gone = [
s
for s in states
if s.get("deleted_at") and int(s["message_id"]) not in present
]
if gone:
self._restore(fragment, account, dialog_id, chat_id, me, gone)
def _confirm(self, fragment, account, dialog_id, chat_id, me, ids):
"""Ask Telegram about ids the server has and the client lacks; those
it no longer has were deleted while we were away."""
from org.telegram.tgnet import TLRPC
def done(found, error):
if found is None:
diag.log(f"confirm {chat_id}: {error}")
return
gone = [
i
for i in ids
if found.get(i) is None or isinstance(found[i], TLRPC.TL_messageEmpty)
]
self._exists.setdefault((account, dialog_id), set()).update(
set(ids) - set(gone)
)
if not gone:
return
diag.log(f"confirmed deleted in {chat_id}: {gone}")
store.mark_deleted(account, dialog_id, gone)
if self.plugin.get_setting(REPORT_DELETIONS, True):
in_background(lambda: self._report(account, dialog_id, chat_id, me, gone))
tg.get_messages(account, dialog_id, ids, done)
def _report(self, account, dialog_id, chat_id, me, ids):
try:
marked = self.api.deletions(me, chat_id, ids)
diag.log(f"reported {len(ids)} deletions in {chat_id}, marked {marked}")
except ApiError as exc:
diag.log(f"report {chat_id}: {exc}")
return
# Sync again for the server's new state (and the logged copies).
self.changed(account, dialog_id)
# restoring (background -> UI)
def _restore(self, fragment, account, dialog_id, chat_id, me, states):
for state in states:
message_id = int(state["message_id"])
key = (account, dialog_id, message_id)
if key in self._restoring:
continue
pointer, strip = self._source(me, chat_id, message_id, state)
if pointer is None:
continue
self._restoring.add(key)
self._fetch_copy(fragment, account, dialog_id, message_id, state, pointer, strip)
def _source(self, me, chat_id, message_id, state):
"""Where to read the deleted message from: its last version (no
header) when it was edited, else the saved copy (header stripped)."""
if int(state.get("edit_count") or 0) > 0:
try:
versions = self.api.versions(me, chat_id, message_id)
except ApiError as exc:
diag.log(f"versions {chat_id}/{message_id}: {exc}")
versions = None
if versions and versions.get("chat_id") and versions.get("versions"):
last = versions["versions"][-1]
return {"chat_id": versions["chat_id"], "message_id": last["message_id"]}, False
return state.get("logged"), True
def _fetch_copy(self, fragment, account, dialog_id, message_id, state, pointer, strip):
channel_id = peers.channel_id_from_marked(pointer["chat_id"])
copy_id = int(pointer["message_id"])
def done(found, error):
copy = (found or {}).get(copy_id)
if copy is None or getattr(copy, "message", None) is None and getattr(copy, "media", None) is None:
self._restoring.discard((account, dialog_id, message_id))
diag.log(f"copy {pointer} unavailable: {error}")
return
on_ui(lambda: self._insert(fragment, account, dialog_id, message_id, state, copy, strip))
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 is not None:
# 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
own dicts and adapter consistent."""
self._restoring.discard((account, dialog_id, message_id))
messages = field(fragment, "messages")
if messages is None:
return
if _loaded(fragment, message_id) or any(
m is not None and int(m.getId()) == message_id for m in java_list(messages)
):
return
from java import jboolean
from java.util import ArrayList
obj = self._build(account, dialog_id, message_id, state, copy, strip)
store.mark_deleted(account, dialog_id, [message_id])
batch = ArrayList()
batch.add(obj)
_process_new_messages().invoke(fragment, batch, jboolean(False))
diag.log(f"restored {dialog_id}/{message_id}")
# cells (UI thread)
def refresh(self, fragment, ids):
"""Rebind the rows of `ids`: their alpha or pill (and so their height)
changed, which only a relayout of the list places correctly."""
messages = field(fragment, "messages")
adapter = field(fragment, "chatAdapter")
if messages is None or adapter is None:
return
# A rebind alone reuses the cached measure: flag the cells for layout.
list_view = field(fragment, "chatListView")
if list_view is not None:
for i in range(list_view.getChildCount()):
child = list_view.getChildAt(i)
message = child.getMessageObject() if hasattr(child, "getMessageObject") else None
if message is not None and int(message.getId()) in ids:
child.forceLayout()
start = int(adapter.messagesStartRow)
for index, m in enumerate(java_list(messages)):
if m is not None and int(m.getId()) in ids:
m.forceUpdate = True # rebinding lays the cell out again (time row)
adapter.notifyItemChanged(start + index)
def on_edited(self, account, message):
"""An edit arrived: sync that chat again once the server logged it."""
from org.telegram.messenger import MessageObject
self.changed(
int(account), int(MessageObject.getDialogId(message)), _EDIT_DELAY_MS
)
def changed(self, account, dialog_id, delay_ms=0):
"""An edit/delete event in a chat: its check is expired, and the chat
synced again (after `delay_ms`) when it is open."""
store.expire_coverage(account, dialog_id)
fragment = self._fragments.get((account, dialog_id))
if fragment is not None:
on_ui(lambda: self.schedule(fragment), delay_ms)
def refresh_dialog(self, account, dialog_id, ids):
fragment = self._fragments.get((account, dialog_id))
if fragment is not None:
on_ui(lambda: self.refresh(fragment, set(int(i) for i in ids)))
+145
View File
@@ -0,0 +1,145 @@
"""Keeps messages others delete: the delete updates are dropped (the message
stays in the app's cache and chat) and the ids are remembered as deleted.
Deletions the user makes here go through MessagesController.deleteMessages
(both overloads start `(messages, randoms, encryptedChat, dialogId, ...)`);
those ids are let through when their echo update arrives.
TL_updateDeleteMessages (private chats, basic groups) carries no chat: ids
are per account, so the dialog is looked up in the app's own database on the
storage queue.
"""
from base_plugin import HookResult, HookStrategy, MethodHook
import diag
import peers
import store
from api import ApiError
from config import KEEP_DELETED, REPORT_DELETIONS
from jutil import field, hook_methods, in_background, java_ints
UPDATES = ("TL_updateDeleteMessages", "TL_updateDeleteChannelMessages")
class Deletes:
def __init__(self, plugin, api, chats):
self.plugin = plugin
self.api = api
self.chats = chats
self.handles = []
def install(self):
for name in UPDATES:
self.plugin.add_hook(name)
deletes = self
class BeforeDelete(MethodHook):
def before_hooked_method(self, param):
try:
deletes.on_own_delete(param)
except Exception as exc:
diag.error("deletes.own", exc)
self.handles += hook_methods(
self.plugin,
"org.telegram.messenger.MessagesController",
"deleteMessages",
BeforeDelete(),
)
def uninstall(self):
for name in UPDATES:
try:
self.plugin.remove_hook(name)
except Exception:
pass
for handle in self.handles:
try:
self.plugin.unhook_method(handle)
except Exception:
pass
self.handles = []
def on_own_delete(self, param):
args = list(param.args or [])
if len(args) < 4:
return
ids = [i for i in java_ints(args[0]) if i > 0]
if not ids:
return
account = int(field(param.thisObject, "currentAccount") or 0)
dialog_id = int(args[3] or 0)
store.note_own_deletion(account, ids)
store.forget_deleted(account, dialog_id, ids)
def on_update(self, name, account, update):
ids = java_ints(update.messages)
others = [i for i in ids if not store.is_own_deletion(account, i)]
if not others:
return HookResult()
if name == "TL_updateDeleteChannelMessages":
self._deleted(account, -int(update.channel_id), others)
else:
self._resolve_dialogs(account, others)
if not self.plugin.get_setting(KEEP_DELETED, True):
return HookResult()
diag.log(f"kept {name} {others}")
if len(others) == len(ids):
return HookResult(strategy=HookStrategy.CANCEL)
from java import jint
from java.util import ArrayList
own = ArrayList()
for i in ids:
if i not in others:
own.add(jint(i)) # a plain int would be boxed as Long
update.messages = own
return HookResult(strategy=HookStrategy.MODIFY, update=update)
def _deleted(self, account, dialog_id, ids):
store.mark_deleted(account, dialog_id, ids)
self.chats.refresh_dialog(account, dialog_id, ids)
# Check the chat again once the server had the report.
self.chats.changed(account, dialog_id, 3000)
if self.plugin.get_setting(REPORT_DELETIONS, True) and self.api.configured:
in_background(lambda: self._report(account, dialog_id, ids))
def _report(self, account, dialog_id, ids):
chat_id = peers.marked_chat_id(account, dialog_id)
if chat_id is None:
return
try:
self.api.deletions(peers.own_user_id(account), chat_id, ids)
except ApiError as exc:
diag.log(f"report {chat_id}: {exc}")
def _resolve_dialogs(self, account, ids):
"""Dialogs of per-account message ids, from the app's database."""
from java import dynamic_proxy
from java.lang import Runnable
from org.telegram.messenger import MessagesStorage
storage = MessagesStorage.getInstance(account)
deletes = self
class Lookup(dynamic_proxy(Runnable)):
def run(self):
try:
found = {}
query = "SELECT mid, uid FROM messages_v2 WHERE mid IN (%s)" % (
",".join(str(int(i)) for i in ids)
)
cursor = storage.getDatabase().queryFinalized(query)
while cursor.next():
uid = int(cursor.longValue(1))
if uid < 0 and peers.is_channel_dialog(account, uid):
continue # channel ids are numbered per channel
found.setdefault(uid, []).append(int(cursor.intValue(0)))
cursor.dispose()
for dialog_id, mids in found.items():
deletes._deleted(account, dialog_id, mids)
except Exception as exc:
diag.error("deletes.resolve", exc)
storage.getStorageQueue().postRunnable(Lookup())
+112
View File
@@ -0,0 +1,112 @@
"""Message links into chats the current account isn't in (the logger's
"Message" links, mostly) open as a signed-in account that is (see
views/open_as.py).
Browser.openUrl (all overloads, Uri or String) is where tapped links go.
"""
import re
from base_plugin import MethodHook
import accounts
import diag
from jutil import hook_methods, on_ui
from views import open_as
BROWSER = "org.telegram.messenger.browser.Browser"
_PRIVATE = re.compile(
r"^(?:https?://)?(?:www\.)?(?:t|telegram)\.me/c/(\d+)/(?:(\d+)/)?(\d+)", re.I
)
_PUBLIC = re.compile(
r"^(?:https?://)?(?:www\.)?(?:t|telegram)\.me/([A-Za-z0-9_]{4,32})/(\d+)(?:[/?#]|$)",
re.I,
)
_TG_PRIVATE = re.compile(r"^tg://privatepost\?(.*)$", re.I)
_TG_RESOLVE = re.compile(r"^tg://resolve\?(.*)$", re.I)
def _query(text):
out = {}
for part in text.split("&"):
key, _, value = part.partition("=")
out[key] = value
return out
def parse(url):
"""(dialog id, message id) of a message link, else None."""
url = url.strip()
match = _PRIVATE.match(url)
if match:
return -int(match.group(1)), int(match.group(3))
match = _TG_PRIVATE.match(url)
if match:
q = _query(match.group(1))
if q.get("channel", "").isdigit() and q.get("post", "").isdigit():
return -int(q["channel"]), int(q["post"])
return None
username, post = None, None
match = _PUBLIC.match(url)
if match:
username, post = match.group(1), match.group(2)
else:
match = _TG_RESOLVE.match(url)
if match:
q = _query(match.group(1))
username, post = q.get("domain"), q.get("post")
if not username or not post or not post.isdigit():
return None
dialog_id = accounts.resolve_username(username)
return (dialog_id, int(post)) if dialog_id is not None else None
class Links:
def __init__(self, plugin):
self.plugin = plugin
self.handles = []
def install(self):
links = self
class BeforeOpen(MethodHook):
def before_hooked_method(self, param):
try:
if links.open(param.args[1]):
param.setResult(None)
except Exception as exc:
diag.error("links.open", exc)
self.handles += hook_methods(self.plugin, BROWSER, "openUrl", BeforeOpen())
def uninstall(self):
for handle in self.handles:
try:
self.plugin.unhook_method(handle)
except Exception:
pass
self.handles = []
def open(self, uri):
"""Opens the link as another account when needed; True if handled."""
if uri is None:
return False
target = parse(str(uri))
if target is None:
return False
dialog_id, message_id = target
from client_utils import get_last_fragment
fragment = get_last_fragment()
if fragment is None:
return False
current = int(fragment.getCurrentAccount())
if accounts.has_access(current, dialog_id):
return False
others = [s for s in accounts.with_access(dialog_id) if s != current]
if not others:
return False
slot = others[0]
on_ui(lambda: open_as.open_chat(fragment, slot, dialog_id, message_id))
return True
+129
View File
@@ -0,0 +1,129 @@
""""Edit history" 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
the row is added to the built popup instead: after ChatActivity.createMenu,
an ActionBarMenuSubItem styled like the Forward row (found by its localized
text) is inserted above it, or first in the list when Forward is an icon of
the bottom action bar (groups). ChatActivity.selectedObject is the message.
"""
from base_plugin import MethodHook
import diag
import store
from jutil import field, hook_methods, java_class
CHAT = "org.telegram.ui.ChatActivity"
_LABEL = "Edit history"
def _worth_it(message):
"""Messages that may have a logged history: edited ones (the app's own
flag or the server's state), which logger posts are once their header
was refreshed."""
if message is None or int(message.getId()) <= 0:
return False
if int(getattr(message.messageOwner, "edit_date", 0) or 0):
return True
return store.is_edited(
int(message.currentAccount), int(message.getDialogId()), int(message.getId())
)
def _first_item(view):
"""The first ActionBarMenuSubItem under `view` (the main page comes
before the swipe-back pages), else None."""
if view.getClass().getSimpleName() == "ActionBarMenuSubItem":
return view
if hasattr(view, "getChildCount"):
for index in range(view.getChildCount()):
found = _first_item(view.getChildAt(index))
if found is not None:
return found
return None
def _find_item(view, text):
"""The ActionBarMenuSubItem showing `text` under `view`, else None."""
if view.getClass().getSimpleName() == "ActionBarMenuSubItem":
label = view.getTextView()
return view if label is not None and str(label.getText()) == text else None
if hasattr(view, "getChildCount"):
for index in range(view.getChildCount()):
found = _find_item(view.getChildAt(index), text)
if found is not None:
return found
return None
class Menu:
def __init__(self, plugin, open_history):
self.plugin = plugin
self.open_history = open_history
self.handles = []
self._close = None
def install(self):
menu = self
class AfterCreate(MethodHook):
def after_hooked_method(self, param):
try:
menu.add_item(param.thisObject)
except Exception as exc:
diag.error("menu.add", exc)
self.handles += hook_methods(self.plugin, CHAT, "createMenu", AfterCreate())
def uninstall(self):
for handle in self.handles:
try:
self.plugin.unhook_method(handle)
except Exception:
pass
self.handles = []
def add_item(self, chat):
message = field(chat, "selectedObject")
popup = field(chat, "scrimPopupWindow")
if popup is None or not _worth_it(message):
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
content = popup.getContentView()
# 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.
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")
return
forward = anchor
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()
item.setColors(color, color)
item.setMinimumWidth(forward.getMinimumWidth())
def chosen(_view):
self._close_menu(chat)
self.open_history(message)
item.setOnClickListener(OnClickListener(chosen))
parent.addView(item, parent.indexOfChild(forward), forward.getLayoutParams())
def _close_menu(self, chat):
if self._close is None:
method = java_class(CHAT).getDeclaredMethod("closeMenu")
method.setAccessible(True)
self._close = method
self._close.invoke(chat)
+176
View File
@@ -0,0 +1,176 @@
"""Chat cell decoration of deleted messages: translucent, with a red trash icon
(from the active icon pack) in the time row like the app's own "edited" pencil.
ChatMessageCell (exteraGram 12.10.6, probed on device): setMessageObject
(5 and 6 args), setAlpha(float), measureTime(MessageObject), and the private
`currentTimeString` (SpannableStringBuilder), `timeTextWidth`, `timeWidth`,
`timeLayout`. The pencil is a ColoredImageSpan on a leading "\\u200d " whose
width (Layout.getDesiredWidth with Theme.chat_timePaint) the time widths
include; the trash icon is added the same way, before the bubble is laid out.
"""
from base_plugin import MethodHook
import diag
import store
from config import ALPHA_CHOICES, DELETED_ALPHA
from jutil import field, hook_methods
CELL = "org.telegram.ui.Cells.ChatMessageCell"
_ICON = "\u200d "
def _dp(value):
from org.telegram.messenger import AndroidUtilities
return AndroidUtilities.dp(value)
def _message(cell):
try:
return cell.getMessageObject()
except Exception:
return None
def _key(message):
"""(account, dialog_id, message_id) of a real message, else None."""
if message is None:
return None
message_id = int(message.getId())
if message_id <= 0:
return None
return int(message.currentAccount), int(message.getDialogId()), message_id
def _deleted(message):
key = _key(message)
return key is not None and store.is_deleted(*key)
def _trash_drawable(res_id):
"""The trash icon of the active icon pack: the activity's resources are
exteraGram's ExteraResources, which apply it (the application context's
plain Resources give the stock icon)."""
from client_utils import get_last_fragment
fragment = get_last_fragment()
activity = fragment.getParentActivity() if fragment is not None else None
if activity is not None:
resources = activity.getResources()
else:
from org.telegram.messenger import ApplicationLoader
resources = ApplicationLoader.applicationContext.getResources()
return resources.getDrawable(res_id, None).mutate()
def _set_int(cell, name, value):
from hook_utils import set_private_field
from java import jint
set_private_field(cell, name, jint(int(value)))
class Render:
def __init__(self, plugin):
self.plugin = plugin
self.handles = []
def _alpha(self):
index = int(self.plugin.get_setting(DELETED_ALPHA, 0) or 0)
return ALPHA_CHOICES[max(0, min(index, len(ALPHA_CHOICES) - 1))][1]
def install(self):
plugin = self.plugin
render = self
class AfterBind(MethodHook):
def after_hooked_method(self, param):
try:
render.apply_alpha(param.thisObject)
except Exception as exc:
diag.error("render.bind", exc)
class BeforeAlpha(MethodHook):
def before_hooked_method(self, param):
try:
render.scale_alpha(param)
except Exception as exc:
diag.error("render.alpha", exc)
class AfterMeasureTime(MethodHook):
def after_hooked_method(self, param):
try:
render.add_trash(param.thisObject, param.args[0])
except Exception as exc:
diag.error("render.time", exc)
self.handles += hook_methods(plugin, CELL, "setMessageObject", AfterBind())
self.handles += hook_methods(plugin, CELL, "setAlpha", BeforeAlpha())
self.handles += hook_methods(plugin, CELL, "measureTime", AfterMeasureTime())
def uninstall(self):
for handle in self.handles:
try:
self.plugin.unhook_method(handle)
except Exception:
pass
self.handles = []
# opacity
def apply_alpha(self, cell):
"""Re-applies the cell's alpha through setAlpha (scaled below)."""
cell.setAlpha(1.0)
def scale_alpha(self, param):
"""Deleted messages stay translucent whatever alpha the list's item
animators set: every setAlpha on their cell is scaled."""
if not _deleted(_message(param.thisObject)):
return
from java import jfloat
param.args[0] = jfloat(float(param.args[0]) * self._alpha())
# trash icon
def add_trash(self, cell, message):
if not _deleted(message):
return
# measureTime builds the string afresh: the icon goes in once, before
# the edited pencil if there is one.
text = field(cell, "currentTimeString")
if text is None:
return
from android.text import Layout, Spanned, StaticLayout
from org.telegram.messenger import R
from org.telegram.ui.ActionBar import Theme
from org.telegram.ui.Components import ColoredImageSpan
paint = Theme.chat_timePaint
before = Layout.getDesiredWidth(text, paint)
from java import jint
span = ColoredImageSpan(_trash_drawable(R.drawable.msg_delete))
span.setSize(_dp(13))
span.setTranslateX(-3.0)
span.setOverrideColor(jint(Theme.getColor(Theme.key_text_RedBold)))
text.insert(0, _ICON)
text.setSpan(span, 0, 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
added = int(Layout.getDesiredWidth(text, paint) - before + 0.999)
_set_int(cell, "timeTextWidth", int(field(cell, "timeTextWidth") or 0) + added)
_set_int(cell, "timeWidth", int(field(cell, "timeWidth") or 0) + added)
if field(cell, "timeLayout") is not None:
from hook_utils import set_private_field
layout = StaticLayout(
text,
paint,
int(field(cell, "timeTextWidth")) + _dp(100),
Layout.Alignment.ALIGN_NORMAL,
1.0,
0.0,
False,
)
set_private_field(cell, "timeLayout", layout)
+92
View File
@@ -0,0 +1,92 @@
"""Chaquopy/Java helpers shared by the hooks."""
import diag
def java_list(items):
"""A java.util.List as a Python list (Java lists aren't iterable here)."""
if items is None:
return []
return [items.get(i) for i in range(items.size())]
def java_ints(items):
return [int(v) for v in java_list(items)]
def java_class(name):
"""The real java.lang.Class: find_class returns a Chaquopy wrapper whose
getClass() is the Class (see chatSummaries/hooks/unread_hook.py)."""
from hook_utils import find_class
wrapper = find_class(name)
if wrapper is None:
return None
try:
return wrapper.getClass()
except Exception:
return wrapper
def methods_named(class_name, name):
cls = java_class(class_name)
if cls is None:
diag.log(f"class not found: {class_name}")
return []
return [m for m in cls.getDeclaredMethods() if m.getName() == name]
def hook_methods(plugin, class_name, name, hook, arity=None):
"""Hooks every overload of `class_name.name` (or those with `arity`
parameters); returns the unhook handles."""
handles = []
for method in methods_named(class_name, name):
if arity is not None and len(method.getParameterTypes()) != arity:
continue
try:
handle = plugin.hook_method(method, hook)
if handle is not None:
handles.append(handle)
except Exception as exc:
diag.error(f"hook {class_name}.{name}", exc)
if not handles:
diag.log(f"nothing hooked for {class_name}.{name}")
return handles
def field(obj, name):
"""A (possibly private) field of a Java object."""
value = getattr(obj, name, None)
if value is not None:
return value
try:
from hook_utils import get_private_field
return get_private_field(obj, name)
except Exception:
return None
def on_ui(fn, delay_ms=0):
from android_utils import run_on_ui_thread
def run():
try:
fn()
except Exception as exc:
diag.error("ui task", exc)
run_on_ui_thread(run, delay_ms)
def in_background(fn):
"""Runs `fn` off the UI thread; exceptions are logged, not lost."""
import threading
def run():
try:
fn()
except Exception as exc:
diag.error("background task", exc)
threading.Thread(target=run, daemon=True).start()
+146 -37
View File
@@ -1,50 +1,159 @@
from base_plugin import BasePlugin, HookResult, HookStrategy
from android_utils import log
from ui.settings import Header, Input, Text
from greeting import render, TemplateError
from base_plugin import BasePlugin, HookResult
from ui.settings import Header, Input, Selector, Switch, Text
DEFAULT_TEMPLATE = "Hello, {name}!"
import accounts
import diag
from api import Api
from config import (
ALPHA_CHOICES,
API_TOKEN,
DEFAULTS,
CACHE_DAYS,
CACHE_DAYS_CHOICES,
DELETED_ALPHA,
KEEP_DELETED,
REPORT_DELETIONS,
RESTORE_MISSING,
SERVER_URL,
)
from hooks.chat import EDIT_UPDATES, ChatSync
from hooks.deletes import UPDATES, Deletes
from hooks.links import Links
from hooks.menu import Menu
from hooks.render import Render
from jutil import in_background, on_ui
from views.history import History
class HelloPlugin(BasePlugin):
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)
class MessageLoggerPlugin(BasePlugin):
def on_plugin_load(self):
log("hello_bundle loaded")
self.add_on_send_message_hook()
self.api = Api(self)
self.history = History(self, self.api)
self.render = Render(self)
self.chats = ChatSync(self, self.api)
self.deletes = Deletes(self, self.api, self.chats)
self.links = Links(self)
self.menu = Menu(self, self.history.open)
for part in (self.render, self.chats, self.deletes, self.links, self.menu):
try:
part.install()
except Exception as exc:
diag.error(f"install {type(part).__name__}", exc)
in_background(accounts.refresh)
in_background(self.purge_cache)
diag.log("loaded")
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`."""
import store
index = int(self.get_setting(CACHE_DAYS, DEFAULTS[CACHE_DAYS]) or 0)
index = max(0, min(index, len(CACHE_DAYS_CHOICES) - 1))
store.purge_old(CACHE_DAYS_CHOICES[index][1], force=force)
def on_plugin_unload(self):
log("hello_bundle unloaded")
for part in ("render", "chats", "deletes", "links", "menu"):
if hasattr(self, part):
getattr(self, part).uninstall()
diag.log("unloaded")
def on_update_hook(self, update_name, account, update):
if update_name in EDIT_UPDATES:
try:
self.chats.on_edited(account, update.message)
except Exception as exc:
diag.error("on_update_hook edit", exc)
return HookResult()
if update_name in UPDATES:
try:
return self.deletes.on_update(update_name, account, update)
except Exception as exc:
diag.error("on_update_hook", exc)
return HookResult()
# settings
def _test_connection(self, _view=None):
def run():
if not self.api.configured:
_bulletin("Message Logger: set the server URL and API tokens", True)
return
lines, failed = [], False
for prefix, me in self.api.me():
if isinstance(me, dict):
lines.append(f"{me.get('master_name')} ✓")
else:
failed = True
lines.append(f"{prefix}…: {me}")
_bulletin("Masters: " + "; ".join(lines), failed)
in_background(run)
def create_settings(self):
return [
Header(text="Hello Bundle"),
Header(text="Server"),
Input(
key="template",
text="Greeting template",
default=DEFAULT_TEMPLATE,
subtext="Use {name} as placeholder.",
icon="msg_edit",
key=SERVER_URL,
text="telegram-panel URL",
default=DEFAULTS[SERVER_URL],
subtext="e.g. https://panel.example.com",
icon="msg_link",
),
Text(
text="Example",
subtext=".hello alice smith -> Hello, Alice Smith!",
icon="msg_info",
Input(
key=API_TOKEN,
text="API tokens",
default=DEFAULTS[API_TOKEN],
subtext="One per master, separated by spaces (panel: Settings → API Tokens)",
icon="msg_permissions",
),
Text(text="Test connection", icon="msg_info", on_click=self._test_connection),
Header(text="Deleted messages"),
Switch(
key=KEEP_DELETED,
text="Keep deleted messages",
default=DEFAULTS[KEEP_DELETED],
subtext="Messages others delete stay in the chat",
icon="msg_delete",
),
Switch(
key=RESTORE_MISSING,
text="Restore from logger",
default=DEFAULTS[RESTORE_MISSING],
subtext="Bring back logged messages deleted while the app was closed",
icon="msg_retry",
),
Switch(
key=REPORT_DELETIONS,
text="Report deletions",
default=DEFAULTS[REPORT_DELETIONS],
subtext="Tell the server about deletions this client sees",
icon="msg_sync",
),
Selector(
key=CACHE_DAYS,
text="Cache lifetime",
default=DEFAULTS[CACHE_DAYS],
items=[label for label, _ in CACHE_DAYS_CHOICES],
icon="msg_clear",
on_change=lambda _index: in_background(
lambda: self.purge_cache(force=True)
),
),
Selector(
key=DELETED_ALPHA,
text="Deleted message opacity",
default=DEFAULTS[DELETED_ALPHA],
items=[label for label, _ in ALPHA_CHOICES],
icon="msg_hide",
),
]
def on_send_message_hook(self, account, params):
message = getattr(params, "message", None)
if not isinstance(message, str):
return HookResult()
stripped = message.strip()
if not stripped.startswith(".hello"):
return HookResult()
parts = stripped.split(" ", 1)
name = parts[1] if len(parts) > 1 else ""
if not name.strip():
params.message = "Usage: .hello <name>"
else:
try:
params.message = render(self.get_setting("template", DEFAULT_TEMPLATE), name)
except TemplateError as exc:
params.message = "Bad template: " + str(exc)
return HookResult(strategy=HookStrategy.MODIFY, params=params)
+47
View File
@@ -0,0 +1,47 @@
"""Telegram Android dialog ids <-> the server's marked (Telethon) chat ids.
Android: users are positive, basic groups and channels both `-id`.
Telethon: users positive, basic groups `-id`, channels `-(10**12 + id)`.
"""
CHANNEL_OFFSET = 10**12
def _controller(account):
from org.telegram.messenger import MessagesController
return MessagesController.getInstance(account)
def is_channel_dialog(account, dialog_id):
if dialog_id >= 0:
return False
from org.telegram.messenger import ChatObject
chat = _controller(account).getChat(-dialog_id)
return chat is not None and ChatObject.isChannel(chat)
def marked_chat_id(account, dialog_id):
"""The server's chat id for a dialog, or None (secret chats etc.)."""
from org.telegram.messenger import DialogObject
dialog_id = int(dialog_id)
if DialogObject.isEncryptedDialog(dialog_id):
return None
if dialog_id > 0:
return dialog_id
if is_channel_dialog(account, dialog_id):
return -(CHANNEL_OFFSET + -dialog_id)
return dialog_id
def channel_id_from_marked(chat_id):
"""Channel id of a marked `-100…` chat id."""
return -int(chat_id) - CHANNEL_OFFSET
def own_user_id(account):
from org.telegram.messenger import UserConfig
return int(UserConfig.getInstance(account).getClientUserId())
+339
View File
@@ -0,0 +1,339 @@
"""What the plugin knows per message: server state (edits, deletion, copy
pointers) 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
message the plugin kept on screen still looks deleted after a restart.
Per chat the range of message ids already checked with the server (and
when) is kept too (`coverage`): see hooks/chat.py for when it is extended,
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`).
"""
import json
import os
import sqlite3
import threading
import time
import diag
class State:
__slots__ = (
"edit_count",
"deleted",
"logged",
"history",
"source_date",
"sender_id",
"raw",
)
def __init__(self):
self.edit_count = 0
self.deleted = False
self.logged = None # {"chat_id", "message_id", "topic_id"}
self.history = None
self.source_date = None # ISO string
self.sender_id = None
self.raw = None # the server's answer for it
@classmethod
def from_server(cls, data):
state = cls()
state.edit_count = int(data.get("edit_count") or 0)
state.deleted = data.get("deleted_at") is not None
state.logged = data.get("logged")
state.history = data.get("history")
state.source_date = data.get("source_date")
state.sender_id = data.get("sender_id")
state.raw = data
return state
_lock = threading.RLock()
# (account, dialog_id) -> {message_id: State}
_states = {}
# (account, dialog_id) -> set of message ids deleted while we watched
_local = {}
_loaded = set()
# (account, message_id) -> monotonic time the user deleted it here
_own = {}
# (account, dialog_id) -> (low, high, checked_at)
_coverage = {}
_purged_on = None # local date of the last age purge
_db = None
_OWN_TTL = 120
def _conn():
global _db
if _db is None:
from file_utils import get_files_dir
directory = os.path.join(get_files_dir(), "message_logger")
os.makedirs(directory, exist_ok=True)
_db = sqlite3.connect(
os.path.join(directory, "state.db"), check_same_thread=False
)
_db.execute(
"CREATE TABLE IF NOT EXISTS deleted ("
"account INTEGER, dialog_id INTEGER, message_id INTEGER, at REAL, "
"PRIMARY KEY (account, dialog_id, message_id))"
)
_db.execute(
"CREATE TABLE IF NOT EXISTS states ("
"account INTEGER, dialog_id INTEGER, message_id INTEGER, data TEXT, "
"at REAL, PRIMARY KEY (account, dialog_id, message_id))"
)
_db.execute("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)")
_db.execute(
"CREATE TABLE IF NOT EXISTS coverage ("
"account INTEGER, dialog_id INTEGER, low INTEGER, high INTEGER, at REAL, "
"PRIMARY KEY (account, dialog_id))"
)
_db.commit()
return _db
def _local_ids(account, dialog_id):
"""Local deletions of a chat; its cached states are loaded alongside."""
key = (account, dialog_id)
if key not in _loaded:
_loaded.add(key)
try:
db = _conn()
rows = db.execute(
"SELECT message_id FROM deleted WHERE account=? AND dialog_id=?",
key,
).fetchall()
_local.setdefault(key, set()).update(r[0] for r in rows)
states = _states.setdefault(key, {})
for message_id, data in db.execute(
"SELECT message_id, data FROM states WHERE account=? AND dialog_id=?",
key,
).fetchall():
states.setdefault(message_id, State.from_server(json.loads(data)))
row = db.execute(
"SELECT low, high, at FROM coverage WHERE account=? AND dialog_id=?",
key,
).fetchone()
if row is not None:
_coverage.setdefault(key, tuple(row))
except Exception as exc:
diag.error("store.load", exc)
return _local.setdefault(key, set())
def get(account, dialog_id, message_id):
with _lock:
local = _local_ids(account, dialog_id) # loads the chat's cache
state = _states.get((account, dialog_id), {}).get(message_id)
if message_id in local:
if state is None:
state = State()
_states.setdefault((account, dialog_id), {})[message_id] = state
state.deleted = True
return state
def is_deleted(account, dialog_id, message_id):
state = get(account, dialog_id, message_id)
return state is not None and state.deleted
def is_edited(account, dialog_id, message_id):
state = get(account, dialog_id, message_id)
return state is not None and state.edit_count > 0
def put_server_states(account, dialog_id, items):
"""Server state of messages (sync, lookups), kept and cached."""
if not items:
return
with _lock:
_local_ids(account, dialog_id) # load the chat's cache first
states = _states.setdefault((account, dialog_id), {})
for data in items:
states[int(data["message_id"])] = State.from_server(data)
now = time.time()
try:
db = _conn()
db.executemany(
"INSERT OR REPLACE INTO states VALUES (?, ?, ?, ?, ?)",
[
(account, dialog_id, int(d["message_id"]), json.dumps(d), now)
for d in items
],
)
db.commit()
except Exception as exc:
diag.error("store.put_server_states", exc)
def cached_deleted(account, dialog_id, low, high):
"""Cached server answers of deleted messages with a logged copy in
[low, high] (what a restore needs, without asking the server again)."""
with _lock:
_local_ids(account, dialog_id)
return [
state.raw
for message_id, state in _states.get((account, dialog_id), {}).items()
if low <= message_id <= high
and state.raw is not None
and state.raw.get("deleted_at")
and state.raw.get("logged")
]
def coverage(account, dialog_id):
"""(low, high, checked_at) of the ids already checked, else None."""
with _lock:
_local_ids(account, dialog_id)
return _coverage.get((account, dialog_id))
def set_coverage(account, dialog_id, low, high, at):
with _lock:
_coverage[(account, dialog_id)] = (int(low), int(high), float(at))
try:
db = _conn()
db.execute(
"INSERT OR REPLACE INTO coverage VALUES (?, ?, ?, ?, ?)",
(account, dialog_id, int(low), int(high), float(at)),
)
db.commit()
except Exception as exc:
diag.error("store.set_coverage", exc)
def expire_coverage(account, dialog_id):
"""An edit/delete happened in the chat: check it again on next look."""
current = coverage(account, dialog_id)
if current is not None:
set_coverage(account, dialog_id, current[0], current[1], 0.0)
def replace_window(account, dialog_id, low, high, items, capped, full=False):
"""A sync's answer for messages [low, high]: cached marks it no longer
reports there are dropped (and returned) — all of the chat's when `full`
(a fresh check replaces the old cache). A capped answer (the newest marks
only) is complete from its oldest mark up."""
if capped and items:
low = max(low, min(int(d["message_id"]) for d in items))
keep = {int(d["message_id"]) for d in items}
with _lock:
_local_ids(account, dialog_id)
states = _states.setdefault((account, dialog_id), {})
stale = [
i for i in states if (full or low <= i <= high) and i not in keep
]
for i in stale:
del states[i]
try:
db = _conn()
db.executemany(
"DELETE FROM states WHERE account=? AND dialog_id=? AND message_id=?",
[(account, dialog_id, i) for i in stale],
)
db.commit()
except Exception as exc:
diag.error("store.replace_window", exc)
put_server_states(account, dialog_id, items)
return stale
def mark_deleted(account, dialog_id, message_ids):
"""Deletions this client saw (kept on screen or confirmed missing)."""
ids = [int(i) for i in message_ids]
with _lock:
_local_ids(account, dialog_id).update(ids)
now = time.time()
try:
db = _conn()
db.executemany(
"INSERT OR IGNORE INTO deleted VALUES (?, ?, ?, ?)",
[(account, dialog_id, i, now) for i in ids],
)
db.commit()
except Exception as exc:
diag.error("store.mark_deleted", exc)
def forget_deleted(account, dialog_id, message_ids):
"""The user deleted kept messages themselves: stop tracking them."""
ids = [int(i) for i in message_ids]
with _lock:
_local_ids(account, dialog_id).difference_update(ids)
states = _states.get((account, dialog_id), {})
for i in ids:
states.pop(i, None)
try:
db = _conn()
for table in ("deleted", "states"):
db.executemany(
f"DELETE FROM {table} WHERE account=? AND dialog_id=? AND message_id=?",
[(account, dialog_id, i) for i in ids],
)
db.commit()
except Exception as exc:
diag.error("store.forget_deleted", exc)
def note_own_deletion(account, message_ids):
now = time.monotonic()
with _lock:
for key in [k for k, at in _own.items() if now - at > _OWN_TTL]:
del _own[key]
for i in message_ids:
_own[(account, int(i))] = now
def is_own_deletion(account, message_id):
with _lock:
return (account, int(message_id)) in _own
def purge_old(days, force=False):
"""Removes cached server answers (marks, checked ranges) older than
`days` (None: never), at most once per local calendar day unless
`force`. Local deletion records are kept: they are what this client saw,
not a copy of the server."""
import datetime
global _purged_on
today = datetime.date.today().isoformat()
if not force and _purged_on == today:
return 0
with _lock:
try:
db = _conn()
row = db.execute("SELECT value FROM meta WHERE key='purged_on'").fetchone()
if not force and row is not None and row[0] == today:
_purged_on = today
return 0
removed = 0
if days is not None:
cutoff = time.time() - days * 86400
removed = db.execute("DELETE FROM states WHERE at < ?", (cutoff,)).rowcount
db.execute("DELETE FROM coverage WHERE at < ?", (cutoff,))
db.execute(
"INSERT OR REPLACE INTO meta VALUES ('purged_on', ?)", (today,)
)
db.commit()
_purged_on = today
except Exception as exc:
diag.error("store.purge_old", exc)
return 0
if removed:
# Reload chats from the database on next use.
_states.clear()
_coverage.clear()
_loaded.clear()
_local.clear()
diag.log(f"cache purge (older than {days} days): {removed} marks removed")
return removed
-2
View File
@@ -1,2 +0,0 @@
def normalize(name: str) -> str:
return " ".join(w[:1].upper() + w[1:] for w in name.split())
+128
View File
@@ -0,0 +1,128 @@
"""Telegram requests through the client's own session.
Sent with ConnectionsManager.sendRequest and a Java RequestDelegate proxy,
kept strongly referenced until it fires: on 12.10.6 the SDK's send_request
never invoked the callback for these requests.
"""
import threading
import diag
from jutil import java_list
_pending = set()
_lock = threading.Lock()
_Delegate = None
def _delegate_class():
"""A Java RequestDelegate calling a Python function (created once)."""
global _Delegate
if _Delegate is None:
from java import dynamic_proxy
from org.telegram.tgnet import RequestDelegate
class Delegate(dynamic_proxy(RequestDelegate)):
def __init__(self, fn):
super().__init__()
self.fn = fn
def run(self, response, error):
self.fn(response, error)
_Delegate = Delegate
return _Delegate
def _send(account, request, callback):
from org.telegram.tgnet import ConnectionsManager
box = []
def done(response, error):
with _lock:
for proxy in box:
_pending.discard(proxy)
try:
callback(response, error)
except Exception as exc:
diag.error("tg callback", exc)
delegate = _delegate_class()(done)
box.append(delegate)
with _lock:
_pending.add(delegate)
ConnectionsManager.getInstance(account).sendRequest(request, delegate)
def _message_ids(ids):
"""getMessages ids: ArrayList<Integer>. `jint` forces Integer boxing;
a plain Python int (even from Integer.valueOf) is boxed as Long, which
fails serialization on the network thread without any callback."""
from java import jint
from java.util import ArrayList
items = ArrayList()
for i in ids:
items.add(jint(int(i)))
return items
def channel_chat(account, channel_id):
"""The cached TLRPC.Chat of a channel, loading it from storage if needed."""
from org.telegram.messenger import MessagesController, MessagesStorage
controller = MessagesController.getInstance(account)
chat = controller.getChat(int(channel_id))
if chat is None:
try:
chat = MessagesStorage.getInstance(account).getChatSync(int(channel_id))
if chat is not None:
controller.putChat(chat, True)
except Exception as exc:
diag.error("tg.channel_chat", exc)
return chat
def get_messages(account, dialog_id, ids, callback):
"""Fetches `ids` of a dialog (a channel when `dialog_id` is one).
`callback(messages, error_text)`: messages by id (TL_messageEmpty for
deleted ones), or None and an error.
"""
from org.telegram.messenger import ChatObject, MessagesController
from org.telegram.tgnet import TLRPC
controller = MessagesController.getInstance(account)
dialog_id = int(dialog_id)
chat = channel_chat(account, -dialog_id) if dialog_id < 0 else None
if chat is not None and ChatObject.isChannel(chat):
request = TLRPC.TL_channels_getMessages()
request.channel = controller.getInputChannel(chat)
if request.channel is None or getattr(request.channel, "access_hash", 0) == 0:
callback(None, "not a member of that chat")
return
elif dialog_id < 0 and chat is None:
callback(None, "chat not found (not a member?)")
return
else:
request = TLRPC.TL_messages_getMessages()
request.id = _message_ids(ids)
def done(response, error):
if error is not None or response is None:
callback(None, getattr(error, "text", None) or "request failed")
return
try:
controller.putUsers(response.users, False)
controller.putChats(response.chats, False)
except Exception:
pass
found = {}
for message in java_list(response.messages):
found[int(message.id)] = message
callback(found, None)
_send(account, request, done)
View File
Whitespace-only changes.
+368
View File
@@ -0,0 +1,368 @@
"""Edit history as a mock chat: every version of a message drawn as a native
message bubble, in a bottom sheet over the chat wallpaper.
The versions live in the logger's activity group, re-posted without a header.
Only a member can read them (and load their media): the master account owning
the logger groups, or another account in that group. When one is signed in on
this device, the versions are fetched and drawn through it (the account viewing
the history if it can); otherwise the server's text is shown and each media is
marked unavailable. Posts of the logger groups themselves (saved posts, history
messages) open the history of the message they belong to.
"""
import accounts
import diag
import peers
import tg
from api import ApiError
from jutil import in_background, on_ui
_MEDIA_NAMES = {
"photo": "Photo",
"video": "Video",
"animation": "GIF",
"audio": "Audio",
"voice": "Voice message",
"video_note": "Video message",
"sticker": "Sticker",
"document": "File",
}
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 _when(iso):
if not iso:
return ""
from datetime import datetime
try:
return datetime.fromisoformat(iso).astimezone().strftime("%d %b %Y, %H:%M")
except ValueError:
return iso
def _epoch(iso):
if not iso:
return 0
from datetime import datetime
try:
return int(datetime.fromisoformat(iso).timestamp())
except ValueError:
return 0
def _entities(items):
"""Telethon entity dicts (server) -> TLRPC entities."""
from java.util import ArrayList
from org.telegram.tgnet import TLRPC
out = ArrayList()
for item in items:
name = str(item.get("_", ""))
cls = getattr(TLRPC, "TL_" + name[:1].lower() + name[1:], None) if name else None
if cls is None:
continue
entity = cls()
entity.offset = int(item["offset"])
entity.length = int(item["length"])
for key in ("url", "language", "document_id"):
if item.get(key) is not None and hasattr(entity, key):
setattr(entity, key, item[key])
out.add(entity)
return out
def _utf16(text):
return len(text.encode("utf-16-le")) // 2
class History:
def __init__(self, plugin, api):
self.plugin = plugin
self.api = api
def open(self, message):
"""Shows the edit history of a MessageObject."""
if message is None:
return
if not self.api.configured:
_bulletin("Message Logger: set the server URL and API token first", True)
return
account = int(message.currentAccount)
chat_id = peers.marked_chat_id(account, int(message.getDialogId()))
if chat_id is None or int(message.getId()) <= 0:
_bulletin("No edit history for this message")
return
in_background(lambda: self._load(message, account, chat_id))
def _load(self, original, account, chat_id):
try:
result = self.api.versions(
peers.own_user_id(account), chat_id, int(original.getId())
)
except ApiError as exc:
_bulletin(f"Message Logger: {exc}", True)
return
versions = (result or {}).get("versions") or []
if len(versions) < 2:
_bulletin("No edits logged for this message")
return
ids = [int(v["message_id"]) for v in versions if v.get("message_id")]
if not result.get("chat_id") or not ids:
on_ui(lambda: self._show(original, versions, None, {}, None))
return
channel_id = peers.channel_id_from_marked(result["chat_id"])
# Whoever can read the activity group: this account, the master, or
# any other signed-in member.
readers = accounts.with_access(
-channel_id, prefer=(account, accounts.slot_of(result.get("master_tg_id")))
)
if not readers:
on_ui(lambda: self._show(original, versions, None, {}, None))
return
reader = readers[0]
# The history topic, at its newest version.
topic = (channel_id, result.get("topic_id"), ids[-1])
def done(found, error):
if found is None:
diag.log(f"history: activity group unreadable: {error}")
found = {}
on_ui(lambda: self._show(original, versions, reader, found, topic))
tg.get_messages(reader, -channel_id, ids, done)
# building the mock messages
def _base(self, original, version):
from org.telegram.tgnet import TLRPC
owner = original.messageOwner
msg = TLRPC.TL_message()
# Its own (negative) id: the original's would give the bubbles the
# original's marks (a deleted message's trash icon and opacity).
msg.id = -(int(version.get("version") or 0) + 1)
msg.dialog_id = int(original.getDialogId())
msg.peer_id = owner.peer_id
msg.from_id = owner.from_id
msg.flags |= 256
msg.out = bool(owner.out)
msg.date = _epoch(version.get("edit_date")) or int(owner.date)
return msg
def _from_copy(self, original, version, copy):
from org.telegram.tgnet import TLRPC
msg = self._base(original, version)
msg.message = copy.message or ""
msg.entities = copy.entities
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:
msg.rich_message = rich
msg.flags2 |= 1 << 12
return msg
def _from_text(self, original, version):
"""The server's text; each media marked unavailable after it."""
from org.telegram.tgnet import TLRPC
msg = self._base(original, version)
text = version.get("text") or ""
entities = _entities(version.get("entities") or [])
notes = [
f"🚫 {_MEDIA_NAMES.get(kind, 'Media')} unavailable"
for kind in version.get("media") or []
]
if notes:
note = "\n".join(notes) + "\n(sign in an account of the logger's groups to load media)"
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
return msg
def _message_object(self, account, original, msg):
from org.telegram.messenger import MessageObject, MessagesController
viewer = int(original.currentAccount)
if account != viewer:
# The bubble is drawn as the reading account (media); names come
# from the viewer's cache.
source = MessagesController.getInstance(viewer)
target = MessagesController.getInstance(account)
sender = original.getFromChatId()
if sender > 0 and source.getUser(sender) is not None:
target.putUser(source.getUser(sender), True)
elif sender < 0 and source.getChat(-sender) is not None:
target.putChat(source.getChat(-sender), True)
return MessageObject(account, msg, True, True)
# the sheet
def _show(self, original, versions, reader, found, topic):
from android.view import Gravity
from android.widget import LinearLayout, ScrollView, TextView
from client_utils import get_last_fragment
from java import jint
from org.telegram.messenger import AndroidUtilities
from org.telegram.ui.ActionBar import BottomSheet, Theme
fragment = get_last_fragment()
context = fragment.getParentActivity() if fragment is not None else None
if context is None:
return
dp = AndroidUtilities.dp
column = LinearLayout(context)
column.setOrientation(LinearLayout.VERTICAL)
column.setPadding(0, dp(8), 0, dp(12))
last = len(versions) - 1
for index, version in enumerate(versions):
label = "Original" if version["version"] == 0 else f"Edit {version['version']}"
if index == last:
label += " · current"
when = _when(version.get("edit_date"))
column.addView(self._service(context, f"{label} · {when}" if when else label))
copy = found.get(int(version.get("message_id") or 0)) if reader is not None else None
if copy is not None and not isinstance(copy, _message_empty()):
msg, account = self._from_copy(original, version, copy), reader
else:
msg, account = self._from_text(original, version), int(original.currentAccount)
try:
column.addView(
self._bubble(context, account, self._message_object(account, original, msg), version)
)
except Exception as exc:
diag.error("history.bubble", exc)
scroll = ScrollView(context)
scroll.addView(column)
wallpaper = Theme.getCachedWallpaper()
if wallpaper is not None:
scroll.setBackground(wallpaper)
else:
scroll.setBackgroundColor(Theme.getColor(Theme.key_windowBackgroundGray))
root = LinearLayout(context)
root.setOrientation(LinearLayout.VERTICAL)
header = LinearLayout(context)
header.setOrientation(LinearLayout.HORIZONTAL)
header.setGravity(Gravity.CENTER_VERTICAL)
title = TextView(context)
title.setText("Edit history" + ("" if reader is not None else " · media unavailable"))
title.setTextSize(17)
title.setTextColor(jint(Theme.getColor(Theme.key_dialogTextBlack)))
title.setPadding(dp(20), dp(14), dp(12), dp(12))
header.addView(title, LinearLayout.LayoutParams(0, -2, 1.0))
sheet = BottomSheet(context, False)
if reader is not None and topic is not None and topic[1]:
header.addView(self._topic_button(context, sheet, fragment, reader, topic))
root.addView(header)
root.addView(
scroll,
LinearLayout.LayoutParams(-1, int(AndroidUtilities.displaySize.y * 0.7)),
)
sheet.setCustomView(root)
sheet.show()
def _topic_button(self, context, sheet, fragment, reader, topic):
"""Opens the history topic of the activity group, as `reader` (an
account that can read it) when the current one can't."""
from android.widget import TextView
from android_utils import OnClickListener
from java import jint
from org.telegram.messenger import AndroidUtilities
from org.telegram.ui.ActionBar import Theme
from views import open_as
dp = AndroidUtilities.dp
button = TextView(context)
button.setText("Open topic")
button.setTextSize(15)
button.setTextColor(jint(Theme.getColor(Theme.key_dialogTextBlue2)))
button.setPadding(dp(12), dp(14), dp(20), dp(12))
channel_id, topic_id, message_id = topic
def open_topic(_view):
sheet.dismiss()
open_as.open_topic(fragment, reader, channel_id, topic_id, message_id)
button.setOnClickListener(OnClickListener(open_topic))
return button
def _service(self, context, text):
"""A date-like service pill above each version."""
from android.view import Gravity
from android.widget import FrameLayout, TextView
from java import jint
from org.telegram.messenger import AndroidUtilities
from org.telegram.ui.ActionBar import Theme
dp = AndroidUtilities.dp
pill = TextView(context)
pill.setText(text)
pill.setTextSize(13)
pill.setTextColor(jint(Theme.getColor(Theme.key_chat_serviceText)))
pill.setPadding(dp(10), dp(3), dp(10), dp(4))
pill.setBackground(
Theme.createRoundRectDrawable(
dp(12), jint(Theme.getColor(Theme.key_chat_serviceBackground))
)
)
holder = FrameLayout(context)
holder.setPadding(0, dp(10), 0, dp(4))
params = FrameLayout.LayoutParams(-2, -2)
params.gravity = Gravity.CENTER_HORIZONTAL
holder.addView(pill, params)
return holder
def _bubble(self, context, account, message_object, version):
"""A ChatMessageCell for the version, under a layer taking its touches
(no chat delegate behind it); long-press copies the text."""
from android.view import View
from android.widget import FrameLayout
from android_utils import OnClickListener, OnLongClickListener, copy_to_clipboard
from org.telegram.ui.Cells import ChatMessageCell
cell = ChatMessageCell(context, account)
cell.setMessageObject(message_object, None, False, False, False)
holder = FrameLayout(context)
holder.addView(cell, FrameLayout.LayoutParams(-1, -2))
shield = View(context)
text = str(message_object.messageOwner.message or version.get("text") or "")
def copy(_view):
if text:
copy_to_clipboard(text)
_bulletin("Copied")
return True
shield.setOnClickListener(OnClickListener(lambda _view: None))
shield.setOnLongClickListener(OnLongClickListener(copy))
holder.addView(shield, FrameLayout.LayoutParams(-1, -1))
return holder
def _message_empty():
from org.telegram.tgnet import TLRPC
return TLRPC.TL_messageEmpty
+100
View File
@@ -0,0 +1,100 @@
"""Open a chat or a forum topic as a given signed-in account, on top of the
current screen: BaseFragment.setCurrentAccount before presenting, so going
back returns to the current account untouched.
A topic is opened the way ForumUtilities.openTopic does it (which always uses
the presenting fragment's account): its start message through
setThreadMessages, the topic loaded through that account's TopicsController
when not cached.
"""
import accounts
import diag
from jutil import on_ui
def _opened_as(chat, slot):
from ui.bulletin import BulletinHelper
if slot == accounts.current_slot():
return
name = accounts.name_of(slot)
on_ui(lambda: BulletinHelper.show_info(f"Opened as {name}", chat), 300)
def open_chat(fragment, slot, dialog_id, message_id=0):
"""Presents a dialog (at `message_id`) as account `slot`. UI thread."""
from android.os import Bundle
from org.telegram.ui import ChatActivity
args = Bundle()
if dialog_id > 0:
args.putLong("user_id", int(dialog_id))
else:
args.putLong("chat_id", -int(dialog_id))
if message_id:
args.putInt("message_id", int(message_id))
chat = ChatActivity(args)
chat.setCurrentAccount(int(slot))
fragment.presentFragment(chat)
_opened_as(chat, slot)
def open_topic(fragment, slot, channel_id, topic_id, message_id=0):
"""Presents a forum topic (at `message_id`) as account `slot`; falls back
to the group at `message_id` when the topic can't be loaded. UI thread."""
from java import dynamic_proxy
from java.lang import Runnable
from org.telegram.messenger import MessagesController
topics = MessagesController.getInstance(int(slot)).getTopicsController()
topic = topics.findTopic(int(channel_id), int(topic_id))
if topic is not None and topic.topicStartMessage is not None:
_present_topic(fragment, slot, channel_id, topic, message_id)
return
class Loaded(dynamic_proxy(Runnable)):
def run(self):
def show():
found = topics.findTopic(int(channel_id), int(topic_id))
if found is not None and found.topicStartMessage is not None:
_present_topic(fragment, slot, channel_id, found, message_id)
else:
diag.log(f"topic {channel_id}/{topic_id} not loadable: opening the group")
open_chat(fragment, slot, -int(channel_id), message_id)
on_ui(show)
_keep.append(Loaded())
topics.loadTopic(int(channel_id), int(topic_id), _keep[-1])
_keep = [] # Runnables handed to Java, kept alive until called
def _present_topic(fragment, slot, channel_id, topic, message_id):
from android.os import Bundle
from java.util import ArrayList
from org.telegram.messenger import MessageObject, MessagesController
from org.telegram.ui import ChatActivity
args = Bundle()
args.putLong("chat_id", int(channel_id))
if message_id:
args.putInt("message_id", int(message_id))
args.putInt("unread_count", int(topic.unread_count))
args.putBoolean("historyPreloaded", False)
chat = ChatActivity(args)
chat.setCurrentAccount(int(slot))
start = ArrayList()
start.add(MessageObject(int(slot), topic.topicStartMessage, False, False))
chat.setThreadMessages(
start,
MessagesController.getInstance(int(slot)).getChat(int(channel_id)),
int(topic.id),
int(topic.read_inbox_max_id),
int(topic.read_outbox_max_id),
topic,
)
fragment.presentFragment(chat)
_opened_as(chat, slot)