From 9b2f26a84593af805390ab72c62bc4d8174a0560 Mon Sep 17 00:00:00 2001 From: lyuksovannyy Date: Thu, 8 Oct 2026 23:55:23 +0200 Subject: [PATCH] Message Logger: show logged edits and deletions from telegram-panel [no bump] 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 --- README.md | 38 ++- docs/sdk-notes.md | 39 +++ plugin.json | 12 +- src/accounts.py | 162 +++++++++++++ src/api.py | 141 +++++++++++ src/config.py | 36 +++ src/diag.py | 54 +++++ src/greeting/__init__.py | 1 - src/greeting/template.py | 13 - src/header.py | 41 ++++ src/hooks/__init__.py | 0 src/hooks/chat.py | 501 +++++++++++++++++++++++++++++++++++++++ src/hooks/deletes.py | 145 +++++++++++ src/hooks/links.py | 112 +++++++++ src/hooks/menu.py | 129 ++++++++++ src/hooks/render.py | 176 ++++++++++++++ src/jutil.py | 92 +++++++ src/main.py | 183 +++++++++++--- src/peers.py | 47 ++++ src/store.py | 339 ++++++++++++++++++++++++++ src/textutil.py | 2 - src/tg.py | 128 ++++++++++ src/views/__init__.py | 0 src/views/history.py | 368 ++++++++++++++++++++++++++++ src/views/open_as.py | 100 ++++++++ tools/verify_bundle.py | 181 ++++++++++---- 26 files changed, 2928 insertions(+), 112 deletions(-) create mode 100644 src/accounts.py create mode 100644 src/api.py create mode 100644 src/config.py create mode 100644 src/diag.py delete mode 100644 src/greeting/__init__.py delete mode 100644 src/greeting/template.py create mode 100644 src/header.py create mode 100644 src/hooks/__init__.py create mode 100644 src/hooks/chat.py create mode 100644 src/hooks/deletes.py create mode 100644 src/hooks/links.py create mode 100644 src/hooks/menu.py create mode 100644 src/hooks/render.py create mode 100644 src/jutil.py create mode 100644 src/peers.py create mode 100644 src/store.py delete mode 100644 src/textutil.py create mode 100644 src/tg.py create mode 100644 src/views/__init__.py create mode 100644 src/views/history.py create mode 100644 src/views/open_as.py diff --git a/README.md b/README.md index da27d39..a914ecf 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,40 @@ -# exteraGram Plugin Base +# Message Logger (exteraGram plugin) + +Shows what a [telegram-panel](../../telegram-panel) message logger saved, natively in chats: + +- messages others delete stay (the delete updates are dropped), at 75% opacity with a red trash + icon (from the active icon pack) in the time row, and are reported to the server; +- logged messages deleted while the app was closed (or never seen deleted by the server, e.g. + bot-logged chats) are confirmed with Telegram, reported, and restored from the logger copy; +- *Edit history* in the message menu (right above Forward, for edited messages) opens every + version as a mock chat — also on the logger groups' own posts — with an *Open topic* button + for the history topic in the activity group. Versions and their media are read from the activity group through + a signed-in account that can (the current one, the master, or any member); otherwise the + server's text is shown and media are marked unavailable; +- message links into a chat the current account isn't in (e.g. a log post's "Message" link) + open that chat as a signed-in account that is in it; going back returns to your account. + Every signed-in account's chats are cached client-side for this. + +The server only points at copies (logger/activity group message ids); the client reads them +through its own session, so the account must be a member of those groups. + +The server is asked about a chat only when needed: when it wasn't checked within the last hour +(from the oldest loaded message on; the answer replaces the chat's cached marks), when older +history is loaded past the checked range (that part only), or after an edit/delete event in the +chat. Answers hold at most 100 marks (edited/deleted) and 100 missing ids, newest first. Marks +are cached per chat in the plugin's SQLite (`state.db`, no size cap), so a chat opened again — +even offline — shows them at once, and deleted messages are restored from the cache. An expired +chat keeps its cache until the next successful check, which replaces it and starts over. Cached +answers older than *Cache lifetime* (1/7/30/90/365 days or never; 30 by default) are removed, +checked once a day (from local midnight) and when the setting changes. + +Setup: in the panel create a token per master — the account owning the logger groups — +(Settings → API Tokens); in the plugin settings enter the panel URL and the tokens (separated by +spaces), then *Test connection*. Any Telegram account can use a token. Diagnostics: see `docs/sdk-notes.md` (gotchas section). + +The rest of this README describes the build toolchain this repo came from (pluginBase). + +## Plugin base toolchain A build toolchain and ADB automation for writing [exteraGram](https://exteragram.app) plugins as ordinary multi-file Python projects instead of hand-maintaining one giant `.py` file. diff --git a/docs/sdk-notes.md b/docs/sdk-notes.md index a0b909d..8ba4e1e 100644 --- a/docs/sdk-notes.md +++ b/docs/sdk-notes.md @@ -112,3 +112,42 @@ The official structured plugin format (Elyx/ElyxBuilder) is a real alternative t This repo's bundler instead compiles a normal multi-file `src/` project down to the one `.plugin` file the base engine already knows how to load, so the dev-server write/reload loop works from the first deploy with no extra on-device engine. + +## Gotchas found on exteraGram 12.10.6 (message_logger) + +- **Java number boxing.** A Python `int` passed where Java takes `Object`, or to an overloaded + method, becomes a `Long` — even the result of `Integer.valueOf()`. Use `java.jint` / + `jfloat` explicitly: `ArrayList` for `TL_*_getMessages.id` (a `Long` there fails + serialization on the network thread and the request silently never answers), + `Paint.setColor` (an `int` otherwise picks the `setColor(long)` ColorSpace overload), + reflective `Method.invoke` arguments, and replacing `param.args[i]` in hooks. +- **Requests.** `client_utils.send_request` did not invoke the callback for these requests; + `ConnectionsManager.getInstance(account).sendRequest(req, RequestDelegate)` with a + `dynamic_proxy(RequestDelegate)` kept strongly referenced works (`src/tg.py`). +- **Logs.** This device's logcat is silenced; `src/diag.py` mirrors to + `…/files/exteraGram/exteraGram Files/message_logger/diag.log` (readable over `adb shell cat`). +- **Deploys.** `devclient.py deploy --enable` only enables; an already enabled plugin needs a + deploy without `--enable` (which reloads) to pick up a new build. +- **Adding messages to an open chat.** Inserting into `ChatActivity.messages` directly (not in + `messagesDict`) breaks later arrivals in that chat; the private + `ChatActivity.processNewMessages(ArrayList, boolean)` places messages (also older ones) and + keeps the app's dicts and adapter consistent. +- **Cell height.** Changing a `ChatMessageCell`'s measured height grows its bubble; after the + state behind it changes, `forceLayout()` the cell and `chatAdapter.notifyItemChanged(...)`, + or the list keeps stale positions. Item animators reset cell alpha: scale it in a + `setAlpha` before-hook instead of setting it once. +- **Icon packs.** exteraGram applies icon packs through the activity's `ExteraResources` + (`getDrawableForDensity`); the application context's plain `Resources` and + `ColoredImageSpan(int)` give stock icons. Load drawables from + `get_last_fragment().getParentActivity().getResources()`. +- **Another account's chat.** `BaseFragment.setCurrentAccount(slot)` before `presentFragment` + opens e.g. a `ChatActivity` as another signed-in account without switching accounts. +- **Links.** Tapped links go through `org.telegram.messenger.browser.Browser.openUrl` (8 overloads, + the second argument a `Uri` or `String`); `adb shell am start -d URL` needs `-p `. +- **Message menu rows.** `MenuItemData` items land in the menu's Plugins folder, and options + added to `ChatActivity.fillMessageMenu`'s lists are dropped by exteraGram. A row in the main + menu is an `ActionBarMenuSubItem` inserted into the built popup after `createMenu` (whose + overloads call one another: insert once); `selectedObject` is the message. +- **Topics as another account.** `ForumUtilities.openTopic` uses the presenting fragment's + account; do its steps yourself (`TopicsController.findTopic`/`loadTopic`, `ChatActivity` + with `setCurrentAccount` then `setThreadMessages`) — see `src/views/open_as.py`. diff --git a/plugin.json b/plugin.json index b1fc013..f8b30c5 100644 --- a/plugin.json +++ b/plugin.json @@ -1,11 +1,11 @@ { - "id": "hello_bundle", - "name": "Hello Bundle", - "description": "Multi-file exteraGram plugin built by tools/bundle.py", - "author": "@yourhandle", - "version": "1.0.0", + "id": "message_logger", + "name": "Message Logger", + "description": "Shows deleted and edited messages and their edit history from your telegram-panel message logger.", + "author": "@lyuksovannyy", + "version": "0.1.0", "icon": "exteraPlugins/1", "app_version": ">=12.5.1", "sdk_version": ">=1.4.4.3", - "requirements": [] + "requirements": ["requests"] } diff --git a/src/accounts.py b/src/accounts.py new file mode 100644 index 0000000..a8e768a --- /dev/null +++ b/src/accounts.py @@ -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 diff --git a/src/api.py b/src/api.py new file mode 100644 index 0000000..42aa046 --- /dev/null +++ b/src/api.py @@ -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 diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..2b280d6 --- /dev/null +++ b/src/config.py @@ -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 diff --git a/src/diag.py b/src/diag.py new file mode 100644 index 0000000..42ac61b --- /dev/null +++ b/src/diag.py @@ -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}") diff --git a/src/greeting/__init__.py b/src/greeting/__init__.py deleted file mode 100644 index f0d6578..0000000 --- a/src/greeting/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .template import render, TemplateError diff --git a/src/greeting/template.py b/src/greeting/template.py deleted file mode 100644 index 5ccc0e9..0000000 --- a/src/greeting/template.py +++ /dev/null @@ -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}") diff --git a/src/header.py b/src/header.py new file mode 100644 index 0000000..028dd5f --- /dev/null +++ b/src/header.py @@ -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 diff --git a/src/hooks/__init__.py b/src/hooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/hooks/chat.py b/src/hooks/chat.py new file mode 100644 index 0000000..b80f11d --- /dev/null +++ b/src/hooks/chat.py @@ -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, 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))) diff --git a/src/hooks/deletes.py b/src/hooks/deletes.py new file mode 100644 index 0000000..62f1e3f --- /dev/null +++ b/src/hooks/deletes.py @@ -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()) diff --git a/src/hooks/links.py b/src/hooks/links.py new file mode 100644 index 0000000..a816980 --- /dev/null +++ b/src/hooks/links.py @@ -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 diff --git a/src/hooks/menu.py b/src/hooks/menu.py new file mode 100644 index 0000000..9481296 --- /dev/null +++ b/src/hooks/menu.py @@ -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) diff --git a/src/hooks/render.py b/src/hooks/render.py new file mode 100644 index 0000000..7f556f5 --- /dev/null +++ b/src/hooks/render.py @@ -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) diff --git a/src/jutil.py b/src/jutil.py new file mode 100644 index 0000000..2722392 --- /dev/null +++ b/src/jutil.py @@ -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() diff --git a/src/main.py b/src/main.py index f24e0fa..806ae09 100644 --- a/src/main.py +++ b/src/main.py @@ -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 " - 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) diff --git a/src/peers.py b/src/peers.py new file mode 100644 index 0000000..505b85a --- /dev/null +++ b/src/peers.py @@ -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()) diff --git a/src/store.py b/src/store.py new file mode 100644 index 0000000..286e791 --- /dev/null +++ b/src/store.py @@ -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 diff --git a/src/textutil.py b/src/textutil.py deleted file mode 100644 index 7c3107b..0000000 --- a/src/textutil.py +++ /dev/null @@ -1,2 +0,0 @@ -def normalize(name: str) -> str: - return " ".join(w[:1].upper() + w[1:] for w in name.split()) diff --git a/src/tg.py b/src/tg.py new file mode 100644 index 0000000..a2da5b5 --- /dev/null +++ b/src/tg.py @@ -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. `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) diff --git a/src/views/__init__.py b/src/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/views/history.py b/src/views/history.py new file mode 100644 index 0000000..f049123 --- /dev/null +++ b/src/views/history.py @@ -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 + diff --git a/src/views/open_as.py b/src/views/open_as.py new file mode 100644 index 0000000..5dcb565 --- /dev/null +++ b/src/views/open_as.py @@ -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) diff --git a/tools/verify_bundle.py b/tools/verify_bundle.py index 2dbc171..bb7c5ab 100755 --- a/tools/verify_bundle.py +++ b/tools/verify_bundle.py @@ -42,6 +42,27 @@ class HookStrategy(Enum): class HookResult: strategy: HookStrategy = HookStrategy.DEFAULT params: object = None + update: object = None + + +class MenuItemType(Enum): + MESSAGE_CONTEXT_MENU = auto() + + +@dataclass +class MenuItemData: + menu_type: MenuItemType + text: str + on_click: object + item_id: object = None + icon: object = None + subtext: object = None + condition: object = None + priority: int = 0 + + +class MethodHook: + pass LOG_LINES: list[str] = [] @@ -84,6 +105,9 @@ def install_fakes() -> None: base_plugin_mod.BasePlugin = BasePlugin base_plugin_mod.HookResult = HookResult base_plugin_mod.HookStrategy = HookStrategy + base_plugin_mod.MenuItemData = MenuItemData + base_plugin_mod.MenuItemType = MenuItemType + base_plugin_mod.MethodHook = MethodHook sys.modules["base_plugin"] = base_plugin_mod android_utils_mod = types.ModuleType("android_utils") @@ -94,18 +118,20 @@ def install_fakes() -> None: ui_mod.__path__ = [] sys.modules["ui"] = ui_mod + import tempfile + + file_utils_mod = types.ModuleType("file_utils") + _files = tempfile.mkdtemp(prefix="ml-verify-") + file_utils_mod.get_files_dir = lambda: _files + sys.modules["file_utils"] = file_utils_mod + ui_settings_mod = types.ModuleType("ui.settings") - for cls_name in ("Header", "Input", "Text", "Switch"): + for cls_name in ("Header", "Input", "Text", "Switch", "Selector"): setattr(ui_settings_mod, cls_name, type(cls_name, (_SettingsRow,), {})) sys.modules["ui.settings"] = ui_settings_mod ui_mod.settings = ui_settings_mod -class SimpleParams: - def __init__(self, message): - self.message = message - - KEY_TO_DUNDER = { "id": "__id__", "name": "__name__", @@ -147,7 +173,7 @@ def main(argv=None) -> int: source = bundle_path.read_text(encoding="utf-8") modules_before = set(sys.modules) - ns = {"__name__": "hello_bundle"} + ns = {"__name__": "message_logger"} exec(compile(source, str(bundle_path), "exec"), ns) new_modules = set(sys.modules) - modules_before @@ -194,63 +220,114 @@ def main(argv=None) -> int: ) plugin = plugin_cls() - LOG_LINES.clear() - plugin.on_plugin_load() + keys = {getattr(row, "key", None) for row in plugin.create_settings()} check( - "on_plugin_load emits load log line", - any("hello_bundle loaded" in line for line in LOG_LINES), - f"log lines: {LOG_LINES}", + "settings expose server URL and API token", + {"server_url", "api_token"} <= keys, + f"keys: {keys}", ) - params = SimpleParams(".hello alice smith") - result = plugin.on_send_message_hook(account=0, params=params) + load = ns.get("_bundle_load") + check("bundle exposes its module loader", load is not None) + if load is not None: + header = load("header") + text = "✉️ From: Bob\n ⤷ 💬 Message\n🗑 Deleted at x\n\nHi 😀 there" + end = header.utf16_len(text) + body, entities = header.strip(text, [(0, 4, "from"), (end - 5, 5, "bold")]) + check("header stripped from logged copy", body == "Hi 😀 there", repr(body)) check( - "'.hello alice smith' renders greeting", - params.message == "Hello, Alice Smith!", - f"got {params.message!r}", + "entities shifted past the header (UTF-16)", + entities == [(6, 5, "bold")], + repr(entities), ) check( - "on_send_message_hook returns MODIFY strategy", - result.strategy == HookStrategy.MODIFY, - f"got {result.strategy}", + "text without a header is kept", + header.strip("plain", [(0, 5, "x")]) == ("plain", [(0, 5, "x")]), ) - plugin.set_setting("template", "{nope}") - params2 = SimpleParams(".hello someone") - plugin.on_send_message_hook(account=0, params=params2) + peers = load("peers") check( - "bad template produces 'Bad template:' message", - params2.message.startswith("Bad template:"), - f"got {params2.message!r}", + "marked channel id -> channel id", + peers.channel_id_from_marked(-1000000001234) == 1234, + ) + chat = load("hooks.chat") + check( + "Telethon sender ids -> Android peer ids", + chat._android_peer_id(-1000000001234) == -1234 and chat._android_peer_id(42) == 42, ) - # main.py imports `render`/`TemplateError` by name, so they live directly - # in the executed namespace — call render() directly to inspect the raised - # traceback (proves linecache registration for bundled, non-file modules). - render = ns.get("render") - template_error = ns.get("TemplateError") - traceback_text = "" - if render is not None and template_error is not None: - try: - render("{nope}", "x") - except template_error: - traceback_text = traceback.format_exc() - check( - "render() traceback names src/greeting/template.py", - "src/greeting/template.py" in traceback_text, - f"traceback:\n{traceback_text}", - ) - check( - "traceback includes the raising source line text", - "unsupported placeholder" in traceback_text, - f"traceback:\n{traceback_text}", - ) + links = load("hooks.links") + check( + "private message links parse (with and without topic)", + links.parse("https://t.me/c/123/45") == (-123, 45) + and links.parse("t.me/c/123/7/45") == (-123, 45) + and links.parse("tg://privatepost?channel=123&post=45") == (-123, 45), + ) + check("non-message links are left alone", links.parse("https://t.me/c/123") is None) - check( - "sys.modules gained no entry for bundled module names", - not ({"greeting", "textutil", "main"} & new_modules), - f"leaked: {({'greeting', 'textutil', 'main'} & new_modules)}", - ) + plan, hour = chat._plan, 3600 + check( + "sync plan: fresh when unchecked or older than an hour", + plan(None, 50, 2**31 - 1, 10_000) == (50, 2**31 - 1, True) + and plan((50, 2**31 - 1, 10_000 - hour), 50, 2**31 - 1, 10_000) + == (50, 2**31 - 1, True), + ) + check( + "sync plan: nothing while the loaded range is checked", + plan((40, 2**31 - 1, 9_000), 50, 2**31 - 1, 10_000) is None, + ) + check( + "sync plan: only the older history loaded since", + plan((40, 2**31 - 1, 9_000), 10, 2**31 - 1, 10_000) == (10, 39, False) + and plan((40, 100, 9_000), 50, 120, 10_000) == (101, 120, False), + ) + + store = load("store") + store.put_server_states( + 0, -5, [{"message_id": i, "edit_count": 1} for i in range(1, 1201)] + ) + (cached,) = store._conn().execute( + "SELECT count(*) FROM states WHERE account=0 AND dialog_id=-5" + ).fetchone() + check("cache has no per-chat cap", cached == 1200, f"{cached}") + check( + "a full check replaces the chat's cached marks", + store.replace_window(0, -5, 1, 10, [{"message_id": 5, "edit_count": 2}], False, full=True) + is not None + and store._conn().execute( + "SELECT count(*) FROM states WHERE account=0 AND dialog_id=-5" + ).fetchone()[0] + == 1, + ) + + store._conn().execute("UPDATE states SET at = at - 40 * 86400") + store._conn().commit() + store.put_server_states(0, -6, [{"message_id": 1, "edit_count": 1}]) + removed = store.purge_old(30, force=True) + left = store._conn().execute("SELECT dialog_id FROM states").fetchall() + check( + "age purge drops answers older than the lifetime only", + removed == 1 and left == [(-6,)], + f"removed={removed} left={left}", + ) + check("age purge runs once a day", store.purge_old(1) == 0) + for name in ("cached_deleted", "coverage", "set_coverage", "expire_coverage", "replace_window"): + check(f"store.{name} exists", callable(getattr(store, name, None))) + + api = load("api") + client = api.Api(BasePlugin()) + check("API unconfigured without URL and token", not client.configured) + client.plugin.set_setting("server_url", "https://panel.example/ ") + client.plugin.set_setting("api_token", "tlp_x, tlp_y\ntlp_z") + check("several tokens, any separator", client.tokens() == ["tlp_x", "tlp_y", "tlp_z"]) + check( + "API base URL", + client._base() == "https://panel.example/api/v1/client/message-logger", + client._base(), + ) + + leaked = {"main", "header", "api", "store", "peers", "hooks", "views"} & new_modules + check("sys.modules gained no entry for bundled module names", not leaked, f"leaked: {leaked}") if _FAILURES: print(f"\n{len(_FAILURES)} check(s) failed: {', '.join(_FAILURES)}", file=sys.stderr)