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
|
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.
|
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
|
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
|
`.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.
|
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",
|
"id": "message_logger",
|
||||||
"name": "Hello Bundle",
|
"name": "Message Logger",
|
||||||
"description": "Multi-file exteraGram plugin built by tools/bundle.py",
|
"description": "Shows deleted and edited messages and their edit history from your telegram-panel message logger.",
|
||||||
"author": "@yourhandle",
|
"author": "@lyuksovannyy",
|
||||||
"version": "1.0.0",
|
"version": "0.1.0",
|
||||||
"icon": "exteraPlugins/1",
|
"icon": "exteraPlugins/1",
|
||||||
"app_version": ">=12.5.1",
|
"app_version": ">=12.5.1",
|
||||||
"sdk_version": ">=1.4.4.3",
|
"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 base_plugin import BasePlugin, HookResult
|
||||||
from android_utils import log
|
from ui.settings import Header, Input, Selector, Switch, Text
|
||||||
from ui.settings import Header, Input, Text
|
|
||||||
from greeting import render, TemplateError
|
|
||||||
|
|
||||||
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):
|
def on_plugin_load(self):
|
||||||
log("hello_bundle loaded")
|
self.api = Api(self)
|
||||||
self.add_on_send_message_hook()
|
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):
|
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):
|
def create_settings(self):
|
||||||
return [
|
return [
|
||||||
Header(text="Hello Bundle"),
|
Header(text="Server"),
|
||||||
Input(
|
Input(
|
||||||
key="template",
|
key=SERVER_URL,
|
||||||
text="Greeting template",
|
text="telegram-panel URL",
|
||||||
default=DEFAULT_TEMPLATE,
|
default=DEFAULTS[SERVER_URL],
|
||||||
subtext="Use {name} as placeholder.",
|
subtext="e.g. https://panel.example.com",
|
||||||
icon="msg_edit",
|
icon="msg_link",
|
||||||
),
|
),
|
||||||
Text(
|
Input(
|
||||||
text="Example",
|
key=API_TOKEN,
|
||||||
subtext=".hello alice smith -> Hello, Alice Smith!",
|
text="API tokens",
|
||||||
icon="msg_info",
|
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:
|
class HookResult:
|
||||||
strategy: HookStrategy = HookStrategy.DEFAULT
|
strategy: HookStrategy = HookStrategy.DEFAULT
|
||||||
params: object = None
|
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] = []
|
LOG_LINES: list[str] = []
|
||||||
@@ -84,6 +105,9 @@ def install_fakes() -> None:
|
|||||||
base_plugin_mod.BasePlugin = BasePlugin
|
base_plugin_mod.BasePlugin = BasePlugin
|
||||||
base_plugin_mod.HookResult = HookResult
|
base_plugin_mod.HookResult = HookResult
|
||||||
base_plugin_mod.HookStrategy = HookStrategy
|
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
|
sys.modules["base_plugin"] = base_plugin_mod
|
||||||
|
|
||||||
android_utils_mod = types.ModuleType("android_utils")
|
android_utils_mod = types.ModuleType("android_utils")
|
||||||
@@ -94,18 +118,20 @@ def install_fakes() -> None:
|
|||||||
ui_mod.__path__ = []
|
ui_mod.__path__ = []
|
||||||
sys.modules["ui"] = ui_mod
|
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")
|
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,), {}))
|
setattr(ui_settings_mod, cls_name, type(cls_name, (_SettingsRow,), {}))
|
||||||
sys.modules["ui.settings"] = ui_settings_mod
|
sys.modules["ui.settings"] = ui_settings_mod
|
||||||
ui_mod.settings = ui_settings_mod
|
ui_mod.settings = ui_settings_mod
|
||||||
|
|
||||||
|
|
||||||
class SimpleParams:
|
|
||||||
def __init__(self, message):
|
|
||||||
self.message = message
|
|
||||||
|
|
||||||
|
|
||||||
KEY_TO_DUNDER = {
|
KEY_TO_DUNDER = {
|
||||||
"id": "__id__",
|
"id": "__id__",
|
||||||
"name": "__name__",
|
"name": "__name__",
|
||||||
@@ -147,7 +173,7 @@ def main(argv=None) -> int:
|
|||||||
source = bundle_path.read_text(encoding="utf-8")
|
source = bundle_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
modules_before = set(sys.modules)
|
modules_before = set(sys.modules)
|
||||||
ns = {"__name__": "hello_bundle"}
|
ns = {"__name__": "message_logger"}
|
||||||
exec(compile(source, str(bundle_path), "exec"), ns)
|
exec(compile(source, str(bundle_path), "exec"), ns)
|
||||||
new_modules = set(sys.modules) - modules_before
|
new_modules = set(sys.modules) - modules_before
|
||||||
|
|
||||||
@@ -194,63 +220,114 @@ def main(argv=None) -> int:
|
|||||||
)
|
)
|
||||||
|
|
||||||
plugin = plugin_cls()
|
plugin = plugin_cls()
|
||||||
LOG_LINES.clear()
|
keys = {getattr(row, "key", None) for row in plugin.create_settings()}
|
||||||
plugin.on_plugin_load()
|
|
||||||
check(
|
check(
|
||||||
"on_plugin_load emits load log line",
|
"settings expose server URL and API token",
|
||||||
any("hello_bundle loaded" in line for line in LOG_LINES),
|
{"server_url", "api_token"} <= keys,
|
||||||
f"log lines: {LOG_LINES}",
|
f"keys: {keys}",
|
||||||
)
|
)
|
||||||
|
|
||||||
params = SimpleParams(".hello alice smith")
|
load = ns.get("_bundle_load")
|
||||||
result = plugin.on_send_message_hook(account=0, params=params)
|
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(
|
check(
|
||||||
"'.hello alice smith' renders greeting",
|
"entities shifted past the header (UTF-16)",
|
||||||
params.message == "Hello, Alice Smith!",
|
entities == [(6, 5, "bold")],
|
||||||
f"got {params.message!r}",
|
repr(entities),
|
||||||
)
|
)
|
||||||
check(
|
check(
|
||||||
"on_send_message_hook returns MODIFY strategy",
|
"text without a header is kept",
|
||||||
result.strategy == HookStrategy.MODIFY,
|
header.strip("plain", [(0, 5, "x")]) == ("plain", [(0, 5, "x")]),
|
||||||
f"got {result.strategy}",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
plugin.set_setting("template", "{nope}")
|
peers = load("peers")
|
||||||
params2 = SimpleParams(".hello someone")
|
|
||||||
plugin.on_send_message_hook(account=0, params=params2)
|
|
||||||
check(
|
check(
|
||||||
"bad template produces 'Bad template:' message",
|
"marked channel id -> channel id",
|
||||||
params2.message.startswith("Bad template:"),
|
peers.channel_id_from_marked(-1000000001234) == 1234,
|
||||||
f"got {params2.message!r}",
|
)
|
||||||
|
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
|
links = load("hooks.links")
|
||||||
# in the executed namespace — call render() directly to inspect the raised
|
|
||||||
# traceback (proves linecache registration for bundled, non-file modules).
|
|
||||||
render = ns.get("render")
|
|
||||||
template_error = ns.get("TemplateError")
|
|
||||||
traceback_text = ""
|
|
||||||
if render is not None and template_error is not None:
|
|
||||||
try:
|
|
||||||
render("{nope}", "x")
|
|
||||||
except template_error:
|
|
||||||
traceback_text = traceback.format_exc()
|
|
||||||
check(
|
check(
|
||||||
"render() traceback names src/greeting/template.py",
|
"private message links parse (with and without topic)",
|
||||||
"src/greeting/template.py" in traceback_text,
|
links.parse("https://t.me/c/123/45") == (-123, 45)
|
||||||
f"traceback:\n{traceback_text}",
|
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(
|
check(
|
||||||
"traceback includes the raising source line text",
|
"sync plan: nothing while the loaded range is checked",
|
||||||
"unsupported placeholder" in traceback_text,
|
plan((40, 2**31 - 1, 9_000), 50, 2**31 - 1, 10_000) is None,
|
||||||
f"traceback:\n{traceback_text}",
|
)
|
||||||
|
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(
|
store = load("store")
|
||||||
"sys.modules gained no entry for bundled module names",
|
store.put_server_states(
|
||||||
not ({"greeting", "textutil", "main"} & new_modules),
|
0, -5, [{"message_id": i, "edit_count": 1} for i in range(1, 1201)]
|
||||||
f"leaked: {({'greeting', 'textutil', 'main'} & new_modules)}",
|
|
||||||
)
|
)
|
||||||
|
(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:
|
if _FAILURES:
|
||||||
print(f"\n{len(_FAILURES)} check(s) failed: {', '.join(_FAILURES)}", file=sys.stderr)
|
print(f"\n{len(_FAILURES)} check(s) failed: {', '.join(_FAILURES)}", file=sys.stderr)
|
||||||
|
|||||||
Reference in new issue
Block a user