generated from exteraGramPlugins/pluginBase
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dba0c4f833 | ||
|
|
362b358d8d | ||
|
|
0176f7099d | ||
|
|
bbe4a557a8 | ||
|
|
b86cc8a1c8 |
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.4",
|
||||
"icon": "exteraGramPluginIcons/4",
|
||||
"app_version": ">=12.5.1",
|
||||
"sdk_version": ">=1.4.4.3",
|
||||
"requirements": [
|
||||
|
||||
+79
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
"""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"
|
||||
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._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 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, 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)
|
||||
|
||||
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
|
||||
+235
-58
@@ -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)
|
||||
_process_new_messages().invoke(fragment, batch, jboolean(False))
|
||||
# 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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user