6 Commits
Author SHA1 Message Date
lyuksovannyyandClaude Opus 5.5 8535bde5ae Message Logger: trash icon under the profile menu on logged photos
Publish plugin / publish (push) Successful in 4s
The expanded profile gallery shows a red trash icon under the menu button
while it shows a logged photo the chat no longer has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:12:59 +02:00
lyuksovannyyandClaude Opus 5.5 dba0c4f833 Message Logger: restore deep in long chats, media through the master, photo fixes
Publish plugin / publish (push) Successful in 4s
- Deleted messages within the loaded part of a long chat are placed by the
  app among the others (not only at the chat's end), without counting as
  unread; restores no longer repeat every second.
- Logger copies and their media are read through a member of the log group
  signed in here (this account, else the master); the shared file cache
  lets the current account show them.
- Logged chat photos: asked for once per 10 minutes, all sizes downloaded
  (no waits on inline thumbnails), added once the photo viewer closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:12:23 +02:00
lyuksovannyyandClaude Opus 5.5 362b358d8d Message Logger: hidden messages on the server, removed chats, old chat photos
Publish plugin / publish (push) Successful in 6s
- Hidden deleted messages are marked on the server (local marks migrated);
  "Reveal hidden messages" in the chat menu, Unhide in the message menu and
  the selection bar.
- Chats an account lost are reported, listed among its chats again and
  opened read-only with their logged messages, all deleted.
- Logged chat photos: a removed chat's avatar, and in a chat's photos the
  ones it no longer has, translucent with a trash icon in the viewer's
  previews.
- Fix: the deleted-post comments thread is answered locally again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:12:33 +02:00
lyuksovannyyandClaude Opus 5.5 0176f7099d Message Logger: keep own deletions, remove from view, reset marks
Publish plugin / publish (push) Successful in 4s
- Delete dialog: "Keep message for myself" checkbox keeps your deletion on
  this device (marked deleted); unticked, the message is hidden and never
  restored. Already deleted messages get a plain "Remove from view" dialog
  (fixes the chat staying dimmed/touch-blocked after it).
- "Reset deleted/edited marks" in the chat menu (Plugins) and, for all
  chats, in the plugin settings.
- Fix restored text-only messages showing as empty bubbles (rich text
  wrappers like links lost their text); missing settings icons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:59:48 +02:00
lyuksovannyyandClaude Opus 5.5 bbe4a557a8 [no bump] Shift plugin icon index down by one
Publish plugin / publish (push) Successful in 4s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:20:24 +02:00
lyuksovannyyandClaude Opus 5.5 b86cc8a1c8 [no bump] Move technical docs to docs/, add icon and PNG export tool
Publish plugin / publish (push) Successful in 4s
README now carries the icon, TL;DR, description and features; the
technical sections live in docs/. tools/icon_png.py renders
images/icon.svg to a square PNG (512x512 by default, --resolution N).
plugin.json points at the plugin's own icon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:16:37 +02:00
24 changed files with 3111 additions and 311 deletions

No files matched your search

+59 -217
View File
@@ -1,229 +1,71 @@
# Message Logger (exteraGram plugin)
<p align="center">
<img src="images/icon.svg" width="128" height="128" alt="Message Logger icon">
</p>
Shows what a [telegram-panel](../../telegram-panel) message logger saved, natively in chats:
<h1 align="center">Message Logger</h1>
- 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;
- the comments button of a deleted channel post (whose thread Telegram deletes with it) opens
the app's own comments section for it — avatars, names, replies, media — filled from the
logged comments (the thread's history request is answered locally);
- 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.
**TL;DR:** an [exteraGram](https://exteragram.app) plugin that keeps deleted messages in
your chats and shows the edit history of edited ones, using what your
[telegram-panel](../../telegram-panel) message logger saved.
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.
## Description
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.
Message Logger connects exteraGram to a telegram-panel message logger and shows its data
natively in chats. Messages other people delete stay visible, edited messages get a full
edit history, and comments of deleted channel posts can still be opened. Marks are cached on
the device, so a chat opened again, even offline, shows them at once.
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).
## Features
The rest of this README describes the build toolchain this repo came from (pluginBase).
- **Deleted messages stay.** Messages others delete remain in the chat at 75% opacity with a
red trash icon (from the active icon pack) in the time row. Messages deleted while the app
was closed are confirmed with Telegram and restored from the logger copy.
- **Keep your own deletions.** The delete dialog gets a *Keep message for myself* checkbox: ticked,
the deletion goes to Telegram but the message stays on this device, marked deleted.
Unticked (the default), it is deleted as usual and hidden, so it isn't restored from the
logger. Deleting a message that is already deleted shows a plain dialog that hides it.
- **Hidden messages.** Hidden deleted messages are marked on the server, so every client
skips them. *Reveal hidden messages* in the chat's menu (Plugins) brings them back until
tapped again; a revealed one has *Unhide* in its menu, and selecting several shows an
Unhide button in the selection bar.
- **Removed chats.** Chats an account lost (left, kicked, deleted by you or the other side)
stay in the chat list with their logged messages, read-only and all marked deleted, until
the chat is back. Chats you delete yourself aren't listed; deleting a listed one hides it.
*Show removed chats* in the settings turns this off.
- **Old chat photos.** Chat photo changes are posted into the chat's log topic. A removed chat
shows its latest logged photo, and a chat's photos also list the logged ones it no longer
has: translucent, with a trash icon, in the photo viewer's previews.
- **Edit history.** *Edit history* in the message menu (right above Forward, for edited
messages) opens every version as a mock chat, also for the logger groups' own posts, with
an *Open topic* button for the history topic in the activity group.
- **Comments of deleted channel posts.** The comments button of a deleted post opens the
app's own comments section for it (avatars, names, replies, media), filled from the logged
comments.
- **Links into chats you're not in.** 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.
- **Reset marks.** *Reset deleted/edited marks* in the chat's menu (Plugins) forgets that
chat's cached marks and checks it with the server again; the same row in the plugin
settings does it for every chat. Hidden messages stay hidden.
- **Cache lifetime.** Cached answers are kept for 1, 7, 30, 90 or 365 days, or forever
(30 by default).
## Plugin base toolchain
The account reading the logged copies must be a member of the logger and activity groups.
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.
## Setup
## How exteraGram plugins actually work
1. In the panel, create a token per master, the account owning the logger groups
(Settings → API Tokens). Any Telegram account can use a token.
2. In the plugin settings, enter the panel URL and the tokens (separated by spaces).
3. Tap *Test connection*.
A plugin, as exteraGram loads it, is exactly **one Python file** on the device:
`/data/user/0/com.exteragram.messenger/files/plugins/<plugin_id>.py`. The distribution
convention names the same content `<id>.plugin`.
For diagnostics, see the gotchas section of [docs/sdk-notes.md](docs/sdk-notes.md).
The app reads plugin metadata **statically via AST** — it looks for top-level constant
assignments (`__id__`, `__name__`, `__description__`, `__author__`, `__version__`,
`__icon__`, `__app_version__`, `__sdk_version__`, `__requirements__`) without executing the
file. Only `__id__` and `__name__` are required.
## Documentation
The file must define exactly one class inheriting `BasePlugin` (`from base_plugin import
BasePlugin`). Lifecycle methods (`on_plugin_load`, `on_plugin_unload`, `create_settings`) and
hooks (`on_send_message_hook`, `pre_request_hook`, `post_request_hook`, `on_update_hook(s)`,
`on_app_event`) are all called by the host; hooks must be registered inside
`on_plugin_load` (e.g. `self.add_on_send_message_hook()`). A hook returns a
`HookResult(strategy=HookStrategy.MODIFY, params=params)` to apply changes, or a bare
`HookResult()` to pass through. Settings rows returned from `create_settings` come from
`ui.settings` (`Header`, `Input`, `Switch`, `Text`, `Selector`, `Divider`) and persist through
`self.get_setting`/`self.set_setting`. Logging goes through `android_utils.log`; Chaquopy also
redirects `sys.stdout`/`sys.stderr` to the logcat tags `python.stdout`/`python.stderr`.
This repo does not use the official Elyx/ElyxBuilder archive format — see
`docs/sdk-notes.md` for why.
## Repo layout
```
plugin.json single source of truth for plugin metadata
src/ the plugin, written as an ordinary Python package
main.py entry module — exactly one BasePlugin subclass, absolute imports only
greeting/ sample package (proves relative + cross-module imports survive bundling)
textutil.py sample top-level sibling module
tools/
bundle.py compiles plugin.json + src/ into dist/<id>.plugin
devclient.py speaks the on-device dev-server protocol (127.0.0.1:42690)
verify_bundle.py offline harness that proves a built bundle behaves correctly, no phone needed
release.py publishes a built bundle to a Gitea release (used by CI)
.gitea/workflows/ CI that builds, verifies and publishes the .plugin to Releases
scripts/ ADB debug-loop automation and the git hooks (see below)
docs/sdk-notes.md condensed SDK API surface gathered from the official docs
```
## The three commands that matter
```bash
python3 tools/bundle.py --release # compile src/ -> dist/<id>.plugin
python3 tools/verify_bundle.py dist/<id>.plugin # offline proof the bundle works, no device needed
bash scripts/deploy.sh --enable # build (--dev) + push to a connected device + enable + reload
```
`bundle.py` walks `src/` recursively, embeds every `.py` file's source and dotted module name
into a compressed, base64-encoded payload, and appends a small runtime preamble that installs
a private import hook (scoped to a per-module `__builtins__` dict, never touching
`sys.modules`) so cross-module and cross-package imports resolve exactly as they would if the
files were still on disk. `--dev` (the default) embeds absolute local paths so a debugger
resolves breakpoints against your real source files; `--release` embeds repo-relative paths
for distribution. Both modes produce byte-reproducible output for a given `src/` + `plugin.json`.
Its first run in a clone also enables the repo's git hooks (see
[Git hooks and versioning](#git-hooks-and-versioning)).
## Device prerequisites
- exteraGram 12.5.1+.
- In-app: Preferences → Plugins → enable the plugin engine, then enable developer mode.
- USB debugging enabled, device authorized (`adb devices`).
Release-build APKs disallow `adb run-as`, so you cannot `adb push` a file straight into the
app's private plugin directory. The on-device **dev server** (`tools/devclient.py`,
`scripts/deploy.sh`) is the actual install path — it talks to the running app over a TCP
socket forwarded with `adb forward`, and the app writes the file into its own storage.
## ADB automation
All scripts live in `scripts/`, are runnable from any working directory, and honor the
standard `ANDROID_SERIAL` env var for device selection.
- `scripts/adb-connect.sh [--debug]` — starts adb, waits for the device, (re)creates the
`tcp:42690` port forward (and `tcp:5678` with `--debug`), pings the dev server.
- `scripts/deploy.sh [--release] [--enable] [--no-connect]` — bundles, connects, writes the
plugin to the device, optionally enables it, reloads it.
- `scripts/watch.sh [deploy args…]` — watches `src/` and `plugin.json`, redeploys on change
(uses `inotifywait` if available, otherwise polls).
- `scripts/logs.sh [-c] [-t] [-g PATTERN]` — streams logcat for the running app (`-t` for the
raw Chaquopy stdio tags, `-g` to grep, `-c` to clear first).
- `scripts/debug.sh start|stop [--platform vscode|pycharm]` — forwards the debugger port and
tells the dev server to start/stop the in-app debugger. Requires `debugpy` (vscode) or
`pydevd-pycharm` (pycharm) present in the app's Python runtime — add it to `requirements` in
`plugin.json` if it's missing.
- `scripts/fetch-sdk-stubs.sh [--beta]` — downloads the latest SDK type stubs into `stubs/`
for editor autocomplete.
## Git hooks and versioning
`scripts/pre-commit` bumps the patch number of `version` in `plugin.json` on every commit
(`1.0.0` → `1.0.1`) and stages it into that same commit. `scripts/post-commit` re-stages
`plugin.json` afterwards, because commits of a file selection (`git commit -- <paths>`, which
some IDEs use) are built from a temporary index and would otherwise leave it looking modified.
**The hooks enable themselves.** Git never activates hooks from repository content, so a
fresh clone, or a repo created from this template, starts without them. The first run of
`tools/bundle.py` fixes that by running `git config core.hooksPath scripts`, and every
workflow runs it: directly, or through `scripts/deploy.sh` and `scripts/watch.sh`. That
includes `--check`, so after cloning run
```bash
python3 tools/bundle.py --check
```
once, before your first commit. A commit made before any build runs without the hooks.
`bundle.py` leaves `core.hooksPath` alone when it is already set (so pointing it elsewhere on
purpose sticks), skips the step in CI (whenever `CI` is set) and outside a git work tree, and
never fails a build over it. To turn the hooks off for a clone, run
`git config core.hooksPath .git/hooks`; `git config --unset core.hooksPath` only lasts
until the next build.
The bump is skipped when:
- the commit message contains `[no bump]`, `[skip bump]`, `[no version bump]` or
`[skip version]` (case-insensitive; `nobump`, `no-bump` and `no_bump` also work),
- `NO_BUMP=1` (or `GIT_NO_BUMP=1`) is set, e.g. `NO_BUMP=1 git commit`, which is the only way
for commits written in an editor, since their message doesn't exist yet when the hook runs,
- `version` in `plugin.json` already differs from `HEAD` (you bumped it by hand, e.g. for a
minor or major release),
- a merge, rebase, cherry-pick or revert is in progress.
Because every version change publishes a release (see below), a normal commit + push to
`main` ships a new patch release. Use a stop word for commits that should not.
## Publishing a release
`.gitea/workflows/release.yml` builds, verifies and publishes the `.plugin` to the repo's
Releases tab on every push to `main`. `dist/` is gitignored, so the artifact is always built
by CI rather than committed. Nothing in the workflow is plugin-specific — it reads the id and
version straight out of `plugin.json`, so it keeps working after you rename the plugin.
The `version` field in `plugin.json` is the single source of truth:
| Push to `main` with… | Result |
| --- | --- |
| a new `version` | creates tag `v<version>` and a new release |
| the same `version` | rebuilds the existing release, replacing its attachment |
So every push leaves a downloadable build attached to the current version, and bumping
`version` is what cuts a new release. With the git hooks enabled every commit bumps it,
so every push of a normal commit cuts a new patch release. Pushing a `v*` tag by hand still works and uses that
tag verbatim, as does a manual run (Actions → Publish plugin → Run workflow).
Every path runs `--check`, builds with `--release`, and gates the upload on
`verify_bundle.py`, so a bundle that fails verification is never published. A rebuild
replaces the attachment rather than piling up duplicates, and refreshes the release notes to
name the commit it came from. The tag itself is never moved: it keeps pointing at the commit
that first introduced that version.
Authentication uses `secrets.API_TOKEN`, an organisation-level Actions secret holding a user
token with the `write:repository` scope (which covers releases). Set it once under
**Org → Settings → Actions → Secrets** and every repo in the organisation inherits it; a
repo-level secret of the same name takes precedence if one repo ever needs different
credentials.
The workflow passes it to `release.py` as `RELEASE_TOKEN`, **not** `GITEA_TOKEN`. The Actions
runner injects its own `GITEA_TOKEN` into every step, and that value overrides a step-level
`env:` entry of the same name, so the org secret would be silently ignored.
If the org secret does not reach a repository the publish still works: `release.py` tries
`RELEASE_TOKEN` first and then falls back to the runner's `GITEA_TOKEN`, which can publish
releases in its own repository, and prints which one it used. An organisation secret is only
exposed to a repository that has Actions enabled, so check **Repo → Settings → Actions** if a
repo unexpectedly falls back.
If you would rather not manage a token at all, that injected `GITEA_TOKEN` can create
releases in its own repo: pass `--token-env GITEA_TOKEN` and drop the org secret. It is
scoped to the single job and expires with it, so it is the safer default; `API_TOKEN` is what
you want when publishing needs to reach beyond this repo.
## Writing your own plugin
1. Run `python3 tools/bundle.py --check` once. It enables the git hooks that auto-bump the
version on commit (see [Git hooks and versioning](#git-hooks-and-versioning)).
2. Edit `plugin.json` (id, name, description, etc.).
3. Write your plugin under `src/`, with `src/main.py` as the entry module (absolute imports
only — it's executed at plugin-module top level, not inside a package).
4. `python3 tools/bundle.py --release && python3 tools/verify_bundle.py dist/<id>.plugin`
(substitute your `id` from `plugin.json`).
5. `bash scripts/deploy.sh --enable` with a device connected.
- [How Message Logger works](docs/how-it-works.md): server sync and the local cache
- [Development](docs/development.md): getting started, repo layout, building and verifying, icon export
- [How exteraGram plugins work](docs/how-plugins-work.md): the plugin format and lifecycle
- [Device setup and ADB automation](docs/device-and-adb.md): deploy, watch, logs, debugger
- [Versioning and releases](docs/versioning-and-releases.md): git hooks, auto-bump, CI publishing
- [SDK notes](docs/sdk-notes.md): condensed exteraGram SDK API surface
+65
View File
@@ -0,0 +1,65 @@
# Development
## Writing your own plugin
1. Run `python3 tools/bundle.py --check` once. It enables the git hooks that auto-bump the
version on commit (see [Git hooks and versioning](versioning-and-releases.md#git-hooks-and-versioning)).
2. Edit `plugin.json` (id, name, description, etc.).
3. Write your plugin under `src/`, with `src/main.py` as the entry module (absolute imports
only — it's executed at plugin-module top level, not inside a package).
4. `python3 tools/bundle.py --release && python3 tools/verify_bundle.py dist/<id>.plugin`
(substitute your `id` from `plugin.json`).
5. `bash scripts/deploy.sh --enable` with a device connected.
## Repo layout
```
plugin.json single source of truth for plugin metadata
src/ the plugin, written as an ordinary Python package
main.py entry module — exactly one BasePlugin subclass, absolute imports only
greeting/ sample package (proves relative + cross-module imports survive bundling)
textutil.py sample top-level sibling module
tools/
bundle.py compiles plugin.json + src/ into dist/<id>.plugin
devclient.py speaks the on-device dev-server protocol (127.0.0.1:42690)
verify_bundle.py offline harness that proves a built bundle behaves correctly, no phone needed
release.py publishes a built bundle to a Gitea release (used by CI)
icon_png.py renders images/icon.svg to a square PNG
images/icon.svg the plugin icon, source of truth for any PNG export
.gitea/workflows/ CI that builds, verifies and publishes the .plugin to Releases
scripts/ ADB debug-loop automation and the git hooks
docs/ these notes + sdk-notes.md, the condensed SDK API surface
```
## The three commands that matter
```bash
python3 tools/bundle.py --release # compile src/ -> dist/<id>.plugin
python3 tools/verify_bundle.py dist/<id>.plugin # offline proof the bundle works, no device needed
bash scripts/deploy.sh --enable # build (--dev) + push to a connected device + enable + reload
```
`bundle.py` walks `src/` recursively, embeds every `.py` file's source and dotted module name
into a compressed, base64-encoded payload, and appends a small runtime preamble that installs
a private import hook (scoped to a per-module `__builtins__` dict, never touching
`sys.modules`) so cross-module and cross-package imports resolve exactly as they would if the
files were still on disk. `--dev` (the default) embeds absolute local paths so a debugger
resolves breakpoints against your real source files; `--release` embeds repo-relative paths
for distribution. Both modes produce byte-reproducible output for a given `src/` + `plugin.json`.
Its first run in a clone also enables the repo's git hooks (see
[Git hooks and versioning](versioning-and-releases.md#git-hooks-and-versioning)).
## Exporting the icon as PNG
`tools/icon_png.py` renders `images/icon.svg` to `images/icon.png`, 512x512 by default.
`--resolution` sets the size of both sides, so `--resolution 1024` gives 1024x1024:
```bash
python3 tools/icon_png.py # images/icon.png, 512x512
python3 tools/icon_png.py --resolution 1024 # images/icon.png, 1024x1024
python3 tools/icon_png.py some.svg -o out.png
```
The output is always PNG; any other `-o` extension is rejected. The script itself is stdlib
only and hands the rendering to the first renderer it finds: the `cairosvg` Python package,
`rsvg-convert` (librsvg), ImageMagick or Inkscape. Install any one of them.
+32
View File
@@ -0,0 +1,32 @@
# Device setup and ADB automation
## Device prerequisites
- exteraGram 12.5.1+.
- In-app: Preferences → Plugins → enable the plugin engine, then enable developer mode.
- USB debugging enabled, device authorized (`adb devices`).
Release-build APKs disallow `adb run-as`, so you cannot `adb push` a file straight into the
app's private plugin directory. The on-device **dev server** (`tools/devclient.py`,
`scripts/deploy.sh`) is the actual install path — it talks to the running app over a TCP
socket forwarded with `adb forward`, and the app writes the file into its own storage.
## ADB automation
All scripts live in `scripts/`, are runnable from any working directory, and honor the
standard `ANDROID_SERIAL` env var for device selection.
- `scripts/adb-connect.sh [--debug]` — starts adb, waits for the device, (re)creates the
`tcp:42690` port forward (and `tcp:5678` with `--debug`), pings the dev server.
- `scripts/deploy.sh [--release] [--enable] [--no-connect]` — bundles, connects, writes the
plugin to the device, optionally enables it, reloads it.
- `scripts/watch.sh [deploy args…]` — watches `src/` and `plugin.json`, redeploys on change
(uses `inotifywait` if available, otherwise polls).
- `scripts/logs.sh [-c] [-t] [-g PATTERN]` — streams logcat for the running app (`-t` for the
raw Chaquopy stdio tags, `-g` to grep, `-c` to clear first).
- `scripts/debug.sh start|stop [--platform vscode|pycharm]` — forwards the debugger port and
tells the dev server to start/stop the in-app debugger. Requires `debugpy` (vscode) or
`pydevd-pycharm` (pycharm) present in the app's Python runtime — add it to `requirements` in
`plugin.json` if it's missing.
- `scripts/fetch-sdk-stubs.sh [--beta]` — downloads the latest SDK type stubs into `stubs/`
for editor autocomplete.
+16
View File
@@ -0,0 +1,16 @@
# How Message Logger works
## Server sync and caching
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.
+26
View File
@@ -0,0 +1,26 @@
# How exteraGram plugins work
## How exteraGram plugins actually work
A plugin, as exteraGram loads it, is exactly **one Python file** on the device:
`/data/user/0/com.exteragram.messenger/files/plugins/<plugin_id>.py`. The distribution
convention names the same content `<id>.plugin`.
The app reads plugin metadata **statically via AST** — it looks for top-level constant
assignments (`__id__`, `__name__`, `__description__`, `__author__`, `__version__`,
`__icon__`, `__app_version__`, `__sdk_version__`, `__requirements__`) without executing the
file. Only `__id__` and `__name__` are required.
The file must define exactly one class inheriting `BasePlugin` (`from base_plugin import
BasePlugin`). Lifecycle methods (`on_plugin_load`, `on_plugin_unload`, `create_settings`) and
hooks (`on_send_message_hook`, `pre_request_hook`, `post_request_hook`, `on_update_hook(s)`,
`on_app_event`) are all called by the host; hooks must be registered inside
`on_plugin_load` (e.g. `self.add_on_send_message_hook()`). A hook returns a
`HookResult(strategy=HookStrategy.MODIFY, params=params)` to apply changes, or a bare
`HookResult()` to pass through. Settings rows returned from `create_settings` come from
`ui.settings` (`Header`, `Input`, `Switch`, `Text`, `Selector`, `Divider`) and persist through
`self.get_setting`/`self.set_setting`. Logging goes through `android_utils.log`; Chaquopy also
redirects `sys.stdout`/`sys.stderr` to the logcat tags `python.stdout`/`python.stderr`.
This repo does not use the official Elyx/ElyxBuilder archive format — see
[`sdk-notes.md`](sdk-notes.md) for why.
+84
View File
@@ -0,0 +1,84 @@
# Versioning and releases
## Git hooks and versioning
`scripts/pre-commit` bumps the patch number of `version` in `plugin.json` on every commit
(`1.0.0` → `1.0.1`) and stages it into that same commit. `scripts/post-commit` re-stages
`plugin.json` afterwards, because commits of a file selection (`git commit -- <paths>`, which
some IDEs use) are built from a temporary index and would otherwise leave it looking modified.
**The hooks enable themselves.** Git never activates hooks from repository content, so a
fresh clone, or a repo created from this template, starts without them. The first run of
`tools/bundle.py` fixes that by running `git config core.hooksPath scripts`, and every
workflow runs it: directly, or through `scripts/deploy.sh` and `scripts/watch.sh`. That
includes `--check`, so after cloning run
```bash
python3 tools/bundle.py --check
```
once, before your first commit. A commit made before any build runs without the hooks.
`bundle.py` leaves `core.hooksPath` alone when it is already set (so pointing it elsewhere on
purpose sticks), skips the step in CI (whenever `CI` is set) and outside a git work tree, and
never fails a build over it. To turn the hooks off for a clone, run
`git config core.hooksPath .git/hooks`; `git config --unset core.hooksPath` only lasts
until the next build.
The bump is skipped when:
- the commit message contains `[no bump]`, `[skip bump]`, `[no version bump]` or
`[skip version]` (case-insensitive; `nobump`, `no-bump` and `no_bump` also work),
- `NO_BUMP=1` (or `GIT_NO_BUMP=1`) is set, e.g. `NO_BUMP=1 git commit`, which is the only way
for commits written in an editor, since their message doesn't exist yet when the hook runs,
- `version` in `plugin.json` already differs from `HEAD` (you bumped it by hand, e.g. for a
minor or major release),
- a merge, rebase, cherry-pick or revert is in progress.
Because every version change publishes a release (see [Publishing a release](#publishing-a-release)), a normal commit + push to
`main` ships a new patch release. Use a stop word for commits that should not.
## Publishing a release
`.gitea/workflows/release.yml` builds, verifies and publishes the `.plugin` to the repo's
Releases tab on every push to `main`. `dist/` is gitignored, so the artifact is always built
by CI rather than committed. Nothing in the workflow is plugin-specific — it reads the id and
version straight out of `plugin.json`, so it keeps working after you rename the plugin.
The `version` field in `plugin.json` is the single source of truth:
| Push to `main` with… | Result |
| --- | --- |
| a new `version` | creates tag `v<version>` and a new release |
| the same `version` | rebuilds the existing release, replacing its attachment |
So every push leaves a downloadable build attached to the current version, and bumping
`version` is what cuts a new release. With the git hooks enabled every commit bumps it,
so every push of a normal commit cuts a new patch release. Pushing a `v*` tag by hand still works and uses that
tag verbatim, as does a manual run (Actions → Publish plugin → Run workflow).
Every path runs `--check`, builds with `--release`, and gates the upload on
`verify_bundle.py`, so a bundle that fails verification is never published. A rebuild
replaces the attachment rather than piling up duplicates, and refreshes the release notes to
name the commit it came from. The tag itself is never moved: it keeps pointing at the commit
that first introduced that version.
Authentication uses `secrets.API_TOKEN`, an organisation-level Actions secret holding a user
token with the `write:repository` scope (which covers releases). Set it once under
**Org → Settings → Actions → Secrets** and every repo in the organisation inherits it; a
repo-level secret of the same name takes precedence if one repo ever needs different
credentials.
The workflow passes it to `release.py` as `RELEASE_TOKEN`, **not** `GITEA_TOKEN`. The Actions
runner injects its own `GITEA_TOKEN` into every step, and that value overrides a step-level
`env:` entry of the same name, so the org secret would be silently ignored.
If the org secret does not reach a repository the publish still works: `release.py` tries
`RELEASE_TOKEN` first and then falls back to the runner's `GITEA_TOKEN`, which can publish
releases in its own repository, and prints which one it used. An organisation secret is only
exposed to a repository that has Actions enabled, so check **Repo → Settings → Actions** if a
repo unexpectedly falls back.
If you would rather not manage a token at all, that injected `GITEA_TOKEN` can create
releases in its own repo: pass `--token-env GITEA_TOKEN` and drop the org secret. It is
scoped to the single job and expires with it, so it is the safer default; `API_TOKEN` is what
you want when publishing needs to reach beyond this repo.
+51
View File
@@ -0,0 +1,51 @@
<svg xmlns="http://www.w3.org/2000/svg" width="640" height="640" viewBox="0 0 640 640">
<defs>
<linearGradient id="bg" x1="0.3" y1="0" x2="0.7" y2="1">
<stop offset="0" stop-color="#F89B2E"/><stop offset="0.5" stop-color="#F7724A"/><stop offset="1" stop-color="#F5517A"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.45" r="0.5">
<stop offset="0" stop-color="#FF5A3C" stop-opacity="0.18"/><stop offset="1" stop-color="#FF5A3C" stop-opacity="0"/>
</radialGradient>
<linearGradient id="paper" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#F7F7F7"/><stop offset="1" stop-color="#E2E2E2"/>
</linearGradient>
<linearGradient id="star" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFE27A"/><stop offset="1" stop-color="#FFC53D"/>
</linearGradient>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#7CC8FF"/><stop offset="1" stop-color="#CFEBFF"/>
</linearGradient>
<linearGradient id="metal" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#9C9C9C"/><stop offset="0.45" stop-color="#E4E4E4"/><stop offset="1" stop-color="#A8A8A8"/>
</linearGradient>
<clipPath id="photo"><rect x="166" y="196" width="228" height="208" rx="18"/></clipPath>
<filter id="sh" x="-20%" y="-20%" width="140%" height="150%">
<feDropShadow dx="0" dy="10" stdDeviation="10" flood-color="#7A1E2E" flood-opacity="0.28"/>
</filter>
</defs>
<rect width="640" height="640" fill="url(#bg)"/>
<rect width="640" height="640" fill="url(#glow)"/>
<g transform="translate(-10,-10)">
<path filter="url(#sh)" fill="url(#paper)" stroke="#B9B9B9" stroke-width="6" stroke-linejoin="round" d="M198,170 H408 A48,48 0 0 1 456,218 V392 A48,48 0 0 1 408,440 H270 L186,498 L204,440 H198 A48,48 0 0 1 150,392 V218 A48,48 0 0 1 198,170 Z"/>
<g stroke-linecap="round">
<line x1="196" y1="234" x2="390" y2="234" stroke="#555555" stroke-width="18"/>
<line x1="196" y1="290" x2="410" y2="290" stroke="#8C8C8C" stroke-width="13"/>
<line x1="196" y1="340" x2="370" y2="340" stroke="#C4C4C4" stroke-width="13"/>
<line x1="186" y1="340" x2="380" y2="340" stroke="#F5517A" stroke-width="7"/>
<line x1="196" y1="390" x2="300" y2="390" stroke="#8C8C8C" stroke-width="13"/>
</g>
<g filter="url(#sh)">
<circle cx="452" cy="430" r="78" fill="url(#star)" stroke="#F59A2E" stroke-width="8" stroke-linejoin="round"/>
<circle cx="452" cy="430" r="56" fill="#FFFFFF" stroke="#F59A2E" stroke-width="5"/>
<g stroke="#E0B060" stroke-width="6" stroke-linecap="round">
<line x1="452" y1="382" x2="452" y2="390"/><line x1="452" y1="470" x2="452" y2="478"/>
<line x1="404" y1="430" x2="412" y2="430"/><line x1="492" y1="430" x2="500" y2="430"/>
</g>
<g stroke="#555555" stroke-width="10" stroke-linecap="round">
<line x1="452" y1="430" x2="452" y2="396"/><line x1="452" y1="430" x2="478" y2="446"/>
</g>
<circle cx="452" cy="430" r="7" fill="#555555"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+2 -2
View File
@@ -3,8 +3,8 @@
"name": "Message Logger",
"description": "Shows deleted and edited messages and their edit history from your telegram-panel message logger.",
"author": "@lyuksovannyy",
"version": "0.1.1",
"icon": "exteraPlugins/1",
"version": "0.1.5",
"icon": "exteraGramPluginIcons/4",
"app_version": ">=12.5.1",
"sdk_version": ">=1.4.4.3",
"requirements": [
+79
View File
@@ -99,6 +99,18 @@ class Api:
out.append((token[:10], exc))
return out
def masters(self):
"""Telegram ids of the tokens' masters (from /me), cached 10 minutes."""
cached = getattr(self, "_masters", None)
if cached is not None and time.monotonic() - cached[0] < 600:
return cached[1]
found = []
for _prefix, me in self.me():
if isinstance(me, dict) and me.get("master_tg_id"):
found.append(int(me["master_tg_id"]))
self._masters = (time.monotonic(), found)
return found
def sync(self, account_id, chat_id, min_id, max_id, present_ids):
body = {
"account_id": account_id,
@@ -152,3 +164,70 @@ class Api:
for _token, result in self._each("POST", "/deletions", json=body):
marked += int((result or {}).get("marked", 0))
return marked
def hidden(self, account_id, chat_id, message_ids, hidden):
"""Hide messages from the chat for every client (or show them again);
True once every master took it."""
ids = sorted(set(int(i) for i in message_ids))
for start in range(0, len(ids), 500):
body = {
"account_id": account_id,
"chat_id": chat_id,
"message_ids": ids[start : start + 500],
"hidden": bool(hidden),
}
for _token, _result in self._each("POST", "/hidden", json=body):
pass
return True
# removed chats
def chat_removed(self, account_id, chat_id, own):
body = {"account_id": account_id, "chat_id": chat_id, "own": bool(own)}
known = False
for _token, result in self._each("POST", "/chats/removed", json=body):
known = known or bool((result or {}).get("known"))
return known
def chat_hidden(self, account_id, chat_id, hidden):
body = {"account_id": account_id, "chat_id": chat_id, "hidden": bool(hidden)}
for _token, _result in self._each("POST", "/chats/hidden", json=body):
pass
def removed_chats(self, account_id):
"""The account's removed chats, each master's answer merged (the
first one listing a chat wins), or None when no master answered."""
chats, any_result = {}, False
for _token, result in self._each(
"GET", "/chats/removed", params={"account_id": account_id}
):
if result is None:
continue
any_result = True
for chat in result.get("chats") or []:
chat["master_tg_id"] = result.get("master_tg_id")
chats.setdefault(int(chat["chat_id"]), chat)
return list(chats.values()) if any_result else None
def history(self, account_id, chat_id, before=0, limit=100):
"""Logged messages of a chat older than `before`, newest first: the
first master that has any."""
params = {"account_id": account_id, "before": int(before), "limit": int(limit)}
for _token, result in self._each(
"GET", f"/chats/{chat_id}/history", params=params
):
if result and result.get("messages"):
return result
return None
def avatars(self, account_id, chat_id):
"""Chat photos logged for a chat, newest first, every master's."""
out = []
for _token, result in self._each(
"GET", f"/chats/{chat_id}/avatars", params={"account_id": account_id}
):
for avatar in (result or {}).get("avatars") or []:
avatar["master_tg_id"] = (result or {}).get("master_tg_id")
out.append(avatar)
out.sort(key=lambda a: a.get("date") or "", reverse=True)
return out
+2
View File
@@ -7,6 +7,7 @@ RESTORE_MISSING = "restore_missing"
REPORT_DELETIONS = "report_deletions"
DELETED_ALPHA = "deleted_alpha"
CACHE_DAYS = "cache_days"
SHOW_REMOVED_CHATS = "show_removed_chats"
DEFAULTS = {
SERVER_URL: "",
@@ -16,6 +17,7 @@ DEFAULTS = {
REPORT_DELETIONS: True,
DELETED_ALPHA: 0, # index into ALPHA_CHOICES
CACHE_DAYS: 2, # index into CACHE_DAYS_CHOICES
SHOW_REMOVED_CHATS: True,
}
# Opacity of deleted messages: well visible, marked by the trash icon.
+479
View File
@@ -0,0 +1,479 @@
"""Chat photos the logger posted (service posts in the chat's log topic, see
telegram-panel `avatars`), read from those posts.
- A removed chat shows its latest logged photo (hooks/removed_chats.py).
- A chat's photos (MessagesController.getDialogPhotos: DialogPhotos `photos`,
shown by the profile's gallery and the photo viewer, both redrawn on
NotificationCenter.dialogPhotosUpdate) get the logged photos the chat no
longer has, after its own. The photo viewer's thumbnails of those
(GroupedPhotosListView.onDraw) are translucent like deleted messages, with
a red trash icon.
A post is read and its photo downloaded through an account that can read
the log group; the app's file cache is shared by its accounts, so the photo
then shows for any of them.
"""
import threading
import time
from base_plugin import MethodHook
import diag
import media_share
import peers
from api import ApiError
from jutil import field, hook_methods, in_background, java_class, java_list, on_ui
from views.history import _epoch
CONTROLLER = "org.telegram.messenger.MessagesController"
THUMBS = "org.telegram.ui.Components.GroupedPhotosListView"
VIEWER = "org.telegram.ui.PhotoViewer"
GALLERY = "org.telegram.ui.Components.ProfileGalleryView"
OVERLAYS = "org.telegram.ui.ProfileActivity$OverlaysView"
_MARK_TAG = "message_logger_trash"
NOTIFICATIONS = "org.telegram.messenger.NotificationCenter"
# A logged photo whose id isn't Telegram's (logged by a bot) is the chat's
# own when one of the chat's photos was set this close to its post.
_SAME_PHOTO_SECONDS = 10
# A chat's logged photos are asked for again after this long.
_FRESH_SECONDS = 600
_TELL_SECONDS = 3
def _photo_of(copy):
"""The photo of a logger post (a photo message, or the first photo of a
rich message)."""
if copy is None:
return None
photo = getattr(getattr(copy, "media", None), "photo", None)
if photo is not None and getattr(photo, "sizes", None) is not None:
return photo
rich = getattr(copy, "rich_message", None)
photos = getattr(rich, "photos", None) if rich is not None else None
if photos is not None and photos.size():
return photos.get(0)
return None
def logged_photo(account, avatar, masters=()):
"""(photo, reader slot, post) of a logged chat photo, read through a
member of its log group signed in here; else None (blocking)."""
from views.removed_chat import _fetch
logged = avatar.get("logged") or {}
if not avatar.get("has_photo") or not logged.get("chat_id"):
return None
reader = media_share.reader_for(
account, logged["chat_id"], [avatar.get("master_tg_id"), *masters]
)
if reader is None:
return None
channel_id = peers.channel_id_from_marked(logged["chat_id"])
message_id = int(logged["message_id"])
copy = _fetch(reader, -channel_id, [message_id]).get(message_id)
found = _photo_of(copy)
return (found, reader, copy) if found is not None else None
class Avatars:
def __init__(self, plugin, api):
self.plugin = plugin
self.api = api
self.handles = []
self._lock = threading.Lock()
self._recorded = {} # (account, dialog) -> [(photo, original id, date)]
self._ids = set() # ids of those photos: drawn with the trash icon
self._loading = set()
self._loaded_at = {} # (account, dialog) -> monotonic time of the answer
self._told = {} # (account, dialog) -> monotonic time the gallery was told
self._appending = False
self._updates = {} # (account, dialog) -> args of its last dialogPhotosUpdate
self._icon = None
self._watched = {} # gallery hash -> its page listener (kept alive)
self._position = None # ProfileGalleryView.getRealPosition
self._shown = None # PhotoViewer.isVisible()
def install(self):
avatars = self
class AfterPhotos(MethodHook):
def after_hooked_method(self, param):
try:
avatars.photos_wanted(param.thisObject, param.args[0], param.getResult())
except Exception as exc:
diag.error("avatars.photos", exc)
class AfterSetData(MethodHook):
def after_hooked_method(self, param):
try:
avatars.watch_gallery(param.thisObject)
except Exception as exc:
diag.error("avatars.gallery", exc)
class AfterOverlays(MethodHook):
def after_hooked_method(self, param):
try:
avatars.update_profile(field(param.thisObject, "this$0"))
except Exception as exc:
diag.error("avatars.profile", exc)
class AfterThumbs(MethodHook):
def after_hooked_method(self, param):
try:
avatars.draw_thumbs(param.thisObject, param.args[0])
except Exception as exc:
diag.error("avatars.thumbs", exc)
class BeforeNotify(MethodHook):
def before_hooked_method(self, param):
try:
avatars.photos_updated(param)
except Exception as exc:
diag.error("avatars.update", exc)
self.handles += hook_methods(self.plugin, CONTROLLER, "getDialogPhotos", AfterPhotos())
self.handles += hook_methods(self.plugin, THUMBS, "onDraw", AfterThumbs())
self.handles += hook_methods(self.plugin, GALLERY, "setData", AfterSetData())
for name in ("onDraw", "setAlphaValue"):
self.handles += hook_methods(self.plugin, OVERLAYS, name, AfterOverlays())
self.handles += hook_methods(
self.plugin, NOTIFICATIONS, "postNotificationName", BeforeNotify()
)
def uninstall(self):
for handle in self.handles:
try:
self.plugin.unhook_method(handle)
except Exception:
pass
self.handles = []
# loading (background)
def photos_wanted(self, controller, dialog_id, holder):
"""getDialogPhotos: the chat's logged photos are loaded once, then
put into its photos (again after the app reloaded them)."""
if self._appending or not self.api.configured or holder is None:
return
account = int(field(controller, "currentAccount") or 0)
key = (account, int(dialog_id))
with self._lock:
if key in self._recorded:
# Added (and the gallery told) once the app is done with it.
on_ui(lambda: self._append(*key))
if time.monotonic() - self._loaded_at.get(key, 0) < _FRESH_SECONDS:
return
if key in self._loading:
return
self._loading.add(key)
in_background(lambda: self._load(*key))
def _load(self, account, dialog_id):
try:
chat_id = peers.marked_chat_id(account, dialog_id)
if chat_id is None:
return
try:
logged = self.api.avatars(peers.own_user_id(account), chat_id)
except ApiError as exc:
diag.log(f"avatars {chat_id}: {exc}")
return
recorded = []
masters = self.api.masters()
for avatar in logged:
found = logged_photo(account, avatar, masters)
if found is None:
continue
photo, reader, copy = found
if not media_share.photo_sizes(reader, photo, copy):
diag.log(f"avatars {chat_id}: logged photo unavailable")
continue # it can't be shown
original = avatar.get("photo_id")
recorded.append(
(photo, int(original) if original else None, _epoch(avatar.get("date")))
)
with self._lock:
self._recorded[(account, dialog_id)] = recorded
self._loaded_at[(account, dialog_id)] = time.monotonic()
self._ids.update(int(p.id) for p, _o, _d in recorded)
if recorded:
diag.log(f"avatars {chat_id}: {len(recorded)} logged photos")
on_ui(lambda: self._append(account, dialog_id))
finally:
with self._lock:
self._loading.discard((account, dialog_id))
# the gallery (UI thread)
def _gone(self, photos, original, date):
"""Whether a logged photo is not one the chat still has."""
for photo in photos:
if photo is None or int(photo.id) in self._ids:
continue
if original is not None and int(photo.id) == original:
return False
if original is None and abs(int(photo.date) - date) <= _SAME_PHOTO_SECONDS:
return False
return True
def _add_to(self, account, dialog_id, holder):
"""Appends the logged photos the chat no longer has; whether any."""
photos = field(holder, "photos")
if photos is None:
return False
current = java_list(photos)
shown = {int(p.id) for p in current if p is not None}
added = False
for photo, original, date in self._recorded.get((account, dialog_id), []):
if int(photo.id) in shown or not self._gone(current, original, date):
continue
photos.add(photo)
shown.add(int(photo.id))
added = True
return added
def _viewing(self, dialog_id):
"""Whether the photo viewer shows the chat's photos: its list is
built when it opens and a change meanwhile desyncs it from its
thumbnails (no swiping to the new ones)."""
from org.telegram.ui import PhotoViewer
if not PhotoViewer.hasInstance():
return False
viewer = PhotoViewer.getInstance()
if self._shown is None:
# The method (not the field, which stays set once it closed).
self._shown = java_class(VIEWER).getMethod("isVisible")
return bool(self._shown.invoke(viewer)) and int(
field(viewer, "avatarsDialogId") or 0
) == int(dialog_id)
def _append(self, account, dialog_id):
"""Puts the logged photos into the chat's photos and tells the
gallery, at most every few seconds (it reads the photos again)."""
from org.telegram.messenger import MessagesController, NotificationCenter
if not self.handles:
return # unloaded
if self._viewing(dialog_id):
on_ui(lambda: self._append(account, dialog_id), 1000) # once it closed
return
self._appending = True # getDialogPhotos calls back into photos_wanted
try:
holder = MessagesController.getInstance(account).getDialogPhotos(dialog_id)
finally:
self._appending = False
if holder is None:
return
key = (account, dialog_id)
added = self._add_to(account, dialog_id, holder)
if not added and time.monotonic() - self._told.get(key, 0) < _TELL_SECONDS:
return
self._told[key] = time.monotonic()
args = self._updates.get(key)
if args is None:
from java import jarray
from java.lang import Object
args = jarray(Object)([holder])
NotificationCenter.getInstance(account).postNotificationName(
NotificationCenter.dialogPhotosUpdate, args
)
def photos_updated(self, param):
"""dialogPhotosUpdate: the logged photos are put back into the
(reloaded) list before the gallery redraws."""
from org.telegram.messenger import NotificationCenter
if int(param.args[0]) != int(NotificationCenter.dialogPhotosUpdate):
return
extra = param.args[1]
holder = next(
(a for a in (extra or []) if a is not None and hasattr(a, "photos")), None
)
if holder is None:
return
account = int(field(param.thisObject, "currentAccount") or 0)
dialog_id = int(field(holder, "dialogId") or 0)
self._updates[(account, dialog_id)] = extra
if (account, dialog_id) not in self._recorded:
return
if self._viewing(dialog_id):
on_ui(lambda: self._append(account, dialog_id), 1000)
else:
self._add_to(account, dialog_id, holder)
# the trash icon
def _logged_at(self, strip, index):
"""Whether the strip's item `index` is a logged photo."""
for name in ("currentObjects", "currentPhotos"):
items = field(strip, name)
if items is None or not 0 <= index < items.size():
continue
item = items.get(index)
photo_id = getattr(item, "photoId", None) if name == "currentPhotos" else None
if photo_id is None:
photo_id = getattr(item, "id", None)
if photo_id is not None and int(photo_id) in self._ids:
return True
return False
def _alpha(self):
from config import ALPHA_CHOICES, DEFAULTS, DELETED_ALPHA
index = int(self.plugin.get_setting(DELETED_ALPHA, DEFAULTS[DELETED_ALPHA]) or 0)
return ALPHA_CHOICES[max(0, min(index, len(ALPHA_CHOICES) - 1))][1]
def draw_thumbs(self, strip, canvas):
"""The photo viewer's thumbnails of logged photos: translucent, with a
small trash icon (GroupedPhotosListView: `imagesToDraw`, each
receiver's param its index)."""
if not self._ids:
return
receivers = field(strip, "imagesToDraw")
if receivers is None:
return
from org.telegram.messenger import AndroidUtilities
from android.graphics import Paint
from java import jint
icon, paint = self._trash()
# The strip draws its thumbnails opaque (it sets their alpha itself)
# over the viewer's black: black at the remaining opacity on top
# looks the same as the deleted message opacity.
shade = Paint()
shade.setColor(jint(int((1.0 - self._alpha()) * 255) << 24))
dp = AndroidUtilities.dp
size = dp(14)
for receiver in java_list(receivers):
if receiver is None or not self._logged_at(strip, int(receiver.getParam())):
continue
canvas.drawRect(
float(receiver.getImageX()),
float(receiver.getImageY()),
float(receiver.getImageX2()),
float(receiver.getImageY2()),
shade,
)
right = int(receiver.getImageX2()) - dp(2)
bottom = int(receiver.getImageY2()) - dp(2)
paint.setAlpha(0x99)
canvas.drawCircle(right - size / 2.0, bottom - size / 2.0, size * 0.8, paint)
icon.setAlpha(255)
icon.setBounds(right - size, bottom - size, right, bottom)
icon.draw(canvas)
# the profile's trash icon (under the menu button)
def watch_gallery(self, gallery):
"""Redraws the icon as the profile's gallery changes page."""
key = int(gallery.hashCode())
if key in self._watched:
return
from java import dynamic_proxy, jclass
listener_class = jclass("androidx.viewpager.widget.ViewPager$OnPageChangeListener")
avatars = self
class Pages(dynamic_proxy(listener_class)):
def onPageScrolled(self, position, offset, pixels):
pass
def onPageSelected(self, position):
on_ui(lambda: avatars.update_profile(field(gallery, "this$0")))
def onPageScrollStateChanged(self, state):
pass
listener = Pages()
gallery.addOnPageChangeListener(listener)
self._watched[key] = listener
def _current_photo(self, gallery):
if self._position is None:
method = java_class(GALLERY).getDeclaredMethod("getRealPosition")
method.setAccessible(True)
self._position = method
photos = field(gallery, "photos")
index = int(self._position.invoke(gallery))
if photos is None or not 0 <= index < photos.size():
return None
return photos.get(index)
def update_profile(self, profile):
"""Shows the icon while the expanded gallery shows a logged photo."""
if profile is None or not self._ids:
return
gallery = field(profile, "avatarsViewPager")
if gallery is None:
return
photo = self._current_photo(gallery)
shown = (
photo is not None
and int(photo.id) in self._ids
and bool(field(profile, "isPulledDown"))
)
mark = self._profile_mark(profile, create=shown)
if mark is None:
return
from android.view import View
mark.setVisibility(View.VISIBLE if shown else View.GONE)
mark.setAlpha(float(field(profile, "currentExpandAnimatorValue") or 1.0))
def _profile_mark(self, profile, create):
root = field(profile, "fragmentView")
if root is None or not hasattr(root, "addView"):
return None
for index in range(root.getChildCount()):
child = root.getChildAt(index)
if str(child.getTag()) == _MARK_TAG:
return child
if not create:
return None
from android.view import Gravity
from android.widget import FrameLayout, ImageView
from java import jint
from org.telegram.messenger import AndroidUtilities
from org.telegram.ui.ActionBar import ActionBar, Theme
from android.graphics import PorterDuff, PorterDuffColorFilter
from org.telegram.messenger import R
from hooks.render import _trash_drawable
dp = AndroidUtilities.dp
icon = _trash_drawable(R.drawable.msg_delete)
red = jint(Theme.getColor(Theme.key_text_RedBold))
icon.setColorFilter(PorterDuffColorFilter(red, PorterDuff.Mode.SRC_IN))
mark = ImageView(root.getContext())
mark.setTag(_MARK_TAG)
mark.setImageDrawable(icon)
mark.setPadding(dp(8), dp(8), dp(8), dp(8))
mark.setBackground(Theme.createCircleDrawable(dp(36), jint(0x66000000)))
params = FrameLayout.LayoutParams(dp(36), dp(36))
params.gravity = Gravity.TOP | Gravity.RIGHT
params.topMargin = AndroidUtilities.statusBarHeight + ActionBar.getCurrentActionBarHeight()
params.rightMargin = dp(10)
root.addView(mark, params)
return mark
def _trash(self):
if self._icon is None:
from android.graphics import Paint, PorterDuff, PorterDuffColorFilter
from java import jint
from org.telegram.messenger import R
from org.telegram.ui.ActionBar import Theme
from hooks.render import _trash_drawable
icon = _trash_drawable(R.drawable.msg_delete)
red = jint(Theme.getColor(Theme.key_text_RedBold))
icon.setColorFilter(PorterDuffColorFilter(red, PorterDuff.Mode.SRC_IN))
paint = Paint(Paint.ANTI_ALIAS_FLAG)
paint.setColor(jint(0x99000000 - (1 << 32))) # ARGB as a signed int
self._icon = (icon, paint)
return self._icon
+234 -57
View File
@@ -27,6 +27,7 @@ from base_plugin import MethodHook
import diag
import header
import media_share
import peers
import store
import tg
@@ -115,6 +116,13 @@ def _loaded(fragment, message_id):
return False
def jint_index(index):
"""ArrayList.remove(int index), not remove(Object)."""
from java import jint
return jint(index)
def _android_peer_id(marked):
"""Telethon sender id -> Android peer id (channels are -id)."""
marked = int(marked)
@@ -141,10 +149,13 @@ def _rich_text(node):
value = getattr(node, "text", None)
if isinstance(value, str):
return value
if value is not None: # a wrapper (url, bold, spoiler...): its inner text
return _rich_text(value)
# textConcat; every RichText carries `texts`, empty unless it concatenates
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)
return ""
def _rich_plain_text(rich):
@@ -157,6 +168,59 @@ def _rich_plain_text(rich):
return "\n".join(_rich_text(b.text) for b in blocks)
def build_from_copy(account, dialog_id, message_id, state, copy, strip):
"""A deleted message rebuilt from its logged copy (`strip`: the copy
starts with the logger's header) with the server's `state`."""
from java.util import ArrayList
from org.telegram.messenger import MessageObject, MessagesController
from org.telegram.tgnet import TLRPC
controller = MessagesController.getInstance(account)
msg = TLRPC.TL_message()
msg.id = message_id
msg.dialog_id = dialog_id
msg.peer_id = controller.getPeer(dialog_id)
sender = state.get("sender_id")
msg.from_id = (
controller.getPeer(_android_peer_id(sender)) if sender else msg.peer_id
)
msg.flags |= 256
msg.date = _epoch(state.get("source_date")) or int(copy.date)
if int(state.get("edit_count") or 0) > 0:
msg.edit_date = _epoch(state.get("edit_date")) or msg.date
msg.flags |= 32768 # edited: the app draws its pencil
text = copy.message or ""
entities = [(int(e.offset), int(e.length), e) for e in java_list(copy.entities)]
if strip:
text, entities = header.strip(text, entities)
msg.message = text
msg.entities = ArrayList()
for offset, length, entity in entities:
entity.offset = offset
entity.length = length
msg.entities.add(entity)
media = getattr(copy, "media", None)
if media is not None and not isinstance(media, TLRPC.TL_messageMediaEmpty):
msg.media = media
msg.flags |= 512
rich = getattr(copy, "rich_message", None)
if rich is not None:
if strip:
_strip_rich_header(rich)
plain = _rich_plain_text(rich)
if plain: # never a blank bubble: else it stays rich
# Text only: a normal bubble, as the original was.
msg.message = plain
msg.entities = ArrayList()
else:
msg.rich_message = rich
msg.flags2 |= 1 << 12
own = peers.own_user_id(account)
msg.out = sender is not None and int(sender) == own
msg.unread = False
return MessageObject(account, msg, True, True)
class ChatSync:
def __init__(self, plugin, api):
self.plugin = plugin
@@ -166,6 +230,8 @@ class ChatSync:
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._copies = {} # (account, dialog, id) -> (copy, strip) read, not placed
self._placing = False # a restored message is being placed
self._fragments = {} # (account, dialog) -> last ChatActivity seen
def install(self):
@@ -184,6 +250,18 @@ class ChatSync:
self.plugin, CHAT, "updateMessagesVisiblePart", AfterVisible()
)
class BeforeBadge(MethodHook):
def before_hooked_method(self, param):
if sync._placing:
param.setResult(None) # a restored message is no arrival
self.handles += hook_methods(
self.plugin,
"org.telegram.ui.Components.chat.layouts.ChatActivitySideControlsButtonsLayout",
"setButtonCount",
BeforeBadge(),
)
def uninstall(self):
for name in EDIT_UPDATES:
try:
@@ -217,6 +295,8 @@ class ChatSync:
if chat_id is None:
return
key = (account, dialog_id)
if store.is_removed_dialog(account, dialog_id):
return # its history brings its state (hooks/removed_chats.py)
self._fragments[key] = fragment
if key in self._inflight:
return
@@ -237,9 +317,10 @@ class ChatSync:
# cached answers (they were never in the app's own database).
if self.plugin.get_setting(RESTORE_MISSING, True):
present = set(ids)
revealed = store.is_revealed(account, dialog_id)
gone = [
s
for s in store.cached_deleted(account, dialog_id, low, high)
for s in store.cached_deleted(account, dialog_id, low, high, revealed)
if int(s["message_id"]) not in present
]
if gone:
@@ -345,11 +426,22 @@ class ChatSync:
# restoring (background -> UI)
def _restore(self, fragment, account, dialog_id, chat_id, me, states):
revealed = store.is_revealed(account, dialog_id)
for state in states:
message_id = int(state["message_id"])
if not revealed and store.is_hidden(account, dialog_id, message_id):
continue # removed from view
key = (account, dialog_id, message_id)
if key in self._restoring:
continue
fetched = self._copies.get(key)
if fetched is not None: # read before; not placed yet
on_ui(
lambda f=fetched, m=message_id, st=state: self._insert(
fragment, account, dialog_id, m, st, *f
)
)
continue
pointer, strip = self._source(me, chat_id, message_id, state)
if pointer is None:
continue
@@ -371,8 +463,24 @@ class ChatSync:
return state.get("logged"), True
def _fetch_copy(self, fragment, account, dialog_id, message_id, state, pointer, strip):
"""Reads the copy through a member of its log group signed in here
(this account, else the master); another account's download of its
media lets this one show it (media_share)."""
channel_id = peers.channel_id_from_marked(pointer["chat_id"])
copy_id = int(pointer["message_id"])
reader = media_share.reader_for(account, pointer["chat_id"], self.api.masters())
if reader is None:
self._restoring.discard((account, dialog_id, message_id))
diag.log(f"copy {pointer}: no account here is in its log group")
return
def insert(copy):
if reader != account:
ready, total = media_share.copy_media(reader, copy)
if total:
diag.log(f"copy {pointer}: {ready}/{total} media through account {reader}")
self._copies[(account, dialog_id, message_id)] = (copy, strip)
on_ui(lambda: self._insert(fragment, account, dialog_id, message_id, state, copy, strip))
def done(found, error):
copy = (found or {}).get(copy_id)
@@ -380,80 +488,55 @@ class ChatSync:
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))
in_background(lambda: insert(copy))
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)
tg.get_messages(reader, -channel_id, [copy_id], done)
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))
key = (account, dialog_id, message_id)
self._restoring.discard(key)
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)
):
self._copies.pop(key, None)
return
ids = [int(m.getId()) for m in java_list(messages) if m is not None and int(m.getId()) > 0]
at_end = _end_reached(fragment, "forwardEndReached")
if not at_end and (not ids or not min(ids) < message_id < max(ids)):
return # outside what is loaded: placed when that part is
self._copies.pop(key, None)
from java import jboolean
from java.util import ArrayList
obj = self._build(account, dialog_id, message_id, state, copy, strip)
obj = build_from_copy(account, dialog_id, message_id, state, copy, strip)
store.mark_deleted(account, dialog_id, [message_id])
batch = ArrayList()
batch.add(obj)
# Away from the newest messages the app only counts arrivals (the
# scroll-down badge); with the end "loaded" it places the message
# by its id among the loaded ones.
# It is no arrival: the unread count (scroll-down badge) stays.
flags = field(fragment, "forwardEndReached")
unread = int(field(fragment, "newUnreadMessageCount") or 0)
if not at_end:
flags[0] = True
self._placing = True
try:
_process_new_messages().invoke(fragment, batch, jboolean(False))
finally:
self._placing = False
if not at_end:
flags[0] = False
from hook_utils import set_private_field
from java import jint
set_private_field(fragment, "newUnreadMessageCount", jint(unread))
diag.log(f"restored {dialog_id}/{message_id}")
# cells (UI thread)
@@ -495,7 +578,101 @@ class ChatSync:
if fragment is not None:
on_ui(lambda: self.schedule(fragment), delay_ms)
def refresh_dialog(self, account, dialog_id, ids):
def _open_chat(self, account, dialog_id):
"""The ChatActivity showing the dialog: the one last synced, else the
screen on top when it is that chat."""
fragment = self._fragments.get((account, dialog_id))
if fragment is not None:
return fragment
from client_utils import get_last_fragment
top = get_last_fragment()
try:
if (
top is not None
and int(top.getCurrentAccount()) == account
and int(top.getDialogId()) == dialog_id
):
return top
except Exception:
pass
return None
def refresh_dialog(self, account, dialog_id, ids):
fragment = self._open_chat(account, dialog_id)
if fragment is not None:
on_ui(lambda: self.refresh(fragment, set(int(i) for i in ids)))
def reset(self, account=None, dialog_id=None):
"""Forgets the cache of a chat (all chats when None) and redraws and
re-checks the open one(s). Returns the number of marks forgotten."""
removed = store.reset(account, dialog_id)
for (acc, dia), fragment in list(self._fragments.items()):
if dialog_id is not None and (acc, dia) != (account, dialog_id):
continue
def redraw(fragment=fragment):
adapter = field(fragment, "chatAdapter")
if adapter is not None:
for m in java_list(field(fragment, "messages")):
if m is not None:
m.forceUpdate = True
adapter.notifyDataSetChanged()
self.schedule(fragment)
on_ui(redraw)
return removed
def restore_hidden(self, fragment):
"""The chat reveals its hidden messages: those deleted in the loaded
range come back from the logger (UI thread). Returns how many."""
account = int(fragment.getCurrentAccount())
dialog_id = int(fragment.getDialogId())
chat_id = peers.marked_chat_id(account, dialog_id)
ids = [
int(m.getId())
for m in java_list(field(fragment, "messages"))
if m is not None and int(m.getId()) > 0
]
if chat_id is None or not ids:
return 0
low = 1 if _end_reached(fragment, "endReached") else min(ids)
high = _MAX_ID if _end_reached(fragment, "forwardEndReached") else max(ids)
present = set(ids)
hidden = set(store.hidden_deleted(account, dialog_id))
gone = [
s
for s in store.cached_deleted(account, dialog_id, low, high, True)
if int(s["message_id"]) in hidden and int(s["message_id"]) not in present
]
if gone:
me = peers.own_user_id(account)
in_background(
lambda: self._restore(fragment, account, dialog_id, chat_id, me, gone)
)
return len(gone)
def drop_from_view(self, account, dialog_id, ids):
"""Takes messages out of the open chat's list (UI thread)."""
fragment = self._open_chat(account, dialog_id)
if fragment is None:
return
wanted = set(int(i) for i in ids)
messages = field(fragment, "messages")
if messages is None:
return
removed = 0
for index in range(messages.size() - 1, -1, -1):
message = messages.get(index)
if message is not None and int(message.getId()) in wanted:
messages.remove(jint_index(index))
removed += 1
try:
by_id = field(fragment, "messagesDict")[0]
for i in wanted:
by_id.remove(int(i))
except Exception:
pass
adapter = field(fragment, "chatAdapter")
if removed and adapter is not None:
adapter.notifyDataSetChanged()
+321
View File
@@ -0,0 +1,321 @@
"""The app's "Delete message" dialog, extended:
- a "Keep message for myself" checkbox: ticked, the deletion goes to Telegram but
the message stays on this device, marked deleted (its local removal is
filtered out of MessagesStorage.markMessagesAsDeleted and the
messagesDeleted notification; the server's echo then counts as someone
else's deletion, see hooks/deletes.py). Unticked (default), it is deleted
as usual and hidden (on the server, for every client), so it is not
restored from the logger;
- already deleted (kept) messages get a plain dialog without checkboxes
whose button removes them from this device's view and hides them.
AlertsCreator.createDeleteMessagesAlert(fragment, user, chat, encryptedChat,
chatInfo, mergeDialogId, selectedMessage, selectedMessages[2], group,
topicId, mode, participants, onDelete, hideDim, resourcesProvider) builds and
shows the dialog (exteraGram 12.10.6, probed).
"""
import time
from base_plugin import MethodHook
import diag
import store
from jutil import field, hook_methods, java_ints, java_list, on_ui
ALERTS = "org.telegram.ui.Components.AlertsCreator"
_KEEP_SECONDS = 60
def _targets(args):
"""MessageObjects the dialog is about."""
selected, arrays, group = args[6], args[7], args[8]
if selected is not None:
if group is not None:
return [m for m in java_list(group.messages) if m is not None]
return [selected]
out = []
for array in arrays or []:
if array is None:
continue
for index in range(array.size()):
out.append(array.valueAt(index))
return [m for m in out if m is not None]
def _key(message):
return int(message.currentAccount), int(message.getDialogId()), int(message.getId())
def _deleted(message):
return int(message.getId()) > 0 and store.is_deleted(*_key(message))
class DeleteDialog:
def __init__(self, plugin, chats, hidden):
self.plugin = plugin
self.chats = chats # hooks/chat.ChatSync
self.hidden = hidden # hooks/hidden.Hidden
self.handles = []
self._choice = None # [checked] of the open dialog
self._keeping = {} # (account, id) -> monotonic time to keep until
# filtering a kept deletion out of the local removal
def keeping(self, account, ids):
now = time.monotonic()
return [i for i in ids if self._keeping.get((account, i), 0) > now]
def install(self):
dialog = self
class BeforeAlert(MethodHook):
def before_hooked_method(self, param):
try:
if dialog.before_alert(param):
param.setResult(None)
except Exception as exc:
diag.error("delete_dialog.before", exc)
def after_hooked_method(self, param):
try:
dialog.after_alert(param)
except Exception as exc:
diag.error("delete_dialog.after", exc)
class BeforeStorage(MethodHook):
def before_hooked_method(self, param):
try:
dialog.filter_storage(param)
except Exception as exc:
diag.error("delete_dialog.storage", exc)
class BeforeNotify(MethodHook):
def before_hooked_method(self, param):
try:
dialog.filter_notification(param)
except Exception as exc:
diag.error("delete_dialog.notify", exc)
self.handles += hook_methods(
self.plugin, ALERTS, "createDeleteMessagesAlert", BeforeAlert()
)
self.handles += hook_methods(
self.plugin,
"org.telegram.messenger.MessagesStorage",
"markMessagesAsDeleted",
BeforeStorage(),
)
self.handles += hook_methods(
self.plugin,
"org.telegram.messenger.NotificationCenter",
"postNotificationName",
BeforeNotify(),
)
def uninstall(self):
for handle in self.handles:
try:
self.plugin.unhook_method(handle)
except Exception:
pass
self.handles = []
# the dialog
def before_alert(self, param):
"""Already deleted messages only: our own dialog instead (True)."""
self._choice = None
targets = _targets(param.args)
if not targets or not all(_deleted(m) for m in targets):
return False
on_delete, hide_dim = param.args[12], param.args[13]
on_ui(lambda: self._hide_dialog(param.args[0], targets, on_delete, hide_dim))
return True
def after_alert(self, param):
"""Adds the "Keep message here" checkbox to the app's dialog."""
targets = _targets(param.args)
if not targets or all(_deleted(m) for m in targets):
return
dialog = param.args[0].getVisibleDialog()
if dialog is None:
return
content = _message_column(dialog.getWindow().getDecorView())
if content is None:
diag.log("delete dialog: no place for the keep checkbox")
return
from android.widget import LinearLayout
from android_utils import OnClickListener
from org.telegram.messenger import AndroidUtilities
from org.telegram.ui.Cells import CheckBoxCell
choice = [False]
self._choice = choice
cell = CheckBoxCell(content.getContext(), 1)
cell.setText("Keep message for myself", "", False, False)
def toggle(_view):
choice[0] = not choice[0]
cell.setChecked(choice[0], True)
cell.setOnClickListener(OnClickListener(toggle))
params = LinearLayout.LayoutParams(-1, -2)
params.topMargin = AndroidUtilities.dp(4)
content.addView(cell, params)
def _hide_dialog(self, fragment, targets, on_delete, hide_dim):
from ui.alert import AlertDialogBuilder
count = len(targets)
builder = AlertDialogBuilder(fragment.getParentActivity())
builder.set_title("Hide deleted message" + ("s" if count > 1 else ""))
builder.set_message(
("These messages were" if count > 1 else "This message was")
+ " already deleted. Hide "
+ ("them" if count > 1 else "it")
+ " from this chat? The logger keeps "
+ ("them" if count > 1 else "it")
+ ": the chat menu reveals hidden messages."
)
def remove(_builder, _which):
self._remove_from_view(targets)
if on_delete is not None:
on_delete.run()
def dismissed(_builder):
# The app dims the chat for its own dialog and undims it when
# that closes: without this the chat stays touch-blocked.
if hide_dim is not None:
hide_dim.run()
builder.set_positive_button("Hide", remove)
builder.set_negative_button("Back")
builder.make_button_red(AlertDialogBuilder.BUTTON_POSITIVE)
builder.set_on_dismiss_listener(dismissed)
builder.show()
def _remove_from_view(self, targets):
"""Hides already deleted messages (on the server) and takes them out
of this device's chat."""
from java import jboolean, jint, jlong
from java.lang import Object
from java.util import ArrayList
from org.telegram.messenger import ChatObject, MessagesController, MessagesStorage
from org.telegram.messenger import NotificationCenter
by_chat = {}
for message in targets:
account, dialog_id, message_id = _key(message)
by_chat.setdefault((account, dialog_id), []).append(message_id)
for (account, dialog_id), ids in by_chat.items():
# Its cached state stays: revealing the chat's hidden messages
# restores it from there.
self.hidden.hide(account, dialog_id, ids)
channel_id = 0
if dialog_id < 0:
chat = MessagesController.getInstance(account).getChat(-dialog_id)
if chat is not None and ChatObject.isChannel(chat):
channel_id = -dialog_id
items = ArrayList()
for i in ids:
items.add(jint(i))
MessagesStorage.getInstance(account).markMessagesAsDeleted(
jlong(dialog_id), items, jboolean(True), jboolean(False), jint(0), jint(0)
)
from java import jarray
NotificationCenter.getInstance(account).postNotificationName(
NotificationCenter.messagesDeleted,
jarray(Object)([items, jlong(channel_id), jboolean(False)]),
)
# The notification removes them where the app tracks them; a
# kept message may sit in the open chat's list only.
self.chats.drop_from_view(account, dialog_id, ids)
diag.log(f"removed from view {dialog_id}: {ids}")
# the deletion itself
def apply_choice(self, account, dialog_id, ids):
"""The dialog's choice for ids being deleted (MessagesController.
deleteMessages, whose overloads call one another: the first call
takes the choice, later ones see the kept ids). Returns kept ids."""
choice, self._choice = self._choice, None
if choice is None:
return self.keeping(account, ids)
if choice[0]:
until = time.monotonic() + _KEEP_SECONDS
for i in ids:
self._keeping[(account, i)] = until
# Marked deleted here; the server's echo is then kept like any
# deletion by someone else (hooks/deletes.py).
store.mark_deleted(account, dialog_id, ids)
diag.log(f"keeping own deletion {dialog_id}: {ids}")
return list(ids)
self.hidden.hide(account, dialog_id, ids)
return []
def filter_storage(self, param):
"""markMessagesAsDeleted(dialogId, ArrayList ids, ...): drop kept ids."""
args = param.args
if len(args) < 2 or not hasattr(args[1], "size"):
return
account = int(field(param.thisObject, "currentAccount") or 0)
kept = set(self.keeping(account, java_ints(args[1])))
if not kept:
return
items = args[1]
for index in range(items.size() - 1, -1, -1):
if int(items.get(index)) in kept:
items.remove(jint_index(index))
def filter_notification(self, param):
"""messagesDeleted with kept ids: those are dropped from it."""
from org.telegram.messenger import NotificationCenter
if int(param.args[0]) != int(NotificationCenter.messagesDeleted):
return
extra = param.args[1]
if extra is None or len(extra) < 1 or not hasattr(extra[0], "size"):
return
account = int(field(param.thisObject, "currentAccount") or 0)
kept = set(self.keeping(account, java_ints(extra[0])))
if not kept:
return
items = extra[0]
for index in range(items.size() - 1, -1, -1):
if int(items.get(index)) in kept:
items.remove(jint_index(index))
if items.size() == 0:
param.setResult(None)
def jint_index(index):
"""ArrayList.remove(int index), not remove(Object)."""
from java import jint
return jint(index)
def _message_column(view):
"""The dialog's vertical layout holding its message text (and checkboxes)."""
from android.widget import LinearLayout, TextView
found = []
def walk(node):
if found:
return
if isinstance(node, TextView):
parent = node.getParent()
if isinstance(parent, LinearLayout) and parent.getOrientation() == LinearLayout.VERTICAL:
found.append(parent)
return
if hasattr(node, "getChildCount"):
for index in range(node.getChildCount()):
walk(node.getChildAt(index))
walk(view)
return found[0] if found else None
+8 -1
View File
@@ -23,10 +23,11 @@ UPDATES = ("TL_updateDeleteMessages", "TL_updateDeleteChannelMessages")
class Deletes:
def __init__(self, plugin, api, chats):
def __init__(self, plugin, api, chats, dialog=None):
self.plugin = plugin
self.api = api
self.chats = chats
self.dialog = dialog # hooks/delete_dialog.DeleteDialog
self.handles = []
def install(self):
@@ -70,6 +71,12 @@ class Deletes:
return
account = int(field(param.thisObject, "currentAccount") or 0)
dialog_id = int(args[3] or 0)
kept = self.dialog.apply_choice(account, dialog_id, ids) if self.dialog else []
if kept:
self.chats.refresh_dialog(account, dialog_id, kept)
ids = [i for i in ids if i not in kept]
if not ids:
return
store.note_own_deletion(account, ids)
store.forget_deleted(account, dialog_id, ids)
+202
View File
@@ -0,0 +1,202 @@
"""Deleted messages removed from view ("hidden"), marked on the server so
every client skips them; a chat can reveal its hidden messages again.
- `flush` sends the marks waiting in the store to the server;
- the chat menu toggles revealing (per chat, until the app restarts): the
hidden deleted messages come back from the logger, then go again;
- a revealed hidden message has "Unhide" in its menu (hooks/menu.py), and
while revealing, the selection bar gets an Unhide button for those
selected (ChatActivity.createActionMode adds the bar's items,
updateActionModeTitle runs on every selection change; probed on 12.10.6).
"""
import threading
from base_plugin import MethodHook
import diag
import peers
import store
from api import ApiError
from jutil import field, hook_methods, in_background, on_ui
CHAT = "org.telegram.ui.ChatActivity"
_UNHIDE_ID = 0x4D4C01 # the selection bar item
def _bulletin(text, error=False):
def show():
from ui.bulletin import BulletinHelper
(BulletinHelper.show_error if error else BulletinHelper.show_info)(text)
on_ui(show)
def _selected(chat):
"""MessageObjects selected in the chat (both halves of a merged chat)."""
out = []
for array in field(chat, "selectedMessagesIds") or []:
if array is None:
continue
for index in range(array.size()):
message = array.valueAt(index)
if message is not None:
out.append(message)
return out
def is_revealed_hidden(message):
"""A hidden deleted message shown because its chat reveals them."""
if message is None or int(message.getId()) <= 0:
return False
account, dialog_id = int(message.currentAccount), int(message.getDialogId())
message_id = int(message.getId())
return (
store.is_revealed(account, dialog_id)
and store.is_deleted(account, dialog_id, message_id)
and store.is_hidden(account, dialog_id, message_id)
)
class Hidden:
def __init__(self, plugin, api, chats):
self.plugin = plugin
self.api = api
self.chats = chats # hooks/chat.ChatSync
self.handles = []
self._flushing = threading.Lock()
self._items = {} # ChatActivity classGuid -> the bar's Unhide item
def install(self):
hidden = self
class AfterActionMode(MethodHook):
def after_hooked_method(self, param):
try:
hidden.add_bar_item(param.thisObject)
except Exception as exc:
diag.error("hidden.bar", exc)
class AfterTitle(MethodHook):
def after_hooked_method(self, param):
try:
hidden.update_bar_item(param.thisObject)
except Exception as exc:
diag.error("hidden.title", exc)
self.handles += hook_methods(self.plugin, CHAT, "createActionMode", AfterActionMode())
self.handles += hook_methods(self.plugin, CHAT, "updateActionModeTitle", AfterTitle())
in_background(self.flush)
def uninstall(self):
for handle in self.handles:
try:
self.plugin.unhook_method(handle)
except Exception:
pass
self.handles = []
# the server
def flush(self):
"""Sends hidden marks waiting in the store (background)."""
if not self.api.configured or not self._flushing.acquire(blocking=False):
return
try:
for (account, dialog_id, hidden), ids in store.pending_hidden().items():
me = peers.own_user_id(account)
chat_id = peers.marked_chat_id(account, dialog_id)
if not me or chat_id is None:
continue
try:
self.api.hidden(me, chat_id, ids, hidden)
except ApiError as exc:
diag.log(f"hidden {chat_id}: {exc}")
continue
store.hidden_sent(account, dialog_id, ids, hidden)
diag.log(f"{'hid' if hidden else 'unhid'} {chat_id}: {ids}")
finally:
self._flushing.release()
def hide(self, account, dialog_id, ids, hidden=True):
store.set_hidden(account, dialog_id, ids, hidden)
in_background(self.flush)
# revealing (chat menu)
def toggle_reveal(self, fragment):
account = int(fragment.getCurrentAccount())
dialog_id = int(fragment.getDialogId())
on = not store.is_revealed(account, dialog_id)
store.reveal(account, dialog_id, on)
if on:
count = self.chats.restore_hidden(fragment)
# And the server's answer, for those not cached.
store.expire_coverage(account, dialog_id)
self.chats.schedule(fragment)
_bulletin(
f"Message Logger: revealing hidden deleted messages ({count} loaded)"
if count
else "Message Logger: revealing hidden deleted messages"
)
else:
ids = store.hidden_deleted(account, dialog_id)
self.chats.drop_from_view(account, dialog_id, ids)
_bulletin("Message Logger: hidden deleted messages are hidden again")
# unhiding
def unhide(self, messages):
by_chat = {}
for message in messages:
if is_revealed_hidden(message):
key = (int(message.currentAccount), int(message.getDialogId()))
by_chat.setdefault(key, []).append(int(message.getId()))
for (account, dialog_id), ids in by_chat.items():
self.hide(account, dialog_id, ids, hidden=False)
self.chats.refresh_dialog(account, dialog_id, ids)
count = sum(len(ids) for ids in by_chat.values())
if count:
_bulletin(f"Message Logger: {count} message{'s' if count > 1 else ''} unhidden")
# the selection bar
def add_bar_item(self, chat):
"""The bar's Unhide item (also for a chat opened before the plugin
loaded: its bar exists already)."""
bar = chat.getActionBar()
menu = bar.createActionMode() if bar is not None else None
if menu is None:
return None
from android.view import View
from android_utils import OnClickListener
from org.telegram.messenger import AndroidUtilities, R
item = menu.getItem(_UNHIDE_ID)
if item is None:
item = menu.addItemWithWidth(
_UNHIDE_ID, R.drawable.msg_views, AndroidUtilities.dp(54)
)
item.setContentDescription("Unhide")
item.setVisibility(View.GONE)
def chosen(_view):
self.unhide(_selected(chat))
chat.clearSelectionMode()
item.setOnClickListener(OnClickListener(chosen))
self._items[int(chat.getClassGuid())] = item
return item
def update_bar_item(self, chat):
item = self._items.get(int(chat.getClassGuid())) or self.add_bar_item(chat)
if item is None:
return
from android.view import View
account, dialog_id = int(chat.getCurrentAccount()), int(chat.getDialogId())
show = store.is_revealed(account, dialog_id) and any(
is_revealed_hidden(m) for m in _selected(chat)
)
item.setVisibility(View.VISIBLE if show else View.GONE)
+32 -18
View File
@@ -1,4 +1,5 @@
""""Edit history" in the message menu itself, right above Forward.
""""Edit history" (and "Unhide" for a revealed hidden message, see
hooks/hidden.py) in the message menu itself, right above Forward.
Plugin menu items (MenuItemData) only go into the menu's Plugins folder, and
exteraGram drops option ids it doesn't know from fillMessageMenu's lists, so
@@ -12,10 +13,12 @@ from base_plugin import MethodHook
import diag
import store
from hooks.hidden import is_revealed_hidden
from jutil import field, hook_methods, java_class
CHAT = "org.telegram.ui.ChatActivity"
_LABEL = "Edit history"
_HISTORY = "Edit history"
_UNHIDE = "Unhide"
def _worth_it(message):
@@ -58,9 +61,10 @@ def _find_item(view, text):
class Menu:
def __init__(self, plugin, open_history):
def __init__(self, plugin, open_history, unhide):
self.plugin = plugin
self.open_history = open_history
self.unhide = unhide # hooks/hidden.Hidden.unhide
self.handles = []
self._close = None
@@ -87,39 +91,49 @@ class Menu:
def add_item(self, chat):
message = field(chat, "selectedObject")
popup = field(chat, "scrimPopupWindow")
if popup is None or not _worth_it(message):
if popup is None or message is None:
return
# The createMenu overloads call one another: add the row once.
if _find_item(popup.getContentView(), _LABEL) is not None:
return
from org.telegram.messenger import LocaleController, R
from org.telegram.messenger import R
rows = []
if _worth_it(message):
rows.append((_HISTORY, R.drawable.msg_edit, lambda: self.open_history(message)))
if is_revealed_hidden(message):
rows.append((_UNHIDE, R.drawable.msg_views, lambda: self.unhide([message])))
# The createMenu overloads call one another: add the rows once.
content = popup.getContentView()
if not rows or _find_item(content, rows[0][0]) is not None:
return
from org.telegram.messenger import LocaleController
# Forward is a row in some chats and an icon in the bottom action bar
# in others (groups): then the row goes first in the list.
# in others (groups): then the rows go first in the list.
anchor = _find_item(
content, str(LocaleController.getString(R.string.Forward))
) or _first_item(content)
if anchor is None:
diag.log("menu: no row to place Edit history by")
diag.log("menu: no row to place the rows by")
return
forward = anchor
for label, icon, action in rows:
self._add_row(chat, anchor, label, icon, action)
def _add_row(self, chat, anchor, label, icon, action):
from android_utils import OnClickListener
from org.telegram.ui.ActionBar import ActionBarMenuSubItem
parent = forward.getParent()
item = ActionBarMenuSubItem(forward.getContext(), False, False)
item.setTextAndIcon(_LABEL, R.drawable.msg_edit)
color = forward.getTextView().getCurrentTextColor()
parent = anchor.getParent()
item = ActionBarMenuSubItem(anchor.getContext(), False, False)
item.setTextAndIcon(label, icon)
color = anchor.getTextView().getCurrentTextColor()
item.setColors(color, color)
item.setMinimumWidth(forward.getMinimumWidth())
item.setMinimumWidth(anchor.getMinimumWidth())
def chosen(_view):
self._close_menu(chat)
self.open_history(message)
action()
item.setOnClickListener(OnClickListener(chosen))
parent.addView(item, parent.indexOfChild(forward), forward.getLayoutParams())
parent.addView(item, parent.indexOfChild(anchor), anchor.getLayoutParams())
def _close_menu(self, chat):
if self._close is None:
+506
View File
@@ -0,0 +1,506 @@
"""Chats an account lost (left, kicked, deleted by it or the other side), kept
by the logger: reported to the server, listed among the account's chats
again, and opened read-only with their logged messages, all deleted.
- A chat leaves the list through MessagesController.deleteDialog(long did,
int onlyHistory[, boolean revoke]) (the user deleting it, or the app when
the account left or was kicked); onlyHistory 1 only clears the history.
The removal is reported, `own` when the call came from the app's screens
(the user deleted it: it is not listed again). Deleting a listed removed
chat hides it.
- The server's list (`/chats/removed`) is put into MessagesController
(dialogs_dict, allDialogs, dialogMessage) before every sortDialogs, with
the user/chat it needs (the app's own when it still has it, else a
stand-in) and the chat's latest logged photo as its avatar
(ImageReceiver.setForUserOrChat).
- Opening one: its requests never reach Telegram (sendRequestInternal):
getHistory and channels.getMessages are answered from the logged history
(views/removed_chat.py), messages.search with nothing, the rest dropped;
the input bar becomes a notice (ChatActivity.updateBottomOverlay).
"""
import json
import threading
import time
from base_plugin import MethodHook
import accounts
import diag
import media_share
import peers
import store
from api import ApiError
from config import DEFAULTS, SHOW_REMOVED_CHATS
from hooks import avatars
from jutil import field, hook_methods, in_background, java_list, on_ui
from views import removed_chat
from views.comments import make_read_only
from views.history import _epoch
CONTROLLER = "org.telegram.messenger.MessagesController"
CONNECTIONS = "org.telegram.tgnet.ConnectionsManager"
CHAT = "org.telegram.ui.ChatActivity"
IMAGES = "org.telegram.messenger.ImageReceiver"
STORAGE = "org.telegram.messenger.MessagesStorage"
_NOTICE = "Removed chat · kept by the logger"
_REFRESH_SECONDS = 900
_REPORT_AGAIN_SECONDS = 30
_ANSWERED = (
"TLRPC$TL_messages_getHistory",
"TLRPC$TL_channels_getMessages",
"TLRPC$TL_messages_search",
)
def _from_screens():
"""Whether the running call came from the app's screens (a user action)."""
from java.lang import Thread
return any(
str(frame.getClassName()).startswith("org.telegram.ui.")
for frame in Thread.currentThread().getStackTrace()
)
def _dialog_id(chat_id):
from hooks.chat import _android_peer_id
return _android_peer_id(int(chat_id))
def _target(request):
"""The dialog a request is about (its peer/channel/user), else None."""
for name in ("peer", "channel", "user_id", "id"):
value = getattr(request, name, None)
if value is None or isinstance(value, (bool, int, float, str)):
continue
kind = type(value).__name__
if "nputPeerChannel" in kind or "nputChannel" in kind:
return -int(value.channel_id)
if "nputPeerChat" in kind:
return -int(value.chat_id)
if "nputPeerUser" in kind or kind.endswith("TL_inputUser"):
return int(value.user_id)
return None
class RemovedChats:
def __init__(self, plugin, api):
self.plugin = plugin
self.api = api
self.handles = []
self._lists = {} # account -> {dialog_id: server item}
self._peers = {} # (account, dialog_id) -> User/Chat (cached or stand-in)
# account -> {dialog_id: (Dialog, [MessageObject], its top message id)}
self._injected = {}
self._histories = {} # (account, dialog_id) -> views.removed_chat.History
self._avatar_paths = {} # (account, dialog_id) -> local photo path
self._reported = {} # (account, dialog_id) -> monotonic time
self._lock = threading.Lock()
self._timer = None
def _enabled(self):
return bool(self.plugin.get_setting(SHOW_REMOVED_CHATS, DEFAULTS[SHOW_REMOVED_CHATS]))
def install(self):
chats = self
def hook(class_name, name, before=None, after=None, arity=None):
class Hook(MethodHook):
def before_hooked_method(self, param):
if before is not None:
try:
before(param)
except Exception as exc:
diag.error(f"removed_chats.{name}", exc)
def after_hooked_method(self, param):
if after is not None:
try:
after(param)
except Exception as exc:
diag.error(f"removed_chats.{name}", exc)
chats.handles += hook_methods(chats.plugin, class_name, name, Hook(), arity)
hook(CONTROLLER, "deleteDialog", before=self.before_delete, arity=2)
hook(CONTROLLER, "deleteDialog", before=self.before_delete, arity=3)
hook(CONTROLLER, "sortDialogs", before=self.before_sort)
hook(CONNECTIONS, "sendRequestInternal", before=self.before_request)
hook(CHAT, "updateBottomOverlay", after=self.after_overlay)
hook(STORAGE, "putMessages", before=self.before_store)
hook(IMAGES, "setForUserOrChat", before=self.before_avatar, arity=6)
in_background(self._start)
def _start(self):
"""Lists from the last run at once (offline too), then the server's."""
for account, _user in accounts.signed_in():
cached = store.get_meta(f"removed_chats:{account}")
if cached:
self._set_list(account, json.loads(cached))
on_ui(self.inject_all)
self._schedule(5)
def uninstall(self):
if self._timer is not None:
self._timer.cancel()
for handle in self.handles:
try:
self.plugin.unhook_method(handle)
except Exception:
pass
self.handles = []
for account in list(self._injected):
self._lists[account] = {}
try:
self.inject(account)
except Exception as exc:
diag.error("removed_chats.uninstall", exc)
# the server's list (background)
def _schedule(self, seconds):
self._timer = threading.Timer(seconds, self._tick)
self._timer.daemon = True
self._timer.start()
def _tick(self):
try:
self.refresh_all()
finally:
self._schedule(_REFRESH_SECONDS)
def refresh_all(self):
if not self.api.configured or not self._enabled():
return
for account, _user in accounts.signed_in():
self.refresh(account)
def refresh(self, account):
me = peers.own_user_id(account)
try:
chats = self.api.removed_chats(me)
except ApiError as exc:
diag.log(f"removed chats: {exc}")
return
if chats is None:
return
store.put_meta(f"removed_chats:{account}", json.dumps(chats))
self._set_list(account, chats)
with self._lock:
for key in [k for k in self._histories if k[0] == account]:
del self._histories[key] # read again on next open
on_ui(lambda: self.inject(account))
for dialog_id, item in self._lists.get(account, {}).items():
avatar = item.get("avatar") or {}
if avatar.get("has_photo") and (account, dialog_id) not in self._avatar_paths:
in_background(lambda d=dialog_id, a=avatar: self._load_avatar(account, d, a))
def _set_list(self, account, chats):
lists = {}
for item in chats:
dialog_id = _dialog_id(item["chat_id"])
lists[dialog_id] = item
if (account, dialog_id) not in self._peers:
self._peers[(account, dialog_id)] = self._peer(account, dialog_id, item)
self._lists[account] = lists
def _peer(self, account, dialog_id, item):
"""The app's user/chat (cache or storage), else a stand-in."""
from org.telegram.messenger import MessagesController, MessagesStorage
import tg
controller = MessagesController.getInstance(account)
if dialog_id > 0:
user = controller.getUser(dialog_id)
if user is None:
try:
user = MessagesStorage.getInstance(account).getUserSync(dialog_id)
except Exception:
user = None
return user or removed_chat.stand_in_user(controller, dialog_id, item.get("title"))
chat = tg.channel_chat(account, -dialog_id)
return chat or removed_chat.stand_in_chat(
controller, -dialog_id, item.get("title"), item.get("kind")
)
def _load_avatar(self, account, dialog_id, avatar):
found = avatars.logged_photo(account, avatar, self.api.masters())
if found is None:
return
photo, reader, copy = found
path = media_share.photo(reader, photo, copy, 160)
if path is None:
return
self._avatar_paths[(account, dialog_id)] = path
on_ui(lambda: self._redraw(account))
# the chat list (UI thread)
def inject_all(self):
for account in list(self._lists):
self.inject(account)
def inject(self, account):
"""Puts the account's removed chats into the app's chat list (and
takes out those no longer removed)."""
from org.telegram.messenger import MessagesController
controller = MessagesController.getInstance(account)
wanted = {}
if self._enabled():
wanted = {
d: item
for d, item in self._lists.get(account, {}).items()
if not item.get("hidden_at") and item.get("top_message")
}
current = self._injected.setdefault(account, {})
for dialog_id in list(current):
if dialog_id not in wanted and self.is_injected(account, dialog_id):
self._take_out(controller, dialog_id, current.pop(dialog_id)[0])
store.set_removed_dialog(account, dialog_id, False)
for dialog_id, item in wanted.items():
if dialog_id in current or controller.dialogs_dict.get(dialog_id) is not None:
continue # listed, or the app has the chat (it is back)
current[dialog_id] = self._dialog(controller, account, dialog_id, item)
store.set_removed_dialog(account, dialog_id, True)
self._put(controller, account)
controller.sortDialogs(None)
self._redraw(account)
def _dialog(self, controller, account, dialog_id, item):
from org.telegram.messenger import MessageObject
from org.telegram.tgnet import TLRPC
peer = self._peers.get((account, dialog_id))
if dialog_id > 0:
controller.putUser(peer, True)
else:
controller.putChat(peer, True)
top = item["top_message"]
dialog = TLRPC.TL_dialog()
dialog.id = dialog_id
dialog.peer = controller.getPeer(dialog_id)
dialog.top_message = int(top["message_id"])
dialog.read_inbox_max_id = dialog.top_message
dialog.read_outbox_max_id = dialog.top_message
dialog.last_message_date = _epoch(top.get("source_date"))
from java.util import ArrayList
message = removed_chat.from_text(account, dialog_id, top)
store.put_server_states(account, dialog_id, [top])
# dialogMessage holds the dialog's preview messages (an album's parts).
preview = ArrayList()
preview.add(MessageObject(account, message, False, False))
return dialog, preview, int(dialog.top_message)
def _put(self, controller, account):
"""Our dialogs in the controller's lists (they rebuild them)."""
injected = self._injected.get(account) or {}
dialogs = controller.dialogs_dict
everything = controller.allDialogs
messages = controller.dialogMessage
for dialog_id, (dialog, message, _top) in list(injected.items()):
if not self.is_injected(account, dialog_id):
continue # back: the app's dialog now
existing = dialogs.get(dialog_id)
if existing is not None and not existing.equals(dialog):
del injected[dialog_id] # the app has the chat again
everything.remove(dialog)
continue
if existing is None:
dialogs.put(dialog_id, dialog)
if not everything.contains(dialog):
everything.add(dialog)
if messages.get(dialog_id) is None:
messages.put(dialog_id, message)
def _take_out(self, controller, dialog_id, dialog):
if controller.dialogs_dict.get(dialog_id) is not None and controller.dialogs_dict.get(
dialog_id
).equals(dialog):
controller.dialogs_dict.remove(dialog_id)
controller.dialogMessage.remove(dialog_id)
controller.allDialogs.remove(dialog)
def _redraw(self, account):
from java import jarray
from java.lang import Object
from org.telegram.messenger import NotificationCenter
NotificationCenter.getInstance(account).postNotificationName(
NotificationCenter.dialogsNeedReload, jarray(Object)([])
)
def before_sort(self, param):
account = int(field(param.thisObject, "currentAccount") or 0)
if self._injected.get(account):
self._put(param.thisObject, account)
def is_injected(self, account, dialog_id):
"""Whether the dialog is a removed chat listed here. One that got a
new message is back: the app keeps the dialog, it is no longer ours."""
account, dialog_id = int(account), int(dialog_id)
entry = (self._injected.get(account) or {}).get(dialog_id)
if entry is None:
return False
if int(entry[0].top_message) == entry[2]:
return True
with self._lock:
(self._injected.get(account) or {}).pop(dialog_id, None)
self._lists.get(account, {}).pop(dialog_id, None)
self._histories.pop((account, dialog_id), None)
store.set_removed_dialog(account, dialog_id, False)
diag.log(f"removed chat {dialog_id} is back")
return False
# removals
def before_delete(self, param):
dialog_id, only_history = int(param.args[0]), int(param.args[1])
account = int(field(param.thisObject, "currentAccount") or 0)
if self.is_injected(account, dialog_id):
param.setResult(None) # not a chat on Telegram
if only_history != 1:
self._hide(account, dialog_id)
return
if only_history == 1 or not self.api.configured:
return
key = (account, dialog_id)
now = time.monotonic()
if now - self._reported.get(key, -_REPORT_AGAIN_SECONDS) < _REPORT_AGAIN_SECONDS:
return # the overloads call one another
self._reported[key] = now
chat_id = peers.marked_chat_id(account, dialog_id)
if chat_id is None:
return
own = _from_screens()
in_background(lambda: self._report(account, chat_id, own))
def _report(self, account, chat_id, own):
try:
known = self.api.chat_removed(peers.own_user_id(account), chat_id, own)
except ApiError as exc:
diag.log(f"chat removal {chat_id}: {exc}")
return
diag.log(f"chat {chat_id} removed{' by the user' if own else ''}, known={known}")
if known:
time.sleep(2)
self.refresh(account)
def _hide(self, account, dialog_id):
item = self._lists.get(account, {}).get(dialog_id)
if item is None:
return
item["hidden_at"] = "now"
on_ui(lambda: self.inject(account))
chat_id = item["chat_id"]
def send():
try:
self.api.chat_hidden(peers.own_user_id(account), chat_id, True)
except ApiError as exc:
diag.log(f"chat hide {chat_id}: {exc}")
in_background(send)
# an opened removed chat
def after_overlay(self, param):
chat = param.thisObject
if self.is_injected(chat.getCurrentAccount(), chat.getDialogId()):
make_read_only(chat, _NOTICE)
# Again once the screen settled (a channel's bar comes back).
on_ui(lambda: make_read_only(chat, _NOTICE), 600)
def before_store(self, param):
"""putMessages(messages_Messages, dialogId, ...): a removed chat's
logged messages are not stored as the app's own (they would make it
a chat of the app's, read from its cache from then on)."""
args = param.args
if len(args) < 2 or not hasattr(args[0], "messages"):
return
account = int(field(param.thisObject, "currentAccount") or 0)
if not self.is_injected(account, int(args[1])):
return
param.setResult(None)
done = args[-1]
if done is not None and hasattr(done, "run"):
done.run()
def _history(self, account, dialog_id):
with self._lock:
history = self._histories.get((account, dialog_id))
if history is None:
item = self._lists.get(account, {}).get(dialog_id) or {}
history = removed_chat.History(
self.api, account, dialog_id, int(item.get("chat_id") or dialog_id)
)
self._histories[(account, dialog_id)] = history
history.load()
return history
def before_request(self, param):
request = param.args[0]
if request is None or not any(self._injected.values()):
return
account = int(field(param.thisObject, "currentAccount") or 0)
dialog_id = _target(request)
if dialog_id is None or not self.is_injected(account, dialog_id):
return
name = type(request).__name__
delegate, timestamped = param.args[1], param.args[2]
if param.method.getReturnType().getName() == "void":
param.setResult(None)
else:
from java import jint
param.setResult(jint(0))
if name not in _ANSWERED:
return # Telegram has nothing for this chat
def deliver():
history = self._history(account, dialog_id)
if name == "TLRPC$TL_messages_getHistory":
picked = history.page(
int(request.offset_id), int(request.add_offset), int(request.limit)
)
elif name == "TLRPC$TL_channels_getMessages":
picked = history.by_ids(int(m.id) for m in java_list(request.id))
else:
picked = []
response = history.response(picked)
if delegate is not None:
delegate.run(response, None)
elif timestamped is not None:
timestamped.run(response, None, 0)
in_background(deliver)
def before_avatar(self, param):
"""The latest logged photo of a removed chat as its avatar."""
from org.telegram.tgnet import TLRPC
target = param.args[0]
if target is None or not self._avatar_paths:
return
if isinstance(target, TLRPC.User):
dialog_id = int(target.id)
elif isinstance(target, TLRPC.Chat):
dialog_id = -int(target.id)
else:
return
receiver = param.thisObject
account = int(field(receiver, "currentAccount") or 0)
path = self._avatar_paths.get((account, dialog_id))
if path is None or not self.is_injected(account, dialog_id):
return
from java import jint
from org.telegram.messenger import ImageLocation
receiver.setImage(
ImageLocation.getForPath(path), "50_50", param.args[1], None, target, jint(0)
)
param.setResult(None)
+111 -7
View File
@@ -1,4 +1,4 @@
from base_plugin import BasePlugin, HookResult
from base_plugin import BasePlugin, HookResult, MenuItemData, MenuItemType
from ui.settings import Header, Input, Selector, Switch, Text
import accounts
@@ -15,12 +15,17 @@ from config import (
REPORT_DELETIONS,
RESTORE_MISSING,
SERVER_URL,
SHOW_REMOVED_CHATS,
)
from hooks.avatars import Avatars
from hooks.chat import EDIT_UPDATES, ChatSync
from hooks.comments import CommentsButton
from hooks.delete_dialog import DeleteDialog
from hooks.deletes import UPDATES, Deletes
from hooks.hidden import Hidden
from hooks.links import Links
from hooks.menu import Menu
from hooks.removed_chats import RemovedChats
from hooks.render import Render
from jutil import in_background, on_ui
from views.comments import Comments
@@ -42,20 +47,93 @@ class MessageLoggerPlugin(BasePlugin):
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.hidden = Hidden(self, self.api, self.chats)
self.delete_dialog = DeleteDialog(self, self.chats, self.hidden)
self.deletes = Deletes(self, self.api, self.chats, self.delete_dialog)
self.links = Links(self)
self.menu = Menu(self, self.history.open)
self.menu = Menu(self, self.history.open, self.hidden.unhide)
self.comments = CommentsButton(self, Comments(self, self.api).open)
parts = (self.render, self.chats, self.deletes, self.links, self.menu, self.comments)
self.avatars = Avatars(self, self.api)
self.removed_chats = RemovedChats(self, self.api)
parts = (
self.render,
self.chats,
self.hidden,
self.deletes,
self.delete_dialog,
self.links,
self.menu,
self.comments,
self.avatars,
self.removed_chats,
)
for part in parts:
try:
part.install()
except Exception as exc:
diag.error(f"install {type(part).__name__}", exc)
self.add_menu_item(
MenuItemData(
menu_type=MenuItemType.CHAT_ACTION_MENU,
item_id="message_logger_reset",
text="Reset deleted/edited marks",
icon="msg_clear",
on_click=self._reset_chat,
)
)
self.add_menu_item(
MenuItemData(
menu_type=MenuItemType.CHAT_ACTION_MENU,
item_id="message_logger_reveal",
text="Reveal hidden messages",
icon="msg_views",
on_click=self._toggle_reveal,
)
)
in_background(accounts.refresh)
in_background(self.purge_cache)
diag.log("loaded")
def _reset_chat(self, context):
"""Chat menu: forget this chat's cached marks and check it again."""
try:
fragment = context.get("fragment")
if fragment is None:
from client_utils import get_last_fragment
fragment = get_last_fragment()
account = int(fragment.getCurrentAccount())
dialog_id = int(context.get("dialog_id") or fragment.getDialogId())
removed = self.chats.reset(account, dialog_id)
_bulletin(f"Message Logger: {removed} marks reset, checking again")
except Exception as exc:
diag.error("reset chat", exc)
def _toggle_reveal(self, context):
"""Chat menu: show the chat's hidden deleted messages (or hide them
again)."""
try:
fragment = context.get("fragment")
if fragment is None:
from client_utils import get_last_fragment
fragment = get_last_fragment()
self.hidden.toggle_reveal(fragment)
except Exception as exc:
diag.error("reveal", exc)
def _reset_all(self, _view=None):
"""Settings: forget every chat's cached marks."""
def run():
removed = self.chats.reset()
_bulletin(f"Message Logger: {removed} marks reset in all chats")
in_background(run)
def _removed_chats_changed(self):
on_ui(self.removed_chats.inject_all)
in_background(self.removed_chats.refresh_all)
def purge_cache(self, force=False):
"""Removes cached answers older than the configured lifetime; at most
once a day (the day turns at local midnight) unless `force`."""
@@ -66,7 +144,18 @@ class MessageLoggerPlugin(BasePlugin):
store.purge_old(CACHE_DAYS_CHOICES[index][1], force=force)
def on_plugin_unload(self):
for part in ("render", "chats", "deletes", "links", "menu", "comments"):
for part in (
"render",
"chats",
"hidden",
"deletes",
"delete_dialog",
"links",
"menu",
"comments",
"avatars",
"removed_chats",
):
if hasattr(self, part):
getattr(self, part).uninstall()
diag.log("unloaded")
@@ -141,7 +230,22 @@ class MessageLoggerPlugin(BasePlugin):
text="Report deletions",
default=DEFAULTS[REPORT_DELETIONS],
subtext="Tell the server about deletions this client sees",
icon="msg_sync",
icon="msg_report",
),
Switch(
key=SHOW_REMOVED_CHATS,
text="Show removed chats",
default=DEFAULTS[SHOW_REMOVED_CHATS],
subtext="Chats you left or lost stay in the chat list, read-only",
icon="msg_archive",
on_change=lambda _value: self._removed_chats_changed(),
),
Text(
text="Reset deleted/edited marks",
subtext="Forget all cached marks; chats are checked with the server again",
icon="msg_clear",
red=True,
on_click=self._reset_all,
),
Selector(
key=CACHE_DAYS,
@@ -158,6 +262,6 @@ class MessageLoggerPlugin(BasePlugin):
text="Deleted message opacity",
default=DEFAULTS[DELETED_ALPHA],
items=[label for label, _ in ALPHA_CHOICES],
icon="msg_hide",
icon="msg_palette",
),
]
+135
View File
@@ -0,0 +1,135 @@
"""Logger copies read through an account that can, their media shown by any.
The log groups' members (the master, accounts added to them) can read the
copies; others can't, not even download a copy's media with its file ids.
So a copy is read through a member signed in here (`reader`: the current
account when it is one, else the master), and its media downloaded through
that member into the app's file directories, which all its accounts share:
the current account then finds the files there.
"""
import time
import accounts
from jutil import java_list
_WAIT_SECONDS = 20
# Larger documents are left to a tap (and fail for a non-member).
_MAX_DOCUMENT_BYTES = 20 * 1024 * 1024
def reader_for(account, log_chat_id, masters=()):
"""A signed-in member of a log group (marked id): `account` when it is
one, else a master, else any; None when none is signed in."""
import peers
channel_id = peers.channel_id_from_marked(log_chat_id)
prefer = [account] + [accounts.slot_of(m) for m in masters if m]
readers = accounts.with_access(-channel_id, prefer=prefer)
return readers[0] if readers else None
def _wait(path):
deadline = time.monotonic() + _WAIT_SECONDS
while not path.exists() and time.monotonic() < deadline:
time.sleep(0.25)
return path.exists()
def _files(photo):
"""The photo's sizes that are files (inline thumbnails are not)."""
return [
size
for size in java_list(photo.sizes)
if getattr(size, "location", None) is not None
and int(size.w or 0) > 0
and not any(
kind in type(size).__name__ for kind in ("Stripped", "PathSize", "Cached")
)
]
def photo_size(photo, side):
"""The smallest downloadable size at least `side` px (else the largest):
inline thumbnails have no file."""
sizes = _files(photo)
if not sizes:
return None
bigger = [size for size in sizes if max(int(size.w), int(size.h)) >= side]
if bigger:
return min(bigger, key=lambda size: int(size.w))
return max(sizes, key=lambda size: int(size.w))
def photo(reader, photo, copy, side):
"""Downloads the photo's size closest to `side` px through `reader` into
the shared cache; its path, or None (blocking)."""
size = photo_size(photo, side)
return _photo_size(reader, photo, size, copy) if size is not None else None
def photo_sizes(reader, photo, copy):
"""Downloads every size of the photo (thumbnails, the viewer's large one)
through `reader`; whether the largest is there (blocking)."""
sizes = _files(photo)
sizes.sort(key=lambda size: int(size.w))
paths = [_photo_size(reader, photo, size, copy) for size in sizes]
return bool(paths) and paths[-1] is not None
def _photo_size(reader, photo, size, copy):
from java import jint
from org.telegram.messenger import FileLoader, ImageLocation, MessageObject
loader = FileLoader.getInstance(reader)
path = loader.getPathToAttach(size, True)
if not path.exists():
parent = MessageObject(reader, copy, False, False)
loader.loadFile(
ImageLocation.getForPhoto(size, photo),
parent,
"jpg",
jint(FileLoader.PRIORITY_HIGH),
jint(1),
)
return str(path.getAbsolutePath()) if _wait(path) else None
def document(reader, document, copy):
"""Downloads a document (up to 20 MB) through `reader` where the app
keeps its kind of file; whether it is there (blocking)."""
from java import jint
from org.telegram.messenger import FileLoader, MessageObject
if int(getattr(document, "size", 0) or 0) > _MAX_DOCUMENT_BYTES:
return False
loader = FileLoader.getInstance(reader)
path = loader.getPathToAttach(document)
if path.exists():
return True
parent = MessageObject(reader, copy, False, False)
loader.loadFile(document, parent, jint(FileLoader.PRIORITY_HIGH), jint(0))
return _wait(path)
def copy_media(reader, copy):
"""Downloads a copy's media (photo, document, a rich message's) through
`reader` before another account shows it (blocking). Returns (ready,
total)."""
media = getattr(copy, "media", None)
photos, documents = [], []
if getattr(media, "photo", None) is not None:
photos.append(media.photo)
if getattr(media, "document", None) is not None:
documents.append(media.document)
rich = getattr(copy, "rich_message", None)
if rich is not None:
photos += java_list(rich.photos)
documents += java_list(rich.documents)
ready = 0
for item in photos:
if getattr(item, "sizes", None) is not None:
ready += bool(photo_sizes(reader, item, copy))
for item in documents:
ready += bool(document(reader, item, copy))
return ready, len(photos) + len(documents)
+217 -3
View File
@@ -1,5 +1,5 @@
"""What the plugin knows per message: server state (edits, deletion, copy
pointers) and deletions this client saw itself.
pointers, hidden marks) and deletions this client saw itself.
Both are cached in SQLite and loaded per chat on first use: a chat opened
again shows its marks at once, before (or without) the server, and a
@@ -11,6 +11,9 @@ renewed or expired. An expired chat keeps its cache (shown meanwhile) until
the next successful check replaces it. Independently, cached answers older
than the configured number of days are removed, checked once a day
(`purge_old`).
Messages removed from view (hidden) are marked on the server, so every
client skips them; a change waits in `hidden_pending` until the server took
it (and overrides the server's mark meanwhile).
"""
import json
@@ -30,6 +33,7 @@ class State:
"history",
"source_date",
"sender_id",
"hidden",
"raw",
)
@@ -40,6 +44,7 @@ class State:
self.history = None
self.source_date = None # ISO string
self.sender_id = None
self.hidden = False # removed from view (kept on the server)
self.raw = None # the server's answer for it
@classmethod
@@ -51,6 +56,7 @@ class State:
state.history = data.get("history")
state.source_date = data.get("source_date")
state.sender_id = data.get("sender_id")
state.hidden = data.get("hidden_at") is not None
state.raw = data
return state
@@ -91,6 +97,20 @@ def _conn():
"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 hidden_pending ("
"account INTEGER, dialog_id INTEGER, message_id INTEGER, hidden INTEGER, "
"at REAL, PRIMARY KEY (account, dialog_id, message_id))"
)
# Before hidden marks were kept on the server: sent there once.
if _db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='hidden'"
).fetchone():
_db.execute(
"INSERT OR IGNORE INTO hidden_pending "
"SELECT account, dialog_id, message_id, 1, at FROM hidden"
)
_db.execute("DROP TABLE hidden")
_db.execute(
"CREATE TABLE IF NOT EXISTS coverage ("
"account INTEGER, dialog_id INTEGER, low INTEGER, high INTEGER, at REAL, "
@@ -175,15 +195,17 @@ def put_server_states(account, dialog_id, items):
diag.error("store.put_server_states", exc)
def cached_deleted(account, dialog_id, low, high):
def cached_deleted(account, dialog_id, low, high, hidden=False):
"""Cached server answers of deleted messages with a logged copy in
[low, high] (what a restore needs, without asking the server again)."""
[low, high] (what a restore needs, without asking the server again);
hidden ones only when `hidden`."""
with _lock:
_local_ids(account, dialog_id)
return [
state.raw
for message_id, state in _states.get((account, dialog_id), {}).items()
if low <= message_id <= high
and (hidden or not is_hidden(account, dialog_id, message_id))
and state.raw is not None
and state.raw.get("deleted_at")
and state.raw.get("logged")
@@ -284,6 +306,198 @@ def forget_deleted(account, dialog_id, message_ids):
diag.error("store.forget_deleted", exc)
def reset(account=None, dialog_id=None):
"""Forgets cached marks, local deletion records and checked ranges of a
chat (or of every chat when `dialog_id` is None); the next look asks the
server again. Messages the user removed from view stay hidden.
Returns the number of marks forgotten."""
where, args = "", ()
if dialog_id is not None:
where, args = " WHERE account=? AND dialog_id=?", (int(account), int(dialog_id))
with _lock:
removed = 0
try:
db = _conn()
for table in ("states", "deleted", "coverage"):
count = db.execute(f"DELETE FROM {table}{where}", args).rowcount
if table != "coverage":
removed += count
db.commit()
except Exception as exc:
diag.error("store.reset", exc)
if dialog_id is None:
_states.clear()
_local.clear()
_coverage.clear()
_loaded.clear()
else:
key = (int(account), int(dialog_id))
for cache in (_states, _local, _coverage):
cache.pop(key, None)
_loaded.discard(key)
return removed
_pending = None # (account, dialog_id, message_id) -> hidden, not yet on the server
_revealed = set() # (account, dialog_id) showing their hidden messages
def _pending_marks():
global _pending
if _pending is None:
found = {}
try:
for account, dialog_id, message_id, hidden in _conn().execute(
"SELECT account, dialog_id, message_id, hidden FROM hidden_pending"
).fetchall():
found[(account, dialog_id, message_id)] = bool(hidden)
except Exception as exc:
diag.error("store.hidden_pending", exc)
_pending = found
return _pending
def set_hidden(account, dialog_id, message_ids, hidden=True):
"""Removes messages from view (or shows them again) here at once; the
change waits for the server in `hidden_pending` (see `pending_hidden`)."""
ids = [int(i) for i in message_ids]
with _lock:
pending = _pending_marks()
states = _states.get((account, dialog_id), {})
for i in ids:
pending[(account, dialog_id, i)] = bool(hidden)
state = states.get(i)
if state is not None:
state.hidden = bool(hidden)
try:
db = _conn()
now = time.time()
db.executemany(
"INSERT OR REPLACE INTO hidden_pending VALUES (?, ?, ?, ?, ?)",
[(account, dialog_id, i, int(bool(hidden)), now) for i in ids],
)
db.commit()
except Exception as exc:
diag.error("store.set_hidden", exc)
def pending_hidden():
"""Hidden marks not on the server yet: {(account, dialog_id, hidden): [ids]}."""
with _lock:
out = {}
for (account, dialog_id, message_id), hidden in _pending_marks().items():
out.setdefault((account, dialog_id, hidden), []).append(message_id)
return out
def hidden_sent(account, dialog_id, message_ids, hidden):
"""The server took these marks: they are its state now."""
ids = [int(i) for i in message_ids]
with _lock:
pending = _pending_marks()
sent = [i for i in ids if pending.get((account, dialog_id, i)) == bool(hidden)]
for i in sent:
del pending[(account, dialog_id, i)]
states = _states.get((account, dialog_id), {})
changed = []
for i in sent:
state = states.get(i)
if state is not None and state.raw is not None:
state.raw["hidden_at"] = (
state.raw.get("hidden_at") or _now_iso() if hidden else None
)
state.hidden = bool(hidden)
changed.append((account, dialog_id, i, json.dumps(state.raw)))
try:
db = _conn()
db.executemany(
"DELETE FROM hidden_pending WHERE account=? AND dialog_id=? AND message_id=?",
[(account, dialog_id, i) for i in sent],
)
db.executemany(
"UPDATE states SET data=? WHERE account=? AND dialog_id=? AND message_id=?",
[(data, a, d, i) for a, d, i, data in changed],
)
db.commit()
except Exception as exc:
diag.error("store.hidden_sent", exc)
def _now_iso():
import datetime
return datetime.datetime.now(datetime.timezone.utc).isoformat()
def is_hidden(account, dialog_id, message_id):
with _lock:
pending = _pending_marks().get((account, dialog_id, int(message_id)))
if pending is not None:
return pending
state = _states.get((account, dialog_id), {}).get(int(message_id))
return state is not None and state.hidden
def hidden_deleted(account, dialog_id):
"""Ids of the chat's deleted messages that are hidden (cached)."""
with _lock:
_local_ids(account, dialog_id)
return [
i
for i, state in _states.get((account, dialog_id), {}).items()
if state.deleted and is_hidden(account, dialog_id, i)
]
def reveal(account, dialog_id, on):
with _lock:
if on:
_revealed.add((account, dialog_id))
else:
_revealed.discard((account, dialog_id))
def is_revealed(account, dialog_id):
return (account, dialog_id) in _revealed
_removed_dialogs = set() # (account, dialog_id) of removed chats listed
def set_removed_dialog(account, dialog_id, listed):
"""A removed chat listed among the chats (hooks/removed_chats.py): its
state comes with its history, the chat is not synced."""
with _lock:
if listed:
_removed_dialogs.add((int(account), int(dialog_id)))
else:
_removed_dialogs.discard((int(account), int(dialog_id)))
def is_removed_dialog(account, dialog_id):
return (int(account), int(dialog_id)) in _removed_dialogs
def get_meta(key):
try:
with _lock:
row = _conn().execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone()
return row[0] if row else None
except Exception as exc:
diag.error("store.get_meta", exc)
return None
def put_meta(key, value):
try:
with _lock:
db = _conn()
db.execute("INSERT OR REPLACE INTO meta VALUES (?, ?)", (key, value))
db.commit()
except Exception as exc:
diag.error("store.put_meta", exc)
def note_own_deletion(account, message_ids):
now = time.monotonic()
with _lock:
+39 -6
View File
@@ -33,9 +33,9 @@ read_only = set()
_lock = threading.Lock()
def make_read_only(chat):
"""Swaps a comment screen's input bar for a read-only notice (the app's
own bottom overlay, as in chats one can't write to). UI thread."""
def make_read_only(chat, notice="Viewing deleted comments"):
"""Swaps a screen's input bar for a read-only notice (the app's own
bottom overlay, as in chats one can't write to). UI thread."""
from android.view import View
from jutil import field
@@ -47,7 +47,12 @@ def make_read_only(chat):
text = field(chat, "bottomOverlayText")
if overlay is None or text is None:
return
for name in ("bottomOverlayChatText", "bottomOverlayStartButton", "bottomOverlayProgress"):
for name in (
"bottomOverlayChat", # a channel's bar (search, mute/join)
"bottomOverlayChatText",
"bottomOverlayStartButton",
"bottomOverlayProgress",
):
other = field(chat, name)
if other is not None:
other.setVisibility(View.GONE)
@@ -56,12 +61,40 @@ def make_read_only(chat):
animation = field(chat, "bottomOverlayAnimation")
if animation is not None:
animation.cancel()
text.setText("Viewing deleted comments")
text.setText(notice)
text.setVisibility(View.VISIBLE)
overlay.setAlpha(1.0)
overlay.setVisibility(View.VISIBLE)
def answer(account, request):
"""The response for a getReplies of a thread opened here, else None."""
with _lock:
thread = _threads.get(int(request.msg_id))
if thread is None or thread[0] != int(account):
return None
_account, _discussion, messages, users, chats = thread
from java.util import ArrayList
from org.telegram.tgnet import TLRPC
offset_id = int(request.offset_id or 0)
add_offset = int(request.add_offset or 0)
picked = [
m for m in messages if not offset_id or add_offset < 0 or int(m.id) < offset_id
]
response = TLRPC.TL_messages_messages()
response.messages = ArrayList()
for message in sorted(picked, key=lambda m: -int(m.id)):
response.messages.add(message)
response.users = ArrayList()
for user in users:
response.users.add(user)
response.chats = ArrayList()
for chat in chats:
response.chats.add(chat)
return response
def _peer(controller, marked):
"""TLRPC peer of a Telethon sender id."""
from hooks.chat import _android_peer_id
@@ -304,7 +337,7 @@ class Comments(History):
if rich is not None:
_strip_rich_header(rich)
plain = _rich_plain_text(rich)
if plain is not None: # text only: a normal bubble
if plain: # text only: a normal bubble
msg.message = plain
msg.entities = ArrayList()
msg.rich_message = None
+267
View File
@@ -0,0 +1,267 @@
"""The history of a removed chat, rebuilt from the logger: the logged messages
(`/chats/{id}/history`), each from its copy in a log group when an account
here can read it (header stripped, media included), else from the server's
text with its media marked unavailable. All of them are deleted for the
account; they are cached as such (store), so they draw like any deleted
message.
`History` holds one chat's messages, newest first, and answers the app's
getHistory/getMessages for it (hooks/removed_chats.py).
"""
import threading
import diag
import media_share
import peers
import store
import tg
from api import ApiError
from views.history import _MEDIA_NAMES, _entities, _epoch, _utf16
_PAGES = 3 # history pages (of 100) loaded when the chat opens
_COPIES_PER_REQUEST = 100
def stand_in_user(controller, peer_id, name):
from org.telegram.tgnet import TLRPC
user = controller.getUser(int(peer_id))
if user is None:
user = TLRPC.TL_user()
user.id = int(peer_id)
user.first_name = name or "Deleted"
user.flags |= 2
return user
def stand_in_chat(controller, chat_id, title, kind):
"""The app's chat (cached or stored), else a stand-in of the right kind."""
from org.telegram.tgnet import TLRPC
chat = controller.getChat(int(chat_id))
if chat is not None:
return chat
if kind == "group":
chat = TLRPC.TL_chat()
else:
chat = TLRPC.TL_channel()
chat.broadcast = kind == "channel"
chat.megagroup = kind != "channel"
chat.id = int(chat_id)
chat.title = title or "Chat"
return chat
class History:
def __init__(self, api, account, dialog_id, chat_id):
self.api = api
self.account = account
self.dialog_id = dialog_id
self.chat_id = chat_id
self.messages = [] # TL messages, newest first
self.users = {}
self.chats = {}
self.loaded = False
self._lock = threading.Lock()
# loading (background)
def load(self):
"""Fetches and builds the chat's history once; later calls wait."""
with self._lock:
if self.loaded:
return
try:
self._load()
except Exception as exc:
diag.error("removed_chat.load", exc)
self.loaded = True
def _load(self):
me = peers.own_user_id(self.account)
items, before, master = [], 0, None
for _page in range(_PAGES):
try:
result = self.api.history(me, self.chat_id, before)
except ApiError as exc:
diag.log(f"removed chat {self.chat_id}: {exc}")
break
page = (result or {}).get("messages") or []
if not page:
break
master = result.get("master_tg_id")
items += page
before = min(int(m["message_id"]) for m in page)
if len(page) < 100:
break
revealed = store.is_revealed(self.account, self.dialog_id)
items = [i for i in items if revealed or not i.get("hidden_at")]
store.put_server_states(self.account, self.dialog_id, items)
copies = self._copies(items, master)
built = []
for item in items:
try:
built.append(self._message(item, copies.get(self._pointer(item))))
except Exception as exc:
diag.error("removed_chat.message", exc)
self.messages = sorted(built, key=lambda m: -int(m.id))
diag.log(f"removed chat {self.chat_id}: {len(self.messages)} messages")
@staticmethod
def _pointer(item):
logged = item.get("logged") or {}
if not logged.get("chat_id") or not logged.get("message_id"):
return None
return int(logged["chat_id"]), int(logged["message_id"])
def _copies(self, items, master):
"""Logged copies by (log chat, message), read through an account here
that can, per log group."""
by_group = {}
for item in items:
pointer = self._pointer(item)
if pointer is not None:
by_group.setdefault(pointer[0], set()).add(pointer[1])
found = {}
masters = [master, *self.api.masters()]
for log_chat, ids in by_group.items():
channel_id = peers.channel_id_from_marked(log_chat)
reader = media_share.reader_for(self.account, log_chat, masters)
if reader is None:
continue
ordered = sorted(ids)
for start in range(0, len(ordered), _COPIES_PER_REQUEST):
chunk = ordered[start : start + _COPIES_PER_REQUEST]
for message_id, copy in _fetch(reader, -channel_id, chunk).items():
if reader != self.account:
media_share.copy_media(reader, copy)
found[(log_chat, message_id)] = copy
return found
# building
def _message(self, item, copy):
from org.telegram.messenger import MessagesController
from org.telegram.tgnet import TLRPC
from hooks.chat import _android_peer_id, build_from_copy
controller = MessagesController.getInstance(self.account)
message_id = int(item["message_id"])
if copy is not None and not isinstance(copy, TLRPC.TL_messageEmpty):
msg = build_from_copy(
self.account, self.dialog_id, message_id, item, copy, True
).messageOwner
else:
msg = from_text(self.account, self.dialog_id, item)
sender = item.get("sender_id")
if sender:
peer_id = _android_peer_id(int(sender))
if peer_id > 0:
user = stand_in_user(controller, peer_id, item.get("sender_name"))
self.users[int(user.id)] = user
else:
chat = stand_in_chat(controller, -peer_id, item.get("sender_name"), "channel")
self.chats[int(chat.id)] = chat
if item.get("reply_to"):
reply = TLRPC.TL_messageReplyHeader()
reply.reply_to_msg_id = int(item["reply_to"])
reply.flags |= 16
msg.reply_to = reply
msg.flags |= 8
return msg
# answering the app's requests
def response(self, picked):
from java.util import ArrayList
from org.telegram.tgnet import TLRPC
response = TLRPC.TL_messages_messages()
response.messages = ArrayList()
for message in picked:
response.messages.add(message)
response.users = ArrayList()
for user in self.users.values():
response.users.add(user)
response.chats = ArrayList()
for chat in self.chats.values():
response.chats.add(chat)
return response
def page(self, offset_id, add_offset, limit):
"""getHistory's slice: newest first, from `offset_id` (exclusive; 0:
the newest) moved by `add_offset` (negative: newer ones too)."""
messages = self.messages
if offset_id:
start = next(
(i for i, m in enumerate(messages) if int(m.id) < offset_id),
len(messages),
)
else:
start = 0
start = max(0, start + add_offset)
return messages[start : start + max(1, limit)]
def by_ids(self, ids):
wanted = set(int(i) for i in ids)
return [m for m in self.messages if int(m.id) in wanted]
def from_text(account, dialog_id, item):
"""A logged message from the server's text; each media is marked
unavailable after it."""
from org.telegram.messenger import MessagesController
from org.telegram.tgnet import TLRPC
from hooks.chat import _android_peer_id
controller = MessagesController.getInstance(account)
msg = TLRPC.TL_message()
msg.id = int(item["message_id"])
msg.dialog_id = dialog_id
msg.peer_id = controller.getPeer(dialog_id)
sender = item.get("sender_id")
msg.from_id = (
controller.getPeer(_android_peer_id(int(sender))) if sender else msg.peer_id
)
msg.flags |= 256
msg.date = _epoch(item.get("source_date"))
if int(item.get("edit_count") or 0) > 0:
msg.edit_date = _epoch(item.get("edit_date")) or msg.date
msg.flags |= 32768
text = item.get("text") or ""
entities = _entities(item.get("entities") or [])
notes = [
f"🚫 {_MEDIA_NAMES.get(kind, 'Media')} unavailable"
for kind in item.get("media") or []
]
if notes:
note = "\n".join(notes)
start = _utf16(text) + (2 if text else 0)
text = (text + "\n\n" if text else "") + note
italic = TLRPC.TL_messageEntityItalic()
italic.offset = start
italic.length = _utf16(note)
entities.add(italic)
msg.message = text or "(empty)"
msg.entities = entities
msg.out = sender is not None and int(sender) == peers.own_user_id(account)
return msg
def _fetch(reader, dialog_id, ids):
"""Messages `ids` of a log group through `reader` (blocking)."""
done = threading.Event()
box = {}
def callback(found, error):
if found is None:
diag.log(f"removed chat copies unreadable: {error}")
box["found"] = found or {}
done.set()
tg.get_messages(reader, dialog_id, ids, callback)
done.wait(20)
return box.get("found") or {}
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Renders images/icon.svg to a square PNG (512x512 by default).
Stdlib only: the actual rasterising is delegated to the first renderer found,
in this order: the cairosvg Python package, rsvg-convert (librsvg),
ImageMagick (magick/convert), Inkscape. Install any one of them.
Usage:
tools/icon_png.py # images/icon.svg -> images/icon.png, 512x512
tools/icon_png.py --resolution 1024 # 1024x1024
tools/icon_png.py other.svg -o out.png # explicit input/output
The output is always PNG, always <resolution>x<resolution>.
"""
from __future__ import annotations
import argparse
import shutil
import subprocess
import sys
from pathlib import Path
from typing import List, Optional
REPO_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_SVG = REPO_ROOT / "images" / "icon.svg"
DEFAULT_RESOLUTION = 512
def fail(message: str) -> "None":
print(f"error: {message}", file=sys.stderr)
raise SystemExit(1)
def cli_command(svg: Path, png: Path, size: int) -> Optional[List[str]]:
"""Returns the command line for the first installed CLI renderer, if any."""
if shutil.which("rsvg-convert"):
return ["rsvg-convert", "-w", str(size), "-h", str(size), "-f", "png",
"-o", str(png), str(svg)]
magick = shutil.which("magick") or shutil.which("convert")
if magick:
# A high density rasterises the vector sharply before scaling down;
# "!" forces the exact size.
return [magick, "-background", "none", "-density", "1200", str(svg),
"-resize", f"{size}x{size}!", f"png:{png}"]
if shutil.which("inkscape"):
return ["inkscape", str(svg), "--export-type=png", f"--export-filename={png}",
"-w", str(size), "-h", str(size)]
return None
def render(svg: Path, png: Path, size: int) -> str:
"""Renders svg into png at size x size, returning the renderer's name."""
try:
import cairosvg # type: ignore[import-not-found]
except ImportError:
pass
else:
cairosvg.svg2png(url=str(svg), write_to=str(png),
output_width=size, output_height=size)
return "cairosvg"
command = cli_command(svg, png, size)
if command is None:
fail("no SVG renderer found; install one of: cairosvg (pip), "
"rsvg-convert (librsvg), ImageMagick, Inkscape")
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode != 0:
fail(f"{Path(command[0]).name} failed:\n{result.stderr.strip()}")
return Path(command[0]).name
def main() -> None:
parser = argparse.ArgumentParser(description="Render an SVG icon to a square PNG.")
parser.add_argument("svg", nargs="?", type=Path, default=DEFAULT_SVG,
help="input SVG (default: images/icon.svg)")
parser.add_argument("-r", "--resolution", type=int, default=DEFAULT_RESOLUTION,
help=f"width and height in pixels (default: {DEFAULT_RESOLUTION})")
parser.add_argument("-o", "--output", type=Path,
help="output PNG (default: the input path with a .png suffix)")
args = parser.parse_args()
if args.resolution <= 0:
fail("--resolution must be a positive integer")
if not args.svg.is_file():
fail(f"{args.svg} not found")
png = args.output or args.svg.with_suffix(".png")
if png.suffix.lower() != ".png":
fail(f"output must be a .png file, got {png}")
png.parent.mkdir(parents=True, exist_ok=True)
renderer = render(args.svg, png, args.resolution)
print(f"{png} ({args.resolution}x{args.resolution}, via {renderer})")
if __name__ == "__main__":
main()
+48
View File
@@ -311,9 +311,57 @@ def main(argv=None) -> int:
f"removed={removed} left={left}",
)
check("age purge runs once a day", store.purge_old(1) == 0)
store.put_server_states(0, -7, [{"message_id": 1, "edit_count": 1}])
store.put_server_states(0, -8, [{"message_id": 1, "edit_count": 1}])
store.mark_deleted(0, -7, [2])
store.set_hidden(0, -7, [3])
check(
"reset forgets one chat's marks, keeps others and hidden ones",
store.reset(0, -7) == 2
and store.get(0, -7, 1) is None
and store.get(0, -8, 1) is not None
and store.is_hidden(0, -7, 3),
)
store.put_server_states(
0, -9, [{"message_id": 4, "deleted_at": "x", "logged": {"chat_id": 1}}]
)
shown = len(store.cached_deleted(0, -9, 1, 10)) == 1
store.set_hidden(0, -9, [4])
waiting = store.pending_hidden().get((0, -9, True))
skipped = store.cached_deleted(0, -9, 1, 10) == []
store.hidden_sent(0, -9, [4], True)
check(
"hidden marks wait for the server, then are its state",
shown
and waiting == [4]
and skipped
and (0, -9, True) not in store.pending_hidden()
and store.is_hidden(0, -9, 4)
and len(store.cached_deleted(0, -9, 1, 10, True)) == 1,
f"waiting={waiting} skipped={skipped}",
)
for name in ("cached_deleted", "coverage", "set_coverage", "expire_coverage", "replace_window"):
check(f"store.{name} exists", callable(getattr(store, name, None)))
class JList(list):
def size(self):
return len(self)
def get(self, index):
return self[index]
class Node:
def __init__(self, text=None, texts=()):
self.text, self.texts = text, JList(texts)
url = Node(Node("https://t.me/c/1/2")) # textUrl: empty `texts`
concat = Node(None, [Node("a "), Node(Node("b"))])
check(
"rich text: wrappers and concatenations",
chat._rich_text(url) == "https://t.me/c/1/2" and chat._rich_text(concat) == "a b",
repr((chat._rich_text(url), chat._rich_text(concat))),
)
api = load("api")
client = api.Api(BasePlugin())
check("API unconfigured without URL and token", not client.configured)