generated from exteraGramPlugins/pluginBase
Message Logger: show logged edits and deletions from telegram-panel [no bump]
Publish plugin / publish (push) Successful in 4s
Publish plugin / publish (push) Successful in 4s
Keeps deleted messages (75% opacity, red icon-pack trash icon), restores logged messages deleted while away, reports deletions, and shows edit history as a mock chat (Edit history in the message menu, Open topic). Message links and history open as any signed-in account with access. Per-master API tokens; chat state synced per checked range with an hour's validity, cached in SQLite with a configurable lifetime. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
80f1d5e55c
commit
9b2f26a845
26 files changed
+2923
-107
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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<Integer>` 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 <package>`.
|
||||
- **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`.
|
||||
+6
-6
@@ -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"]
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
"""Every signed-in account and the chats it has, cached client-side, to find
|
||||
an account that can open a chat the current one isn't in.
|
||||
|
||||
Each account's dialogs come from its own database (`dialogs` table, read on
|
||||
its storage queue); membership is confirmed against the chat itself before
|
||||
use, since a dialog can outlive leaving a channel.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
import diag
|
||||
|
||||
_REFRESH_SECONDS = 600
|
||||
_lock = threading.Lock()
|
||||
_dialogs = {} # account slot -> set of dialog ids
|
||||
_loaded_at = 0.0
|
||||
_Query = []
|
||||
|
||||
|
||||
def signed_in():
|
||||
"""[(slot, telegram user id)] of every signed-in account."""
|
||||
from org.telegram.messenger import UserConfig
|
||||
|
||||
out = []
|
||||
for slot in range(UserConfig.MAX_ACCOUNT_COUNT):
|
||||
config = UserConfig.getInstance(slot)
|
||||
if config.isClientActivated():
|
||||
out.append((slot, int(config.getClientUserId())))
|
||||
return out
|
||||
|
||||
|
||||
def current_slot():
|
||||
from client_utils import get_last_fragment
|
||||
from org.telegram.messenger import UserConfig
|
||||
|
||||
fragment = get_last_fragment()
|
||||
if fragment is not None:
|
||||
return int(fragment.getCurrentAccount())
|
||||
return int(UserConfig.selectedAccount)
|
||||
|
||||
|
||||
def slot_of(tg_id):
|
||||
"""The slot signed in as Telegram user `tg_id`, else None."""
|
||||
for slot, user_id in signed_in():
|
||||
if tg_id and user_id == int(tg_id):
|
||||
return slot
|
||||
return None
|
||||
|
||||
|
||||
def name_of(slot):
|
||||
from org.telegram.messenger import UserConfig, UserObject
|
||||
|
||||
user = UserConfig.getInstance(slot).getCurrentUser()
|
||||
return UserObject.getUserName(user) if user is not None else f"account {slot + 1}"
|
||||
|
||||
|
||||
def _query_class():
|
||||
if not _Query:
|
||||
from java import dynamic_proxy
|
||||
from java.lang import Runnable
|
||||
|
||||
class Query(dynamic_proxy(Runnable)):
|
||||
def __init__(self, storage, done):
|
||||
super().__init__()
|
||||
self.storage = storage
|
||||
self.done = done
|
||||
|
||||
def run(self):
|
||||
found = set()
|
||||
try:
|
||||
cursor = self.storage.getDatabase().queryFinalized(
|
||||
"SELECT did FROM dialogs"
|
||||
)
|
||||
while cursor.next():
|
||||
found.add(int(cursor.longValue(0)))
|
||||
cursor.dispose()
|
||||
except Exception as exc:
|
||||
diag.error("accounts.query", exc)
|
||||
self.done(found)
|
||||
|
||||
_Query.append(Query)
|
||||
return _Query[0]
|
||||
|
||||
|
||||
def refresh(wait=False):
|
||||
"""Re-reads every account's dialogs (blocking when `wait`; never call
|
||||
that from the UI or a storage thread)."""
|
||||
global _loaded_at
|
||||
from org.telegram.messenger import MessagesStorage
|
||||
|
||||
pending = []
|
||||
for slot, _user in signed_in():
|
||||
event = threading.Event()
|
||||
|
||||
def done(found, slot=slot, event=event):
|
||||
with _lock:
|
||||
_dialogs[slot] = found
|
||||
event.set()
|
||||
|
||||
storage = MessagesStorage.getInstance(slot)
|
||||
storage.getStorageQueue().postRunnable(_query_class()(storage, done))
|
||||
pending.append(event)
|
||||
_loaded_at = time.monotonic()
|
||||
if wait:
|
||||
for event in pending:
|
||||
event.wait(5)
|
||||
|
||||
|
||||
def _fresh():
|
||||
if time.monotonic() - _loaded_at > _REFRESH_SECONDS:
|
||||
refresh()
|
||||
|
||||
|
||||
def has_access(slot, dialog_id):
|
||||
"""Whether `slot` can open the dialog (a member, for groups/channels)."""
|
||||
from org.telegram.messenger import ChatObject, MessagesController
|
||||
|
||||
dialog_id = int(dialog_id)
|
||||
controller = MessagesController.getInstance(slot)
|
||||
if dialog_id > 0:
|
||||
return dialog_id in _dialogs.get(slot, ()) or (
|
||||
controller.dialogs_dict.get(dialog_id) is not None
|
||||
)
|
||||
import tg
|
||||
|
||||
chat = tg.channel_chat(slot, -dialog_id)
|
||||
return chat is not None and not ChatObject.isNotInChat(chat)
|
||||
|
||||
|
||||
def with_access(dialog_id, prefer=()):
|
||||
"""Signed-in slots that can open the dialog: `prefer` first, then any
|
||||
whose chats include it."""
|
||||
_fresh()
|
||||
with _lock:
|
||||
cached = [s for s, d in _dialogs.items() if int(dialog_id) in d]
|
||||
order = [s for s in prefer if s is not None] + cached
|
||||
seen, out = set(), []
|
||||
for slot in order:
|
||||
if slot in seen:
|
||||
continue
|
||||
seen.add(slot)
|
||||
try:
|
||||
if has_access(slot, dialog_id):
|
||||
out.append(slot)
|
||||
except Exception as exc:
|
||||
diag.error("accounts.has_access", exc)
|
||||
return out
|
||||
|
||||
|
||||
def resolve_username(username):
|
||||
"""Dialog id of a username known to any signed-in account, else None."""
|
||||
from org.telegram.messenger import MessagesController
|
||||
from org.telegram.tgnet import TLRPC
|
||||
|
||||
for slot, _user in signed_in():
|
||||
found = MessagesController.getInstance(slot).getUserOrChat(username)
|
||||
if isinstance(found, TLRPC.User):
|
||||
return int(found.id)
|
||||
if isinstance(found, TLRPC.Chat):
|
||||
return -int(found.id)
|
||||
return None
|
||||
+141
@@ -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
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Setting keys and defaults."""
|
||||
|
||||
SERVER_URL = "server_url"
|
||||
API_TOKEN = "api_token"
|
||||
KEEP_DELETED = "keep_deleted"
|
||||
RESTORE_MISSING = "restore_missing"
|
||||
REPORT_DELETIONS = "report_deletions"
|
||||
DELETED_ALPHA = "deleted_alpha"
|
||||
CACHE_DAYS = "cache_days"
|
||||
|
||||
DEFAULTS = {
|
||||
SERVER_URL: "",
|
||||
API_TOKEN: "",
|
||||
KEEP_DELETED: True,
|
||||
RESTORE_MISSING: True,
|
||||
REPORT_DELETIONS: True,
|
||||
DELETED_ALPHA: 0, # index into ALPHA_CHOICES
|
||||
CACHE_DAYS: 2, # index into CACHE_DAYS_CHOICES
|
||||
}
|
||||
|
||||
# Opacity of deleted messages: well visible, marked by the trash icon.
|
||||
ALPHA_CHOICES = [("75%", 0.75), ("90%", 0.9), ("50%", 0.5)]
|
||||
|
||||
# A chat's checked range of message ids stays valid this long: within it the
|
||||
# server is asked again only after an edit/delete event in the chat.
|
||||
COVERAGE_SECONDS = 3600
|
||||
# Cached server answers older than this are removed (checked once a day).
|
||||
CACHE_DAYS_CHOICES = [
|
||||
("1 day", 1),
|
||||
("7 days", 7),
|
||||
("30 days", 30),
|
||||
("90 days", 90),
|
||||
("365 days", 365),
|
||||
("Never", None),
|
||||
]
|
||||
HTTP_TIMEOUT_SECONDS = 15
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
"""Diagnostics mirrored to a file, since this device's logcat surfaces neither
|
||||
Chaquopy stdout nor android_utils.log output.
|
||||
|
||||
The file lives under `get_files_dir()` (app external storage), so it is
|
||||
readable over adb:
|
||||
adb shell cat "/sdcard/Android/data/com.exteragram.messenger/files/exteraGram/exteraGram Files/message_logger/diag.log"
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import traceback
|
||||
|
||||
_MAX_BYTES = 256 * 1024
|
||||
_path = None
|
||||
|
||||
|
||||
def _file():
|
||||
global _path
|
||||
if _path is None:
|
||||
try:
|
||||
from file_utils import get_files_dir
|
||||
|
||||
directory = os.path.join(get_files_dir(), "message_logger")
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
_path = os.path.join(directory, "diag.log")
|
||||
except Exception:
|
||||
_path = ""
|
||||
return _path
|
||||
|
||||
|
||||
def log(message):
|
||||
line = time.strftime("%H:%M:%S ") + str(message)
|
||||
try:
|
||||
from android_utils import log as android_log
|
||||
|
||||
android_log("[message_logger] " + line)
|
||||
except Exception:
|
||||
pass
|
||||
path = _file()
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
mode = "a"
|
||||
if os.path.exists(path) and os.path.getsize(path) > _MAX_BYTES:
|
||||
mode = "w"
|
||||
with open(path, mode, encoding="utf-8") as handle:
|
||||
handle.write(line + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def error(where, exc=None):
|
||||
detail = traceback.format_exc() if exc is not None else ""
|
||||
log(f"ERROR {where}: {exc!r}\n{detail}")
|
||||
@@ -1 +0,0 @@
|
||||
from .template import render, TemplateError
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
Whitespace-only changes.
@@ -0,0 +1,501 @@
|
||||
"""Per open chat: sync the loaded window with the server, confirm and report
|
||||
messages that vanished, and put deleted messages back from the logger.
|
||||
|
||||
ChatActivity (12.10.6, probed): updateMessagesVisiblePart(boolean) runs on
|
||||
every scroll/load; `messages` is the loaded ArrayList<MessageObject>, newest
|
||||
first; `chatListView`, `chatAdapter`.
|
||||
|
||||
The server is asked about a chat only when needed (store.coverage keeps the
|
||||
range of ids already checked, per chat, for COVERAGE_SECONDS):
|
||||
- the chat wasn't checked within the hour: everything loaded, from the oldest
|
||||
loaded message on, and the answer replaces the chat's cached marks;
|
||||
- history older than the checked range was loaded: that older part only;
|
||||
- an edit or delete event in the chat (or a deletion this client reported):
|
||||
the check is expired, so the next look asks again.
|
||||
Answers hold at most 100 marks and 100 missing ids, newest first; a capped
|
||||
answer covers down to its oldest mark, the rest is asked for next.
|
||||
|
||||
Restored messages go through ChatActivity.processNewMessages, like messages
|
||||
that just arrived: the app places them and keeps `messagesDict` and its
|
||||
adapter consistent (inserting into `messages` directly broke later arrivals),
|
||||
and deleting one removes it the usual way.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from base_plugin import MethodHook
|
||||
|
||||
import diag
|
||||
import header
|
||||
import peers
|
||||
import store
|
||||
import tg
|
||||
from api import ApiError
|
||||
from config import COVERAGE_SECONDS, REPORT_DELETIONS, RESTORE_MISSING
|
||||
from jutil import field, hook_methods, in_background, java_class, java_list, on_ui
|
||||
|
||||
CHAT = "org.telegram.ui.ChatActivity"
|
||||
_DEBOUNCE_MS = 700
|
||||
# The server logs an edit asynchronously; sync after it had time to.
|
||||
_EDIT_DELAY_MS = 4000
|
||||
EDIT_UPDATES = ("TL_updateEditMessage", "TL_updateEditChannelMessage")
|
||||
|
||||
|
||||
def _epoch(iso):
|
||||
if not iso:
|
||||
return 0
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
return int(datetime.fromisoformat(iso).timestamp())
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
_MAX_ID = 2**31 - 1
|
||||
|
||||
|
||||
def _end_reached(fragment, name):
|
||||
"""ChatActivity.endReached / forwardEndReached: boolean[2], index 0 is
|
||||
the chat itself (1 is the merged migrated chat)."""
|
||||
flags = field(fragment, name)
|
||||
try:
|
||||
return bool(flags[0])
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
_process = []
|
||||
|
||||
|
||||
def _process_new_messages():
|
||||
"""ChatActivity.processNewMessages(ArrayList, boolean) (private)."""
|
||||
if not _process:
|
||||
from java.lang import Boolean
|
||||
|
||||
method = java_class(CHAT).getDeclaredMethod(
|
||||
"processNewMessages", java_class("java.util.ArrayList"), Boolean.TYPE
|
||||
)
|
||||
method.setAccessible(True)
|
||||
_process.append(method)
|
||||
return _process[0]
|
||||
|
||||
|
||||
def _plan(coverage, low, high, now):
|
||||
"""(low, high, fresh) to ask the server about, or None when the loaded
|
||||
range [low, high] is already checked."""
|
||||
if coverage is None or now - coverage[2] >= COVERAGE_SECONDS:
|
||||
return low, high, True
|
||||
covered_low, covered_high, _at = coverage
|
||||
if low < covered_low:
|
||||
return low, covered_low - 1, False # older history was loaded
|
||||
if high > covered_high:
|
||||
return covered_high + 1, high, False # newer messages were loaded
|
||||
return None
|
||||
|
||||
|
||||
def _cover(account, dialog_id, low, high, fresh):
|
||||
"""Records [low, high] as checked: a fresh check starts the hour, a
|
||||
partial one extends the range it belongs to."""
|
||||
current = store.coverage(account, dialog_id)
|
||||
if fresh or current is None:
|
||||
store.set_coverage(account, dialog_id, low, high, time.time())
|
||||
return
|
||||
covered_low, covered_high, at = current
|
||||
store.set_coverage(
|
||||
account, dialog_id, min(covered_low, low), max(covered_high, high), at
|
||||
)
|
||||
|
||||
|
||||
def _loaded(fragment, message_id):
|
||||
"""Whether the app itself has `message_id` in this chat (messagesDict[0])."""
|
||||
try:
|
||||
return field(fragment, "messagesDict")[0].get(int(message_id)) is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _android_peer_id(marked):
|
||||
"""Telethon sender id -> Android peer id (channels are -id)."""
|
||||
marked = int(marked)
|
||||
if marked <= -peers.CHANNEL_OFFSET:
|
||||
return -(-marked - peers.CHANNEL_OFFSET)
|
||||
return marked
|
||||
|
||||
|
||||
def _strip_rich_header(rich):
|
||||
"""A rich copy is header paragraphs, a divider, then the body
|
||||
(telegram-panel render.compose): drop everything up to the divider."""
|
||||
blocks = rich.blocks
|
||||
for index in range(min(blocks.size(), 12)):
|
||||
if blocks.get(index).getClass().getSimpleName() == "pageBlockDivider":
|
||||
for _ in range(index + 1):
|
||||
blocks.remove(0)
|
||||
return
|
||||
|
||||
|
||||
def _rich_text(node):
|
||||
"""Plain text of a TL_iv RichText tree (textPlain/textConcat/styles...)."""
|
||||
if node is None:
|
||||
return ""
|
||||
value = getattr(node, "text", None)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
texts = getattr(node, "texts", None)
|
||||
if texts is not None and hasattr(texts, "size"):
|
||||
return "".join(_rich_text(t) for t in java_list(texts))
|
||||
return _rich_text(value)
|
||||
|
||||
|
||||
def _rich_plain_text(rich):
|
||||
"""The body as plain text when it is only text paragraphs, else None."""
|
||||
blocks = java_list(rich.blocks)
|
||||
if not blocks or any(
|
||||
b.getClass().getSimpleName() != "pageBlockParagraph" for b in blocks
|
||||
):
|
||||
return None
|
||||
return "\n".join(_rich_text(b.text) for b in blocks)
|
||||
|
||||
|
||||
class ChatSync:
|
||||
def __init__(self, plugin, api):
|
||||
self.plugin = plugin
|
||||
self.api = api
|
||||
self.handles = []
|
||||
self._scheduled = set()
|
||||
self._inflight = set() # (account, dialog) with a sync running
|
||||
self._exists = {} # (account, dialog) -> ids Telegram still has
|
||||
self._restoring = set() # (account, dialog, id) in flight
|
||||
self._fragments = {} # (account, dialog) -> last ChatActivity seen
|
||||
|
||||
def install(self):
|
||||
for name in EDIT_UPDATES:
|
||||
self.plugin.add_hook(name)
|
||||
sync = self
|
||||
|
||||
class AfterVisible(MethodHook):
|
||||
def after_hooked_method(self, param):
|
||||
try:
|
||||
sync.schedule(param.thisObject)
|
||||
except Exception as exc:
|
||||
diag.error("chat.schedule", exc)
|
||||
|
||||
self.handles += hook_methods(
|
||||
self.plugin, CHAT, "updateMessagesVisiblePart", AfterVisible()
|
||||
)
|
||||
|
||||
def uninstall(self):
|
||||
for name in EDIT_UPDATES:
|
||||
try:
|
||||
self.plugin.remove_hook(name)
|
||||
except Exception:
|
||||
pass
|
||||
for handle in self.handles:
|
||||
try:
|
||||
self.plugin.unhook_method(handle)
|
||||
except Exception:
|
||||
pass
|
||||
self.handles = []
|
||||
|
||||
# collecting the window (UI thread)
|
||||
|
||||
def schedule(self, fragment):
|
||||
if not self.api.configured:
|
||||
return
|
||||
guid = int(fragment.getClassGuid())
|
||||
if guid in self._scheduled:
|
||||
return
|
||||
self._scheduled.add(guid)
|
||||
on_ui(lambda: self._collect(fragment, guid), _DEBOUNCE_MS)
|
||||
|
||||
def _collect(self, fragment, guid):
|
||||
self._scheduled.discard(guid)
|
||||
self.plugin.purge_cache() # once a day
|
||||
account = int(fragment.getCurrentAccount())
|
||||
dialog_id = int(fragment.getDialogId())
|
||||
chat_id = peers.marked_chat_id(account, dialog_id)
|
||||
if chat_id is None:
|
||||
return
|
||||
key = (account, dialog_id)
|
||||
self._fragments[key] = fragment
|
||||
if key in self._inflight:
|
||||
return
|
||||
ids = [
|
||||
int(m.getId())
|
||||
for m in java_list(field(fragment, "messages"))
|
||||
if m is not None and int(m.getId()) > 0
|
||||
]
|
||||
if not ids:
|
||||
return
|
||||
# The oldest loaded message bounds every request; an end of the chat
|
||||
# that is loaded is open (deleted past it still belongs to it).
|
||||
low = 1 if _end_reached(fragment, "endReached") else min(ids)
|
||||
high = _MAX_ID if _end_reached(fragment, "forwardEndReached") else max(ids)
|
||||
request = _plan(store.coverage(account, dialog_id), low, high, time.time())
|
||||
if request is None:
|
||||
# Checked within the hour: deleted messages come back from the
|
||||
# cached answers (they were never in the app's own database).
|
||||
if self.plugin.get_setting(RESTORE_MISSING, True):
|
||||
present = set(ids)
|
||||
gone = [
|
||||
s
|
||||
for s in store.cached_deleted(account, dialog_id, low, high)
|
||||
if int(s["message_id"]) not in present
|
||||
]
|
||||
if gone:
|
||||
me = peers.own_user_id(account)
|
||||
in_background(
|
||||
lambda: self._restore(
|
||||
fragment, account, dialog_id, chat_id, me, gone
|
||||
)
|
||||
)
|
||||
return
|
||||
present = {i for i in ids if request[0] <= i <= request[1]}
|
||||
self._inflight.add(key)
|
||||
in_background(
|
||||
lambda: self._sync(fragment, account, dialog_id, chat_id, request, present)
|
||||
)
|
||||
|
||||
# server round trip (background)
|
||||
|
||||
def _sync(self, fragment, account, dialog_id, chat_id, request, present):
|
||||
key = (account, dialog_id)
|
||||
try:
|
||||
self._sync_range(fragment, account, dialog_id, chat_id, request, present)
|
||||
finally:
|
||||
self._inflight.discard(key)
|
||||
|
||||
def _sync_range(self, fragment, account, dialog_id, chat_id, request, present):
|
||||
low, high, fresh = request
|
||||
me = peers.own_user_id(account)
|
||||
started = time.monotonic()
|
||||
try:
|
||||
result = self.api.sync(me, chat_id, low, high, sorted(present))
|
||||
except ApiError as exc:
|
||||
diag.log(f"sync {chat_id}: {exc}")
|
||||
return
|
||||
if not result:
|
||||
return
|
||||
states = result.get("states") or []
|
||||
capped = len(states) >= 100
|
||||
diag.log(
|
||||
f"sync {chat_id} [{low}..{high}]{' fresh' if fresh else ''} "
|
||||
f"loaded={len(present)} marks={len(states)} "
|
||||
f"missing={len(result.get('missing') or [])} "
|
||||
f"{int((time.monotonic() - started) * 1000)}ms"
|
||||
)
|
||||
dropped = store.replace_window(
|
||||
account, dialog_id, low, high, states, capped, full=fresh
|
||||
)
|
||||
# A capped answer is complete from its oldest mark up only.
|
||||
covered_low = max(low, min(int(s["message_id"]) for s in states)) if capped else low
|
||||
_cover(account, dialog_id, covered_low, high, fresh)
|
||||
changed = {int(s["message_id"]) for s in states} | set(dropped)
|
||||
if changed:
|
||||
on_ui(lambda: self.refresh(fragment, changed))
|
||||
known = self._exists.setdefault((account, dialog_id), set())
|
||||
missing = [int(i) for i in result.get("missing") or [] if int(i) not in known]
|
||||
if missing:
|
||||
self._confirm(fragment, account, dialog_id, chat_id, me, missing[:100])
|
||||
if self.plugin.get_setting(RESTORE_MISSING, True):
|
||||
gone = [
|
||||
s
|
||||
for s in states
|
||||
if s.get("deleted_at") and int(s["message_id"]) not in present
|
||||
]
|
||||
if gone:
|
||||
self._restore(fragment, account, dialog_id, chat_id, me, gone)
|
||||
|
||||
def _confirm(self, fragment, account, dialog_id, chat_id, me, ids):
|
||||
"""Ask Telegram about ids the server has and the client lacks; those
|
||||
it no longer has were deleted while we were away."""
|
||||
from org.telegram.tgnet import TLRPC
|
||||
|
||||
def done(found, error):
|
||||
if found is None:
|
||||
diag.log(f"confirm {chat_id}: {error}")
|
||||
return
|
||||
gone = [
|
||||
i
|
||||
for i in ids
|
||||
if found.get(i) is None or isinstance(found[i], TLRPC.TL_messageEmpty)
|
||||
]
|
||||
self._exists.setdefault((account, dialog_id), set()).update(
|
||||
set(ids) - set(gone)
|
||||
)
|
||||
if not gone:
|
||||
return
|
||||
diag.log(f"confirmed deleted in {chat_id}: {gone}")
|
||||
store.mark_deleted(account, dialog_id, gone)
|
||||
if self.plugin.get_setting(REPORT_DELETIONS, True):
|
||||
in_background(lambda: self._report(account, dialog_id, chat_id, me, gone))
|
||||
|
||||
tg.get_messages(account, dialog_id, ids, done)
|
||||
|
||||
def _report(self, account, dialog_id, chat_id, me, ids):
|
||||
try:
|
||||
marked = self.api.deletions(me, chat_id, ids)
|
||||
diag.log(f"reported {len(ids)} deletions in {chat_id}, marked {marked}")
|
||||
except ApiError as exc:
|
||||
diag.log(f"report {chat_id}: {exc}")
|
||||
return
|
||||
# Sync again for the server's new state (and the logged copies).
|
||||
self.changed(account, dialog_id)
|
||||
|
||||
# restoring (background -> UI)
|
||||
|
||||
def _restore(self, fragment, account, dialog_id, chat_id, me, states):
|
||||
for state in states:
|
||||
message_id = int(state["message_id"])
|
||||
key = (account, dialog_id, message_id)
|
||||
if key in self._restoring:
|
||||
continue
|
||||
pointer, strip = self._source(me, chat_id, message_id, state)
|
||||
if pointer is None:
|
||||
continue
|
||||
self._restoring.add(key)
|
||||
self._fetch_copy(fragment, account, dialog_id, message_id, state, pointer, strip)
|
||||
|
||||
def _source(self, me, chat_id, message_id, state):
|
||||
"""Where to read the deleted message from: its last version (no
|
||||
header) when it was edited, else the saved copy (header stripped)."""
|
||||
if int(state.get("edit_count") or 0) > 0:
|
||||
try:
|
||||
versions = self.api.versions(me, chat_id, message_id)
|
||||
except ApiError as exc:
|
||||
diag.log(f"versions {chat_id}/{message_id}: {exc}")
|
||||
versions = None
|
||||
if versions and versions.get("chat_id") and versions.get("versions"):
|
||||
last = versions["versions"][-1]
|
||||
return {"chat_id": versions["chat_id"], "message_id": last["message_id"]}, False
|
||||
return state.get("logged"), True
|
||||
|
||||
def _fetch_copy(self, fragment, account, dialog_id, message_id, state, pointer, strip):
|
||||
channel_id = peers.channel_id_from_marked(pointer["chat_id"])
|
||||
copy_id = int(pointer["message_id"])
|
||||
|
||||
def done(found, error):
|
||||
copy = (found or {}).get(copy_id)
|
||||
if copy is None or getattr(copy, "message", None) is None and getattr(copy, "media", None) is None:
|
||||
self._restoring.discard((account, dialog_id, message_id))
|
||||
diag.log(f"copy {pointer} unavailable: {error}")
|
||||
return
|
||||
on_ui(lambda: self._insert(fragment, account, dialog_id, message_id, state, copy, strip))
|
||||
|
||||
tg.get_messages(account, -channel_id, [copy_id], done)
|
||||
|
||||
def _build(self, account, dialog_id, message_id, state, copy, strip):
|
||||
from java.util import ArrayList
|
||||
from org.telegram.messenger import MessageObject, MessagesController
|
||||
from org.telegram.tgnet import TLRPC
|
||||
|
||||
controller = MessagesController.getInstance(account)
|
||||
msg = TLRPC.TL_message()
|
||||
msg.id = message_id
|
||||
msg.dialog_id = dialog_id
|
||||
msg.peer_id = controller.getPeer(dialog_id)
|
||||
sender = state.get("sender_id")
|
||||
msg.from_id = (
|
||||
controller.getPeer(_android_peer_id(sender)) if sender else msg.peer_id
|
||||
)
|
||||
msg.flags |= 256
|
||||
msg.date = _epoch(state.get("source_date")) or int(copy.date)
|
||||
if int(state.get("edit_count") or 0) > 0:
|
||||
msg.edit_date = _epoch(state.get("edit_date")) or msg.date
|
||||
msg.flags |= 32768 # edited: the app draws its pencil
|
||||
text = copy.message or ""
|
||||
entities = [(int(e.offset), int(e.length), e) for e in java_list(copy.entities)]
|
||||
if strip:
|
||||
text, entities = header.strip(text, entities)
|
||||
msg.message = text
|
||||
msg.entities = ArrayList()
|
||||
for offset, length, entity in entities:
|
||||
entity.offset = offset
|
||||
entity.length = length
|
||||
msg.entities.add(entity)
|
||||
media = getattr(copy, "media", None)
|
||||
if media is not None and not isinstance(media, TLRPC.TL_messageMediaEmpty):
|
||||
msg.media = media
|
||||
msg.flags |= 512
|
||||
rich = getattr(copy, "rich_message", None)
|
||||
if rich is not None:
|
||||
if strip:
|
||||
_strip_rich_header(rich)
|
||||
plain = _rich_plain_text(rich)
|
||||
if plain is not None:
|
||||
# Text only: a normal bubble, as the original was.
|
||||
msg.message = plain
|
||||
msg.entities = ArrayList()
|
||||
else:
|
||||
msg.rich_message = rich
|
||||
msg.flags2 |= 1 << 12
|
||||
own = peers.own_user_id(account)
|
||||
msg.out = sender is not None and int(sender) == own
|
||||
msg.unread = False
|
||||
return MessageObject(account, msg, True, True)
|
||||
|
||||
def _insert(self, fragment, account, dialog_id, message_id, state, copy, strip):
|
||||
"""Hands the rebuilt message to the chat the way new messages arrive
|
||||
(ChatActivity.processNewMessages), so the app places it and keeps its
|
||||
own dicts and adapter consistent."""
|
||||
self._restoring.discard((account, dialog_id, message_id))
|
||||
messages = field(fragment, "messages")
|
||||
if messages is None:
|
||||
return
|
||||
if _loaded(fragment, message_id) or any(
|
||||
m is not None and int(m.getId()) == message_id for m in java_list(messages)
|
||||
):
|
||||
return
|
||||
from java import jboolean
|
||||
from java.util import ArrayList
|
||||
|
||||
obj = self._build(account, dialog_id, message_id, state, copy, strip)
|
||||
store.mark_deleted(account, dialog_id, [message_id])
|
||||
batch = ArrayList()
|
||||
batch.add(obj)
|
||||
_process_new_messages().invoke(fragment, batch, jboolean(False))
|
||||
diag.log(f"restored {dialog_id}/{message_id}")
|
||||
|
||||
# cells (UI thread)
|
||||
|
||||
def refresh(self, fragment, ids):
|
||||
"""Rebind the rows of `ids`: their alpha or pill (and so their height)
|
||||
changed, which only a relayout of the list places correctly."""
|
||||
messages = field(fragment, "messages")
|
||||
adapter = field(fragment, "chatAdapter")
|
||||
if messages is None or adapter is None:
|
||||
return
|
||||
# A rebind alone reuses the cached measure: flag the cells for layout.
|
||||
list_view = field(fragment, "chatListView")
|
||||
if list_view is not None:
|
||||
for i in range(list_view.getChildCount()):
|
||||
child = list_view.getChildAt(i)
|
||||
message = child.getMessageObject() if hasattr(child, "getMessageObject") else None
|
||||
if message is not None and int(message.getId()) in ids:
|
||||
child.forceLayout()
|
||||
start = int(adapter.messagesStartRow)
|
||||
for index, m in enumerate(java_list(messages)):
|
||||
if m is not None and int(m.getId()) in ids:
|
||||
m.forceUpdate = True # rebinding lays the cell out again (time row)
|
||||
adapter.notifyItemChanged(start + index)
|
||||
|
||||
def on_edited(self, account, message):
|
||||
"""An edit arrived: sync that chat again once the server logged it."""
|
||||
from org.telegram.messenger import MessageObject
|
||||
|
||||
self.changed(
|
||||
int(account), int(MessageObject.getDialogId(message)), _EDIT_DELAY_MS
|
||||
)
|
||||
|
||||
def changed(self, account, dialog_id, delay_ms=0):
|
||||
"""An edit/delete event in a chat: its check is expired, and the chat
|
||||
synced again (after `delay_ms`) when it is open."""
|
||||
store.expire_coverage(account, dialog_id)
|
||||
fragment = self._fragments.get((account, dialog_id))
|
||||
if fragment is not None:
|
||||
on_ui(lambda: self.schedule(fragment), delay_ms)
|
||||
|
||||
def refresh_dialog(self, account, dialog_id, ids):
|
||||
fragment = self._fragments.get((account, dialog_id))
|
||||
if fragment is not None:
|
||||
on_ui(lambda: self.refresh(fragment, set(int(i) for i in ids)))
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Chaquopy/Java helpers shared by the hooks."""
|
||||
|
||||
import diag
|
||||
|
||||
|
||||
def java_list(items):
|
||||
"""A java.util.List as a Python list (Java lists aren't iterable here)."""
|
||||
if items is None:
|
||||
return []
|
||||
return [items.get(i) for i in range(items.size())]
|
||||
|
||||
|
||||
def java_ints(items):
|
||||
return [int(v) for v in java_list(items)]
|
||||
|
||||
|
||||
def java_class(name):
|
||||
"""The real java.lang.Class: find_class returns a Chaquopy wrapper whose
|
||||
getClass() is the Class (see chatSummaries/hooks/unread_hook.py)."""
|
||||
from hook_utils import find_class
|
||||
|
||||
wrapper = find_class(name)
|
||||
if wrapper is None:
|
||||
return None
|
||||
try:
|
||||
return wrapper.getClass()
|
||||
except Exception:
|
||||
return wrapper
|
||||
|
||||
|
||||
def methods_named(class_name, name):
|
||||
cls = java_class(class_name)
|
||||
if cls is None:
|
||||
diag.log(f"class not found: {class_name}")
|
||||
return []
|
||||
return [m for m in cls.getDeclaredMethods() if m.getName() == name]
|
||||
|
||||
|
||||
def hook_methods(plugin, class_name, name, hook, arity=None):
|
||||
"""Hooks every overload of `class_name.name` (or those with `arity`
|
||||
parameters); returns the unhook handles."""
|
||||
handles = []
|
||||
for method in methods_named(class_name, name):
|
||||
if arity is not None and len(method.getParameterTypes()) != arity:
|
||||
continue
|
||||
try:
|
||||
handle = plugin.hook_method(method, hook)
|
||||
if handle is not None:
|
||||
handles.append(handle)
|
||||
except Exception as exc:
|
||||
diag.error(f"hook {class_name}.{name}", exc)
|
||||
if not handles:
|
||||
diag.log(f"nothing hooked for {class_name}.{name}")
|
||||
return handles
|
||||
|
||||
|
||||
def field(obj, name):
|
||||
"""A (possibly private) field of a Java object."""
|
||||
value = getattr(obj, name, None)
|
||||
if value is not None:
|
||||
return value
|
||||
try:
|
||||
from hook_utils import get_private_field
|
||||
|
||||
return get_private_field(obj, name)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def on_ui(fn, delay_ms=0):
|
||||
from android_utils import run_on_ui_thread
|
||||
|
||||
def run():
|
||||
try:
|
||||
fn()
|
||||
except Exception as exc:
|
||||
diag.error("ui task", exc)
|
||||
|
||||
run_on_ui_thread(run, delay_ms)
|
||||
|
||||
|
||||
def in_background(fn):
|
||||
"""Runs `fn` off the UI thread; exceptions are logged, not lost."""
|
||||
import threading
|
||||
|
||||
def run():
|
||||
try:
|
||||
fn()
|
||||
except Exception as exc:
|
||||
diag.error("background task", exc)
|
||||
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
+146
-37
@@ -1,50 +1,159 @@
|
||||
from base_plugin import BasePlugin, HookResult, HookStrategy
|
||||
from android_utils import log
|
||||
from ui.settings import Header, Input, Text
|
||||
from greeting import render, TemplateError
|
||||
from base_plugin import BasePlugin, HookResult
|
||||
from ui.settings import Header, Input, Selector, Switch, Text
|
||||
|
||||
DEFAULT_TEMPLATE = "Hello, {name}!"
|
||||
import accounts
|
||||
import diag
|
||||
from api import Api
|
||||
from config import (
|
||||
ALPHA_CHOICES,
|
||||
API_TOKEN,
|
||||
DEFAULTS,
|
||||
CACHE_DAYS,
|
||||
CACHE_DAYS_CHOICES,
|
||||
DELETED_ALPHA,
|
||||
KEEP_DELETED,
|
||||
REPORT_DELETIONS,
|
||||
RESTORE_MISSING,
|
||||
SERVER_URL,
|
||||
)
|
||||
from hooks.chat import EDIT_UPDATES, ChatSync
|
||||
from hooks.deletes import UPDATES, Deletes
|
||||
from hooks.links import Links
|
||||
from hooks.menu import Menu
|
||||
from hooks.render import Render
|
||||
from jutil import in_background, on_ui
|
||||
from views.history import History
|
||||
|
||||
|
||||
class HelloPlugin(BasePlugin):
|
||||
def _bulletin(text, error=False):
|
||||
def show():
|
||||
from ui.bulletin import BulletinHelper
|
||||
|
||||
(BulletinHelper.show_error if error else BulletinHelper.show_info)(text)
|
||||
|
||||
on_ui(show)
|
||||
|
||||
|
||||
class MessageLoggerPlugin(BasePlugin):
|
||||
def on_plugin_load(self):
|
||||
log("hello_bundle loaded")
|
||||
self.add_on_send_message_hook()
|
||||
self.api = Api(self)
|
||||
self.history = History(self, self.api)
|
||||
self.render = Render(self)
|
||||
self.chats = ChatSync(self, self.api)
|
||||
self.deletes = Deletes(self, self.api, self.chats)
|
||||
self.links = Links(self)
|
||||
self.menu = Menu(self, self.history.open)
|
||||
for part in (self.render, self.chats, self.deletes, self.links, self.menu):
|
||||
try:
|
||||
part.install()
|
||||
except Exception as exc:
|
||||
diag.error(f"install {type(part).__name__}", exc)
|
||||
in_background(accounts.refresh)
|
||||
in_background(self.purge_cache)
|
||||
diag.log("loaded")
|
||||
|
||||
def purge_cache(self, force=False):
|
||||
"""Removes cached answers older than the configured lifetime; at most
|
||||
once a day (the day turns at local midnight) unless `force`."""
|
||||
import store
|
||||
|
||||
index = int(self.get_setting(CACHE_DAYS, DEFAULTS[CACHE_DAYS]) or 0)
|
||||
index = max(0, min(index, len(CACHE_DAYS_CHOICES) - 1))
|
||||
store.purge_old(CACHE_DAYS_CHOICES[index][1], force=force)
|
||||
|
||||
def on_plugin_unload(self):
|
||||
log("hello_bundle unloaded")
|
||||
for part in ("render", "chats", "deletes", "links", "menu"):
|
||||
if hasattr(self, part):
|
||||
getattr(self, part).uninstall()
|
||||
diag.log("unloaded")
|
||||
|
||||
def on_update_hook(self, update_name, account, update):
|
||||
if update_name in EDIT_UPDATES:
|
||||
try:
|
||||
self.chats.on_edited(account, update.message)
|
||||
except Exception as exc:
|
||||
diag.error("on_update_hook edit", exc)
|
||||
return HookResult()
|
||||
if update_name in UPDATES:
|
||||
try:
|
||||
return self.deletes.on_update(update_name, account, update)
|
||||
except Exception as exc:
|
||||
diag.error("on_update_hook", exc)
|
||||
return HookResult()
|
||||
|
||||
# settings
|
||||
|
||||
def _test_connection(self, _view=None):
|
||||
def run():
|
||||
if not self.api.configured:
|
||||
_bulletin("Message Logger: set the server URL and API tokens", True)
|
||||
return
|
||||
lines, failed = [], False
|
||||
for prefix, me in self.api.me():
|
||||
if isinstance(me, dict):
|
||||
lines.append(f"{me.get('master_name')} ✓")
|
||||
else:
|
||||
failed = True
|
||||
lines.append(f"{prefix}…: {me}")
|
||||
_bulletin("Masters: " + "; ".join(lines), failed)
|
||||
|
||||
in_background(run)
|
||||
|
||||
def create_settings(self):
|
||||
return [
|
||||
Header(text="Hello Bundle"),
|
||||
Header(text="Server"),
|
||||
Input(
|
||||
key="template",
|
||||
text="Greeting template",
|
||||
default=DEFAULT_TEMPLATE,
|
||||
subtext="Use {name} as placeholder.",
|
||||
icon="msg_edit",
|
||||
key=SERVER_URL,
|
||||
text="telegram-panel URL",
|
||||
default=DEFAULTS[SERVER_URL],
|
||||
subtext="e.g. https://panel.example.com",
|
||||
icon="msg_link",
|
||||
),
|
||||
Text(
|
||||
text="Example",
|
||||
subtext=".hello alice smith -> Hello, Alice Smith!",
|
||||
icon="msg_info",
|
||||
Input(
|
||||
key=API_TOKEN,
|
||||
text="API tokens",
|
||||
default=DEFAULTS[API_TOKEN],
|
||||
subtext="One per master, separated by spaces (panel: Settings → API Tokens)",
|
||||
icon="msg_permissions",
|
||||
),
|
||||
Text(text="Test connection", icon="msg_info", on_click=self._test_connection),
|
||||
Header(text="Deleted messages"),
|
||||
Switch(
|
||||
key=KEEP_DELETED,
|
||||
text="Keep deleted messages",
|
||||
default=DEFAULTS[KEEP_DELETED],
|
||||
subtext="Messages others delete stay in the chat",
|
||||
icon="msg_delete",
|
||||
),
|
||||
Switch(
|
||||
key=RESTORE_MISSING,
|
||||
text="Restore from logger",
|
||||
default=DEFAULTS[RESTORE_MISSING],
|
||||
subtext="Bring back logged messages deleted while the app was closed",
|
||||
icon="msg_retry",
|
||||
),
|
||||
Switch(
|
||||
key=REPORT_DELETIONS,
|
||||
text="Report deletions",
|
||||
default=DEFAULTS[REPORT_DELETIONS],
|
||||
subtext="Tell the server about deletions this client sees",
|
||||
icon="msg_sync",
|
||||
),
|
||||
Selector(
|
||||
key=CACHE_DAYS,
|
||||
text="Cache lifetime",
|
||||
default=DEFAULTS[CACHE_DAYS],
|
||||
items=[label for label, _ in CACHE_DAYS_CHOICES],
|
||||
icon="msg_clear",
|
||||
on_change=lambda _index: in_background(
|
||||
lambda: self.purge_cache(force=True)
|
||||
),
|
||||
),
|
||||
Selector(
|
||||
key=DELETED_ALPHA,
|
||||
text="Deleted message opacity",
|
||||
default=DEFAULTS[DELETED_ALPHA],
|
||||
items=[label for label, _ in ALPHA_CHOICES],
|
||||
icon="msg_hide",
|
||||
),
|
||||
]
|
||||
|
||||
def on_send_message_hook(self, account, params):
|
||||
message = getattr(params, "message", None)
|
||||
if not isinstance(message, str):
|
||||
return HookResult()
|
||||
stripped = message.strip()
|
||||
if not stripped.startswith(".hello"):
|
||||
return HookResult()
|
||||
parts = stripped.split(" ", 1)
|
||||
name = parts[1] if len(parts) > 1 else ""
|
||||
if not name.strip():
|
||||
params.message = "Usage: .hello <name>"
|
||||
else:
|
||||
try:
|
||||
params.message = render(self.get_setting("template", DEFAULT_TEMPLATE), name)
|
||||
except TemplateError as exc:
|
||||
params.message = "Bad template: " + str(exc)
|
||||
return HookResult(strategy=HookStrategy.MODIFY, params=params)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Telegram Android dialog ids <-> the server's marked (Telethon) chat ids.
|
||||
|
||||
Android: users are positive, basic groups and channels both `-id`.
|
||||
Telethon: users positive, basic groups `-id`, channels `-(10**12 + id)`.
|
||||
"""
|
||||
|
||||
CHANNEL_OFFSET = 10**12
|
||||
|
||||
|
||||
def _controller(account):
|
||||
from org.telegram.messenger import MessagesController
|
||||
|
||||
return MessagesController.getInstance(account)
|
||||
|
||||
|
||||
def is_channel_dialog(account, dialog_id):
|
||||
if dialog_id >= 0:
|
||||
return False
|
||||
from org.telegram.messenger import ChatObject
|
||||
|
||||
chat = _controller(account).getChat(-dialog_id)
|
||||
return chat is not None and ChatObject.isChannel(chat)
|
||||
|
||||
|
||||
def marked_chat_id(account, dialog_id):
|
||||
"""The server's chat id for a dialog, or None (secret chats etc.)."""
|
||||
from org.telegram.messenger import DialogObject
|
||||
|
||||
dialog_id = int(dialog_id)
|
||||
if DialogObject.isEncryptedDialog(dialog_id):
|
||||
return None
|
||||
if dialog_id > 0:
|
||||
return dialog_id
|
||||
if is_channel_dialog(account, dialog_id):
|
||||
return -(CHANNEL_OFFSET + -dialog_id)
|
||||
return dialog_id
|
||||
|
||||
|
||||
def channel_id_from_marked(chat_id):
|
||||
"""Channel id of a marked `-100…` chat id."""
|
||||
return -int(chat_id) - CHANNEL_OFFSET
|
||||
|
||||
|
||||
def own_user_id(account):
|
||||
from org.telegram.messenger import UserConfig
|
||||
|
||||
return int(UserConfig.getInstance(account).getClientUserId())
|
||||
+339
@@ -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
|
||||
@@ -1,2 +0,0 @@
|
||||
def normalize(name: str) -> str:
|
||||
return " ".join(w[:1].upper() + w[1:] for w in name.split())
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Telegram requests through the client's own session.
|
||||
|
||||
Sent with ConnectionsManager.sendRequest and a Java RequestDelegate proxy,
|
||||
kept strongly referenced until it fires: on 12.10.6 the SDK's send_request
|
||||
never invoked the callback for these requests.
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
||||
import diag
|
||||
from jutil import java_list
|
||||
|
||||
_pending = set()
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
_Delegate = None
|
||||
|
||||
|
||||
def _delegate_class():
|
||||
"""A Java RequestDelegate calling a Python function (created once)."""
|
||||
global _Delegate
|
||||
if _Delegate is None:
|
||||
from java import dynamic_proxy
|
||||
from org.telegram.tgnet import RequestDelegate
|
||||
|
||||
class Delegate(dynamic_proxy(RequestDelegate)):
|
||||
def __init__(self, fn):
|
||||
super().__init__()
|
||||
self.fn = fn
|
||||
|
||||
def run(self, response, error):
|
||||
self.fn(response, error)
|
||||
|
||||
_Delegate = Delegate
|
||||
return _Delegate
|
||||
|
||||
|
||||
def _send(account, request, callback):
|
||||
from org.telegram.tgnet import ConnectionsManager
|
||||
|
||||
box = []
|
||||
|
||||
def done(response, error):
|
||||
with _lock:
|
||||
for proxy in box:
|
||||
_pending.discard(proxy)
|
||||
try:
|
||||
callback(response, error)
|
||||
except Exception as exc:
|
||||
diag.error("tg callback", exc)
|
||||
|
||||
delegate = _delegate_class()(done)
|
||||
box.append(delegate)
|
||||
with _lock:
|
||||
_pending.add(delegate)
|
||||
ConnectionsManager.getInstance(account).sendRequest(request, delegate)
|
||||
|
||||
|
||||
def _message_ids(ids):
|
||||
"""getMessages ids: ArrayList<Integer>. `jint` forces Integer boxing;
|
||||
a plain Python int (even from Integer.valueOf) is boxed as Long, which
|
||||
fails serialization on the network thread without any callback."""
|
||||
from java import jint
|
||||
from java.util import ArrayList
|
||||
|
||||
items = ArrayList()
|
||||
for i in ids:
|
||||
items.add(jint(int(i)))
|
||||
return items
|
||||
|
||||
|
||||
def channel_chat(account, channel_id):
|
||||
"""The cached TLRPC.Chat of a channel, loading it from storage if needed."""
|
||||
from org.telegram.messenger import MessagesController, MessagesStorage
|
||||
|
||||
controller = MessagesController.getInstance(account)
|
||||
chat = controller.getChat(int(channel_id))
|
||||
if chat is None:
|
||||
try:
|
||||
chat = MessagesStorage.getInstance(account).getChatSync(int(channel_id))
|
||||
if chat is not None:
|
||||
controller.putChat(chat, True)
|
||||
except Exception as exc:
|
||||
diag.error("tg.channel_chat", exc)
|
||||
return chat
|
||||
|
||||
|
||||
def get_messages(account, dialog_id, ids, callback):
|
||||
"""Fetches `ids` of a dialog (a channel when `dialog_id` is one).
|
||||
|
||||
`callback(messages, error_text)`: messages by id (TL_messageEmpty for
|
||||
deleted ones), or None and an error.
|
||||
"""
|
||||
from org.telegram.messenger import ChatObject, MessagesController
|
||||
from org.telegram.tgnet import TLRPC
|
||||
|
||||
controller = MessagesController.getInstance(account)
|
||||
dialog_id = int(dialog_id)
|
||||
chat = channel_chat(account, -dialog_id) if dialog_id < 0 else None
|
||||
if chat is not None and ChatObject.isChannel(chat):
|
||||
request = TLRPC.TL_channels_getMessages()
|
||||
request.channel = controller.getInputChannel(chat)
|
||||
if request.channel is None or getattr(request.channel, "access_hash", 0) == 0:
|
||||
callback(None, "not a member of that chat")
|
||||
return
|
||||
elif dialog_id < 0 and chat is None:
|
||||
callback(None, "chat not found (not a member?)")
|
||||
return
|
||||
else:
|
||||
request = TLRPC.TL_messages_getMessages()
|
||||
request.id = _message_ids(ids)
|
||||
|
||||
def done(response, error):
|
||||
if error is not None or response is None:
|
||||
callback(None, getattr(error, "text", None) or "request failed")
|
||||
return
|
||||
try:
|
||||
controller.putUsers(response.users, False)
|
||||
controller.putChats(response.chats, False)
|
||||
except Exception:
|
||||
pass
|
||||
found = {}
|
||||
for message in java_list(response.messages):
|
||||
found[int(message.id)] = message
|
||||
callback(found, None)
|
||||
|
||||
_send(account, request, done)
|
||||
Whitespace-only changes.
@@ -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
|
||||
|
||||
@@ -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)
|
||||
+124
-47
@@ -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()
|
||||
links = load("hooks.links")
|
||||
check(
|
||||
"render() traceback names src/greeting/template.py",
|
||||
"src/greeting/template.py" in traceback_text,
|
||||
f"traceback:\n{traceback_text}",
|
||||
"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)
|
||||
|
||||
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(
|
||||
"traceback includes the raising source line text",
|
||||
"unsupported placeholder" in traceback_text,
|
||||
f"traceback:\n{traceback_text}",
|
||||
"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),
|
||||
)
|
||||
|
||||
check(
|
||||
"sys.modules gained no entry for bundled module names",
|
||||
not ({"greeting", "textutil", "main"} & new_modules),
|
||||
f"leaked: {({'greeting', 'textutil', 'main'} & new_modules)}",
|
||||
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)
|
||||
|
||||
Reference in new issue
Block a user