generated from exteraGramPlugins/pluginBase
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0176f7099d | ||
|
|
bbe4a557a8 | ||
|
|
b86cc8a1c8 | ||
|
|
a2e4075a03 |
No files matched your search
@@ -1,226 +1,61 @@
|
|||||||
# 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
|
**TL;DR:** an [exteraGram](https://exteragram.app) plugin that keeps deleted messages in
|
||||||
icon (from the active icon pack) in the time row, and are reported to the server;
|
your chats and shows the edit history of edited ones, using what your
|
||||||
- logged messages deleted while the app was closed (or never seen deleted by the server, e.g.
|
[telegram-panel](../../telegram-panel) message logger saved.
|
||||||
bot-logged chats) are confirmed with Telegram, reported, and restored from the logger copy;
|
|
||||||
- *Edit history* in the message menu (right above Forward, for edited messages) opens every
|
|
||||||
version as a mock chat — also on the logger groups' own posts — with an *Open topic* button
|
|
||||||
for the history topic in the activity group. Versions and their media are read from the activity group through
|
|
||||||
a signed-in account that can (the current one, the master, or any member); otherwise the
|
|
||||||
server's text is shown and media are marked unavailable;
|
|
||||||
- message links into a chat the current account isn't in (e.g. a log post's "Message" link)
|
|
||||||
open that chat as a signed-in account that is in it; going back returns to your account.
|
|
||||||
Every signed-in account's chats are cached client-side for this.
|
|
||||||
|
|
||||||
The server only points at copies (logger/activity group message ids); the client reads them
|
## Description
|
||||||
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
|
Message Logger connects exteraGram to a telegram-panel message logger and shows its data
|
||||||
(from the oldest loaded message on; the answer replaces the chat's cached marks), when older
|
natively in chats. Messages other people delete stay visible, edited messages get a full
|
||||||
history is loaded past the checked range (that part only), or after an edit/delete event in the
|
edit history, and comments of deleted channel posts can still be opened. Marks are cached on
|
||||||
chat. Answers hold at most 100 marks (edited/deleted) and 100 missing ids, newest first. Marks
|
the device, so a chat opened again, even offline, shows them at once.
|
||||||
are cached per chat in the plugin's SQLite (`state.db`, no size cap), so a chat opened again —
|
|
||||||
even offline — shows them at once, and deleted messages are restored from the cache. An expired
|
|
||||||
chat keeps its cache until the next successful check, which replaces it and starts over. Cached
|
|
||||||
answers older than *Cache lifetime* (1/7/30/90/365 days or never; 30 by default) are removed,
|
|
||||||
checked once a day (from local midnight) and when the setting changes.
|
|
||||||
|
|
||||||
Setup: in the panel create a token per master — the account owning the logger groups —
|
## Features
|
||||||
(Settings → API Tokens); in the plugin settings enter the panel URL and the tokens (separated by
|
|
||||||
spaces), then *Test connection*. Any Telegram account can use a token. Diagnostics: see `docs/sdk-notes.md` (gotchas section).
|
|
||||||
|
|
||||||
The rest of this README describes the build toolchain this repo came from (pluginBase).
|
- **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 never restored from the logger here.
|
||||||
|
Deleting a message that is already deleted shows a plain dialog that removes it from view
|
||||||
|
on this device only.
|
||||||
|
- **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. Messages you removed from view 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
|
## Setup
|
||||||
as ordinary multi-file Python projects instead of hand-maintaining one giant `.py` file.
|
|
||||||
|
|
||||||
## 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:
|
For diagnostics, see the gotchas section of [docs/sdk-notes.md](docs/sdk-notes.md).
|
||||||
`/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
|
## Documentation
|
||||||
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
|
- [How Message Logger works](docs/how-it-works.md): server sync and the local cache
|
||||||
BasePlugin`). Lifecycle methods (`on_plugin_load`, `on_plugin_unload`, `create_settings`) and
|
- [Development](docs/development.md): getting started, repo layout, building and verifying, icon export
|
||||||
hooks (`on_send_message_hook`, `pre_request_hook`, `post_request_hook`, `on_update_hook(s)`,
|
- [How exteraGram plugins work](docs/how-plugins-work.md): the plugin format and lifecycle
|
||||||
`on_app_event`) are all called by the host; hooks must be registered inside
|
- [Device setup and ADB automation](docs/device-and-adb.md): deploy, watch, logs, debugger
|
||||||
`on_plugin_load` (e.g. `self.add_on_send_message_hook()`). A hook returns a
|
- [Versioning and releases](docs/versioning-and-releases.md): git hooks, auto-bump, CI publishing
|
||||||
`HookResult(strategy=HookStrategy.MODIFY, params=params)` to apply changes, or a bare
|
- [SDK notes](docs/sdk-notes.md): condensed exteraGram SDK API surface
|
||||||
`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.
|
|
||||||
@@ -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 |
+5
-3
@@ -3,9 +3,11 @@
|
|||||||
"name": "Message Logger",
|
"name": "Message Logger",
|
||||||
"description": "Shows deleted and edited messages and their edit history from your telegram-panel message logger.",
|
"description": "Shows deleted and edited messages and their edit history from your telegram-panel message logger.",
|
||||||
"author": "@lyuksovannyy",
|
"author": "@lyuksovannyy",
|
||||||
"version": "0.1.0",
|
"version": "0.1.2",
|
||||||
"icon": "exteraPlugins/1",
|
"icon": "exteraGramPluginIcons/4",
|
||||||
"app_version": ">=12.5.1",
|
"app_version": ">=12.5.1",
|
||||||
"sdk_version": ">=1.4.4.3",
|
"sdk_version": ">=1.4.4.3",
|
||||||
"requirements": ["requests"]
|
"requirements": [
|
||||||
|
"requests"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
+13
@@ -54,6 +54,12 @@ class Api:
|
|||||||
except requests.RequestException as exc:
|
except requests.RequestException as exc:
|
||||||
raise ApiError(f"Server unreachable: {exc}") from exc
|
raise ApiError(f"Server unreachable: {exc}") from exc
|
||||||
if response.status_code == 404:
|
if response.status_code == 404:
|
||||||
|
try:
|
||||||
|
detail = response.json().get("detail")
|
||||||
|
except Exception:
|
||||||
|
detail = None
|
||||||
|
if detail == "Not Found": # the route itself: an older server
|
||||||
|
raise ApiError("the server doesn't support this yet (update telegram-panel)")
|
||||||
return None
|
return None
|
||||||
if response.status_code >= 400:
|
if response.status_code >= 400:
|
||||||
try:
|
try:
|
||||||
@@ -127,6 +133,13 @@ class Api:
|
|||||||
params={"account_id": account_id},
|
params={"account_id": account_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def comments(self, account_id, chat_id, message_id):
|
||||||
|
return self._first(
|
||||||
|
"GET",
|
||||||
|
f"/messages/{chat_id}/{message_id}/comments",
|
||||||
|
params={"account_id": account_id},
|
||||||
|
)
|
||||||
|
|
||||||
def deletions(self, account_id, chat_id, message_ids):
|
def deletions(self, account_id, chat_id, message_ids):
|
||||||
ids = sorted(set(int(i) for i in message_ids))
|
ids = sorted(set(int(i) for i in message_ids))
|
||||||
marked = 0
|
marked = 0
|
||||||
|
|||||||
+80
-3
@@ -115,6 +115,13 @@ def _loaded(fragment, message_id):
|
|||||||
return False
|
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):
|
def _android_peer_id(marked):
|
||||||
"""Telethon sender id -> Android peer id (channels are -id)."""
|
"""Telethon sender id -> Android peer id (channels are -id)."""
|
||||||
marked = int(marked)
|
marked = int(marked)
|
||||||
@@ -141,10 +148,13 @@ def _rich_text(node):
|
|||||||
value = getattr(node, "text", None)
|
value = getattr(node, "text", None)
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
return value
|
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)
|
texts = getattr(node, "texts", None)
|
||||||
if texts is not None and hasattr(texts, "size"):
|
if texts is not None and hasattr(texts, "size"):
|
||||||
return "".join(_rich_text(t) for t in java_list(texts))
|
return "".join(_rich_text(t) for t in java_list(texts))
|
||||||
return _rich_text(value)
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _rich_plain_text(rich):
|
def _rich_plain_text(rich):
|
||||||
@@ -347,6 +357,8 @@ class ChatSync:
|
|||||||
def _restore(self, fragment, account, dialog_id, chat_id, me, states):
|
def _restore(self, fragment, account, dialog_id, chat_id, me, states):
|
||||||
for state in states:
|
for state in states:
|
||||||
message_id = int(state["message_id"])
|
message_id = int(state["message_id"])
|
||||||
|
if store.is_hidden(account, dialog_id, message_id):
|
||||||
|
continue # the user removed it from view
|
||||||
key = (account, dialog_id, message_id)
|
key = (account, dialog_id, message_id)
|
||||||
if key in self._restoring:
|
if key in self._restoring:
|
||||||
continue
|
continue
|
||||||
@@ -422,7 +434,7 @@ class ChatSync:
|
|||||||
if strip:
|
if strip:
|
||||||
_strip_rich_header(rich)
|
_strip_rich_header(rich)
|
||||||
plain = _rich_plain_text(rich)
|
plain = _rich_plain_text(rich)
|
||||||
if plain is not None:
|
if plain: # never a blank bubble: else it stays rich
|
||||||
# Text only: a normal bubble, as the original was.
|
# Text only: a normal bubble, as the original was.
|
||||||
msg.message = plain
|
msg.message = plain
|
||||||
msg.entities = ArrayList()
|
msg.entities = ArrayList()
|
||||||
@@ -495,7 +507,72 @@ class ChatSync:
|
|||||||
if fragment is not None:
|
if fragment is not None:
|
||||||
on_ui(lambda: self.schedule(fragment), delay_ms)
|
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))
|
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:
|
if fragment is not None:
|
||||||
on_ui(lambda: self.refresh(fragment, set(int(i) for i in ids)))
|
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 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,121 @@
|
|||||||
|
"""The comments button of a deleted channel post opens its logged comments
|
||||||
|
(views/comments.py) instead of the app's discussion, which Telegram deleted
|
||||||
|
with the post ("message doesn't exist").
|
||||||
|
|
||||||
|
ChatActivity$ChatMessageCellDelegate.didPressCommentButton(ChatMessageCell)
|
||||||
|
handles the button; an album's comments hang on any of its messages.
|
||||||
|
ConnectionsManager.sendRequestInternal (where every sendRequest overload
|
||||||
|
ends) is where the opened thread's getReplies is answered locally, and
|
||||||
|
ChatActivity.updateBottomOverlay where its input bar is kept hidden.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from base_plugin import MethodHook
|
||||||
|
|
||||||
|
import diag
|
||||||
|
import store
|
||||||
|
from jutil import field, hook_methods, in_background, java_list
|
||||||
|
from views import comments as comments_view
|
||||||
|
|
||||||
|
DELEGATE = "org.telegram.ui.ChatActivity$ChatMessageCellDelegate"
|
||||||
|
CONNECTIONS = "org.telegram.tgnet.ConnectionsManager"
|
||||||
|
|
||||||
|
|
||||||
|
def _deleted_post(cell):
|
||||||
|
"""The deleted message of the cell (or its album), else None."""
|
||||||
|
message = cell.getMessageObject()
|
||||||
|
if message is None:
|
||||||
|
return None
|
||||||
|
candidates = [message]
|
||||||
|
group = cell.getCurrentMessagesGroup()
|
||||||
|
if group is not None:
|
||||||
|
candidates += java_list(group.messages)
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate is not None and int(candidate.getId()) > 0 and store.is_deleted(
|
||||||
|
int(candidate.currentAccount),
|
||||||
|
int(candidate.getDialogId()),
|
||||||
|
int(candidate.getId()),
|
||||||
|
):
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class CommentsButton:
|
||||||
|
def __init__(self, plugin, open_comments):
|
||||||
|
self.plugin = plugin
|
||||||
|
self.open_comments = open_comments
|
||||||
|
self.handles = []
|
||||||
|
|
||||||
|
def install(self):
|
||||||
|
button = self
|
||||||
|
|
||||||
|
class BeforePress(MethodHook):
|
||||||
|
def before_hooked_method(self, param):
|
||||||
|
try:
|
||||||
|
post = _deleted_post(param.args[0])
|
||||||
|
if post is not None:
|
||||||
|
param.setResult(None)
|
||||||
|
button.open_comments(post)
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("comments.press", exc)
|
||||||
|
|
||||||
|
self.handles += hook_methods(
|
||||||
|
self.plugin, DELEGATE, "didPressCommentButton", BeforePress()
|
||||||
|
)
|
||||||
|
|
||||||
|
class BeforeSend(MethodHook):
|
||||||
|
def before_hooked_method(self, param):
|
||||||
|
try:
|
||||||
|
button.answer_locally(param)
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("comments.request", exc)
|
||||||
|
|
||||||
|
self.handles += hook_methods(
|
||||||
|
self.plugin, CONNECTIONS, "sendRequestInternal", BeforeSend()
|
||||||
|
)
|
||||||
|
|
||||||
|
class AfterOverlay(MethodHook):
|
||||||
|
def after_hooked_method(self, param):
|
||||||
|
try:
|
||||||
|
chat = param.thisObject
|
||||||
|
if int(chat.getClassGuid()) in comments_view.read_only:
|
||||||
|
comments_view.make_read_only(chat)
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("comments.overlay", exc)
|
||||||
|
|
||||||
|
self.handles += hook_methods(
|
||||||
|
self.plugin, "org.telegram.ui.ChatActivity", "updateBottomOverlay", AfterOverlay()
|
||||||
|
)
|
||||||
|
|
||||||
|
def answer_locally(self, param):
|
||||||
|
"""A thread opened by views/comments.py asks for its replies: answer
|
||||||
|
from the logged comments instead of Telegram (the thread is gone)."""
|
||||||
|
request = param.args[0]
|
||||||
|
if request is None or type(request).__name__ != "TLRPC$TL_messages_getReplies":
|
||||||
|
return
|
||||||
|
account = int(field(param.thisObject, "currentAccount") or 0)
|
||||||
|
response = comments_view.answer(account, request)
|
||||||
|
if response is None:
|
||||||
|
return
|
||||||
|
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))
|
||||||
|
|
||||||
|
def deliver():
|
||||||
|
if delegate is not None:
|
||||||
|
delegate.run(response, None)
|
||||||
|
elif timestamped is not None:
|
||||||
|
timestamped.run(response, None, 0)
|
||||||
|
|
||||||
|
in_background(deliver)
|
||||||
|
|
||||||
|
def uninstall(self):
|
||||||
|
for handle in self.handles:
|
||||||
|
try:
|
||||||
|
self.plugin.unhook_method(handle)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.handles = []
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
"""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 remembered as hidden, so it is never restored from the
|
||||||
|
logger here;
|
||||||
|
- already deleted (kept) messages get a plain dialog without checkboxes
|
||||||
|
whose button removes them from view (locally only) 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):
|
||||||
|
self.plugin = plugin
|
||||||
|
self.chats = chats # hooks/chat.ChatSync
|
||||||
|
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("Remove deleted message" + ("s" if count > 1 else ""))
|
||||||
|
builder.set_message(
|
||||||
|
("These messages were" if count > 1 else "This message was")
|
||||||
|
+ " already deleted. Remove "
|
||||||
|
+ ("them" if count > 1 else "it")
|
||||||
|
+ " from view on this device?"
|
||||||
|
)
|
||||||
|
|
||||||
|
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("Remove", 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):
|
||||||
|
"""Removes already deleted messages from this device only."""
|
||||||
|
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():
|
||||||
|
store.hide(account, dialog_id, ids)
|
||||||
|
store.forget_deleted(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)
|
||||||
|
store.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:
|
class Deletes:
|
||||||
def __init__(self, plugin, api, chats):
|
def __init__(self, plugin, api, chats, dialog=None):
|
||||||
self.plugin = plugin
|
self.plugin = plugin
|
||||||
self.api = api
|
self.api = api
|
||||||
self.chats = chats
|
self.chats = chats
|
||||||
|
self.dialog = dialog # hooks/delete_dialog.DeleteDialog
|
||||||
self.handles = []
|
self.handles = []
|
||||||
|
|
||||||
def install(self):
|
def install(self):
|
||||||
@@ -70,6 +71,12 @@ class Deletes:
|
|||||||
return
|
return
|
||||||
account = int(field(param.thisObject, "currentAccount") or 0)
|
account = int(field(param.thisObject, "currentAccount") or 0)
|
||||||
dialog_id = int(args[3] 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.note_own_deletion(account, ids)
|
||||||
store.forget_deleted(account, dialog_id, ids)
|
store.forget_deleted(account, dialog_id, ids)
|
||||||
|
|
||||||
|
|||||||
+66
-6
@@ -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
|
from ui.settings import Header, Input, Selector, Switch, Text
|
||||||
|
|
||||||
import accounts
|
import accounts
|
||||||
@@ -17,11 +17,14 @@ from config import (
|
|||||||
SERVER_URL,
|
SERVER_URL,
|
||||||
)
|
)
|
||||||
from hooks.chat import EDIT_UPDATES, ChatSync
|
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.deletes import UPDATES, Deletes
|
||||||
from hooks.links import Links
|
from hooks.links import Links
|
||||||
from hooks.menu import Menu
|
from hooks.menu import Menu
|
||||||
from hooks.render import Render
|
from hooks.render import Render
|
||||||
from jutil import in_background, on_ui
|
from jutil import in_background, on_ui
|
||||||
|
from views.comments import Comments
|
||||||
from views.history import History
|
from views.history import History
|
||||||
|
|
||||||
|
|
||||||
@@ -40,18 +43,60 @@ class MessageLoggerPlugin(BasePlugin):
|
|||||||
self.history = History(self, self.api)
|
self.history = History(self, self.api)
|
||||||
self.render = Render(self)
|
self.render = Render(self)
|
||||||
self.chats = ChatSync(self, self.api)
|
self.chats = ChatSync(self, self.api)
|
||||||
self.deletes = Deletes(self, self.api, self.chats)
|
self.delete_dialog = DeleteDialog(self, self.chats)
|
||||||
|
self.deletes = Deletes(self, self.api, self.chats, self.delete_dialog)
|
||||||
self.links = Links(self)
|
self.links = Links(self)
|
||||||
self.menu = Menu(self, self.history.open)
|
self.menu = Menu(self, self.history.open)
|
||||||
for part in (self.render, self.chats, self.deletes, self.links, self.menu):
|
self.comments = CommentsButton(self, Comments(self, self.api).open)
|
||||||
|
parts = (
|
||||||
|
self.render,
|
||||||
|
self.chats,
|
||||||
|
self.deletes,
|
||||||
|
self.delete_dialog,
|
||||||
|
self.links,
|
||||||
|
self.menu,
|
||||||
|
self.comments,
|
||||||
|
)
|
||||||
|
for part in parts:
|
||||||
try:
|
try:
|
||||||
part.install()
|
part.install()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
diag.error(f"install {type(part).__name__}", exc)
|
diag.error(f"install {type(part).__name__}", exc)
|
||||||
|
self.add_menu_item(
|
||||||
|
MenuItemData(
|
||||||
|
menu_type=MenuItemType.CHAT_ACTION_MENU,
|
||||||
|
text="Reset deleted/edited marks",
|
||||||
|
icon="msg_clear",
|
||||||
|
on_click=self._reset_chat,
|
||||||
|
)
|
||||||
|
)
|
||||||
in_background(accounts.refresh)
|
in_background(accounts.refresh)
|
||||||
in_background(self.purge_cache)
|
in_background(self.purge_cache)
|
||||||
diag.log("loaded")
|
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 _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 purge_cache(self, force=False):
|
def purge_cache(self, force=False):
|
||||||
"""Removes cached answers older than the configured lifetime; at most
|
"""Removes cached answers older than the configured lifetime; at most
|
||||||
once a day (the day turns at local midnight) unless `force`."""
|
once a day (the day turns at local midnight) unless `force`."""
|
||||||
@@ -62,7 +107,15 @@ class MessageLoggerPlugin(BasePlugin):
|
|||||||
store.purge_old(CACHE_DAYS_CHOICES[index][1], force=force)
|
store.purge_old(CACHE_DAYS_CHOICES[index][1], force=force)
|
||||||
|
|
||||||
def on_plugin_unload(self):
|
def on_plugin_unload(self):
|
||||||
for part in ("render", "chats", "deletes", "links", "menu"):
|
for part in (
|
||||||
|
"render",
|
||||||
|
"chats",
|
||||||
|
"deletes",
|
||||||
|
"delete_dialog",
|
||||||
|
"links",
|
||||||
|
"menu",
|
||||||
|
"comments",
|
||||||
|
):
|
||||||
if hasattr(self, part):
|
if hasattr(self, part):
|
||||||
getattr(self, part).uninstall()
|
getattr(self, part).uninstall()
|
||||||
diag.log("unloaded")
|
diag.log("unloaded")
|
||||||
@@ -137,7 +190,14 @@ class MessageLoggerPlugin(BasePlugin):
|
|||||||
text="Report deletions",
|
text="Report deletions",
|
||||||
default=DEFAULTS[REPORT_DELETIONS],
|
default=DEFAULTS[REPORT_DELETIONS],
|
||||||
subtext="Tell the server about deletions this client sees",
|
subtext="Tell the server about deletions this client sees",
|
||||||
icon="msg_sync",
|
icon="msg_report",
|
||||||
|
),
|
||||||
|
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(
|
Selector(
|
||||||
key=CACHE_DAYS,
|
key=CACHE_DAYS,
|
||||||
@@ -154,6 +214,6 @@ class MessageLoggerPlugin(BasePlugin):
|
|||||||
text="Deleted message opacity",
|
text="Deleted message opacity",
|
||||||
default=DEFAULTS[DELETED_ALPHA],
|
default=DEFAULTS[DELETED_ALPHA],
|
||||||
items=[label for label, _ in ALPHA_CHOICES],
|
items=[label for label, _ in ALPHA_CHOICES],
|
||||||
icon="msg_hide",
|
icon="msg_palette",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -91,6 +91,11 @@ def _conn():
|
|||||||
"at REAL, PRIMARY KEY (account, dialog_id, message_id))"
|
"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 meta (key TEXT PRIMARY KEY, value TEXT)")
|
||||||
|
_db.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS hidden ("
|
||||||
|
"account INTEGER, dialog_id INTEGER, message_id INTEGER, at REAL, "
|
||||||
|
"PRIMARY KEY (account, dialog_id, message_id))"
|
||||||
|
)
|
||||||
_db.execute(
|
_db.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS coverage ("
|
"CREATE TABLE IF NOT EXISTS coverage ("
|
||||||
"account INTEGER, dialog_id INTEGER, low INTEGER, high INTEGER, at REAL, "
|
"account INTEGER, dialog_id INTEGER, low INTEGER, high INTEGER, at REAL, "
|
||||||
@@ -180,10 +185,12 @@ def cached_deleted(account, dialog_id, low, high):
|
|||||||
[low, high] (what a restore needs, without asking the server again)."""
|
[low, high] (what a restore needs, without asking the server again)."""
|
||||||
with _lock:
|
with _lock:
|
||||||
_local_ids(account, dialog_id)
|
_local_ids(account, dialog_id)
|
||||||
|
hidden = _hidden_ids(account, dialog_id)
|
||||||
return [
|
return [
|
||||||
state.raw
|
state.raw
|
||||||
for message_id, state in _states.get((account, dialog_id), {}).items()
|
for message_id, state in _states.get((account, dialog_id), {}).items()
|
||||||
if low <= message_id <= high
|
if low <= message_id <= high
|
||||||
|
and message_id not in hidden
|
||||||
and state.raw is not None
|
and state.raw is not None
|
||||||
and state.raw.get("deleted_at")
|
and state.raw.get("deleted_at")
|
||||||
and state.raw.get("logged")
|
and state.raw.get("logged")
|
||||||
@@ -284,6 +291,79 @@ def forget_deleted(account, dialog_id, message_ids):
|
|||||||
diag.error("store.forget_deleted", exc)
|
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
|
||||||
|
|
||||||
|
|
||||||
|
_hidden = {} # (account, dialog_id) -> set of ids the user removed from view
|
||||||
|
|
||||||
|
|
||||||
|
def _hidden_ids(account, dialog_id):
|
||||||
|
key = (account, dialog_id)
|
||||||
|
if key not in _hidden:
|
||||||
|
found = set()
|
||||||
|
try:
|
||||||
|
rows = _conn().execute(
|
||||||
|
"SELECT message_id FROM hidden WHERE account=? AND dialog_id=?", key
|
||||||
|
).fetchall()
|
||||||
|
found = {r[0] for r in rows}
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("store.hidden", exc)
|
||||||
|
_hidden[key] = found
|
||||||
|
return _hidden[key]
|
||||||
|
|
||||||
|
|
||||||
|
def hide(account, dialog_id, message_ids):
|
||||||
|
"""Messages the user deleted (or removed from view): never shown or
|
||||||
|
restored here again."""
|
||||||
|
ids = [int(i) for i in message_ids]
|
||||||
|
with _lock:
|
||||||
|
_hidden_ids(account, dialog_id).update(ids)
|
||||||
|
try:
|
||||||
|
db = _conn()
|
||||||
|
now = time.time()
|
||||||
|
db.executemany(
|
||||||
|
"INSERT OR IGNORE INTO hidden VALUES (?, ?, ?, ?)",
|
||||||
|
[(account, dialog_id, i, now) for i in ids],
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
diag.error("store.hide", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def is_hidden(account, dialog_id, message_id):
|
||||||
|
with _lock:
|
||||||
|
return int(message_id) in _hidden_ids(account, dialog_id)
|
||||||
|
|
||||||
|
|
||||||
def note_own_deletion(account, message_ids):
|
def note_own_deletion(account, message_ids):
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
with _lock:
|
with _lock:
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
"""Comments of a deleted channel post, shown as the app's own comments
|
||||||
|
section: the discussion group's ChatActivity in thread mode, with names,
|
||||||
|
avatars, replies and media as for a post that still exists.
|
||||||
|
|
||||||
|
Telegram deletes a post's discussion thread with it (the comments come back
|
||||||
|
empty, getDiscussionMessage fails with MSG_ID_INVALID), so the comments come
|
||||||
|
from the logger: copies in the activity group's comments topic (each starting
|
||||||
|
with a header, stripped here), read through an account that can (else the
|
||||||
|
server's text, media marked unavailable).
|
||||||
|
|
||||||
|
The thread is opened like ChatActivity.openDiscussionMessageChat does
|
||||||
|
(setThreadMessages with the post as its start message); the history request
|
||||||
|
it then sends (messages.getReplies of the thread) is answered locally from
|
||||||
|
the logged comments (`answer`, hooked in ConnectionsManager by
|
||||||
|
hooks/comments.py), so the app builds the section itself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import accounts
|
||||||
|
import diag
|
||||||
|
import header
|
||||||
|
import peers
|
||||||
|
import tg
|
||||||
|
from api import ApiError
|
||||||
|
from jutil import in_background, java_list, on_ui
|
||||||
|
from views.history import History, _bulletin, _epoch
|
||||||
|
|
||||||
|
# thread id -> (account, discussion channel id, [TL messages], [users], [chats])
|
||||||
|
_threads = {}
|
||||||
|
# classGuids of the comment screens opened here: read-only (no input bar)
|
||||||
|
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."""
|
||||||
|
from android.view import View
|
||||||
|
|
||||||
|
from jutil import field
|
||||||
|
|
||||||
|
enter = field(chat, "chatActivityEnterView")
|
||||||
|
if enter is not None:
|
||||||
|
enter.setVisibility(View.GONE)
|
||||||
|
overlay = field(chat, "bottomOverlay")
|
||||||
|
text = field(chat, "bottomOverlayText")
|
||||||
|
if overlay is None or text is None:
|
||||||
|
return
|
||||||
|
for name in ("bottomOverlayChatText", "bottomOverlayStartButton", "bottomOverlayProgress"):
|
||||||
|
other = field(chat, name)
|
||||||
|
if other is not None:
|
||||||
|
other.setVisibility(View.GONE)
|
||||||
|
# The app animates the overlay out when the thread has an input bar:
|
||||||
|
# stop that, or it ends invisible (alpha 0) after this.
|
||||||
|
animation = field(chat, "bottomOverlayAnimation")
|
||||||
|
if animation is not None:
|
||||||
|
animation.cancel()
|
||||||
|
text.setText("Viewing deleted comments")
|
||||||
|
text.setVisibility(View.VISIBLE)
|
||||||
|
overlay.setAlpha(1.0)
|
||||||
|
overlay.setVisibility(View.VISIBLE)
|
||||||
|
|
||||||
|
|
||||||
|
def _peer(controller, marked):
|
||||||
|
"""TLRPC peer of a Telethon sender id."""
|
||||||
|
from hooks.chat import _android_peer_id
|
||||||
|
|
||||||
|
return controller.getPeer(_android_peer_id(int(marked)))
|
||||||
|
|
||||||
|
|
||||||
|
def _sender_object(controller, marked, name):
|
||||||
|
"""The sender's User/Chat from the account's cache, else a stand-in with
|
||||||
|
the logged name (avatar drawn from its initials)."""
|
||||||
|
from hooks.chat import _android_peer_id
|
||||||
|
from org.telegram.tgnet import TLRPC
|
||||||
|
|
||||||
|
peer_id = _android_peer_id(int(marked))
|
||||||
|
if peer_id > 0:
|
||||||
|
user = controller.getUser(peer_id)
|
||||||
|
if user is None:
|
||||||
|
user = TLRPC.TL_user()
|
||||||
|
user.id = peer_id
|
||||||
|
user.first_name = name or "Deleted"
|
||||||
|
user.flags |= 2
|
||||||
|
return user, None
|
||||||
|
chat = controller.getChat(-peer_id)
|
||||||
|
if chat is None:
|
||||||
|
chat = TLRPC.TL_channel()
|
||||||
|
chat.id = -peer_id
|
||||||
|
chat.title = name or "Channel"
|
||||||
|
chat.broadcast = True
|
||||||
|
return None, chat
|
||||||
|
|
||||||
|
|
||||||
|
class Comments(History):
|
||||||
|
def open(self, message):
|
||||||
|
"""Shows the logged comments of a channel post (MessageObject)."""
|
||||||
|
if message is None:
|
||||||
|
return
|
||||||
|
if not self.api.configured:
|
||||||
|
_bulletin("Message Logger: set the server URL and API token first", True)
|
||||||
|
return
|
||||||
|
account = int(message.currentAccount)
|
||||||
|
chat_id = peers.marked_chat_id(account, int(message.getDialogId()))
|
||||||
|
replies = message.messageOwner.replies
|
||||||
|
if chat_id is None or int(message.getId()) <= 0 or replies is None:
|
||||||
|
_bulletin("No comments logged for this post")
|
||||||
|
return
|
||||||
|
discussion = int(replies.channel_id)
|
||||||
|
in_background(lambda: self._load(message, account, chat_id, discussion))
|
||||||
|
|
||||||
|
def _load(self, original, account, chat_id, discussion):
|
||||||
|
try:
|
||||||
|
result = self.api.comments(
|
||||||
|
peers.own_user_id(account), chat_id, int(original.getId())
|
||||||
|
)
|
||||||
|
except ApiError as exc:
|
||||||
|
_bulletin(f"Message Logger: {exc}", True)
|
||||||
|
return
|
||||||
|
items = (result or {}).get("comments") or []
|
||||||
|
if not items:
|
||||||
|
_bulletin("The post was deleted; no comments were logged for it")
|
||||||
|
return
|
||||||
|
ids = [int(c["message_id"]) for c in items if c.get("message_id")]
|
||||||
|
reader = None
|
||||||
|
if result.get("chat_id") and ids:
|
||||||
|
channel_id = peers.channel_id_from_marked(result["chat_id"])
|
||||||
|
readers = accounts.with_access(
|
||||||
|
-channel_id,
|
||||||
|
prefer=(account, accounts.slot_of(result.get("master_tg_id"))),
|
||||||
|
)
|
||||||
|
reader = readers[0] if readers else None
|
||||||
|
if reader is None:
|
||||||
|
on_ui(lambda: self._open(original, items, None, {}, discussion))
|
||||||
|
return
|
||||||
|
|
||||||
|
def done(found, error):
|
||||||
|
if found is None:
|
||||||
|
diag.log(f"comments: activity group unreadable: {error}")
|
||||||
|
found = {}
|
||||||
|
on_ui(lambda: self._open(original, items, reader, found, discussion))
|
||||||
|
|
||||||
|
tg.get_messages(reader, -channel_id, ids, done)
|
||||||
|
|
||||||
|
# the thread
|
||||||
|
|
||||||
|
def _open(self, original, items, reader, found, discussion):
|
||||||
|
from java import jint
|
||||||
|
from org.telegram.messenger import MessageObject, MessagesController
|
||||||
|
from org.telegram.ui import ChatActivity
|
||||||
|
from android.os import Bundle
|
||||||
|
from client_utils import get_last_fragment
|
||||||
|
|
||||||
|
from views import open_as
|
||||||
|
|
||||||
|
# Drawn as the account that read the copies (their media and the
|
||||||
|
# senders it saw) when it knows the discussion group, else as the
|
||||||
|
# viewer.
|
||||||
|
viewer = int(original.currentAccount)
|
||||||
|
account = viewer
|
||||||
|
if reader is not None and MessagesController.getInstance(reader).getChat(
|
||||||
|
discussion
|
||||||
|
) is not None:
|
||||||
|
account = reader
|
||||||
|
controller = MessagesController.getInstance(account)
|
||||||
|
group = controller.getChat(discussion)
|
||||||
|
if group is None:
|
||||||
|
_bulletin("The post's discussion group isn't known to this account", True)
|
||||||
|
return
|
||||||
|
sources = {int(c["source_message_id"]) for c in items}
|
||||||
|
# Comments answering the post itself reply to its (deleted) copy in
|
||||||
|
# the discussion group: that id is the thread's.
|
||||||
|
tops = [
|
||||||
|
int(c["reply_to"])
|
||||||
|
for c in items
|
||||||
|
if c.get("reply_to") and int(c["reply_to"]) not in sources
|
||||||
|
]
|
||||||
|
top = max(set(tops), key=tops.count) if tops else min(sources) - 1
|
||||||
|
|
||||||
|
messages, users, chats = [], {}, {group.id: group}
|
||||||
|
channel = controller.getChat(-int(original.getDialogId()))
|
||||||
|
if channel is not None:
|
||||||
|
chats[channel.id] = channel
|
||||||
|
for item in items:
|
||||||
|
copy = found.get(int(item.get("message_id") or 0)) if reader is not None else None
|
||||||
|
msg, _drawn_as = self._message_for(original, item, copy, reader)
|
||||||
|
self._into_thread(msg, item, discussion, top, sources)
|
||||||
|
if item.get("sender_id"):
|
||||||
|
user, chat = _sender_object(
|
||||||
|
controller, item["sender_id"], item.get("sender_name")
|
||||||
|
)
|
||||||
|
if user is not None:
|
||||||
|
users[user.id] = user
|
||||||
|
if chat is not None:
|
||||||
|
chats[chat.id] = chat
|
||||||
|
messages.append(msg)
|
||||||
|
with _lock:
|
||||||
|
_threads[top] = (account, discussion, messages, list(users.values()), list(chats.values()))
|
||||||
|
|
||||||
|
start = MessageObject(account, self._start_message(original, discussion, top, len(items)), False, False)
|
||||||
|
args = Bundle()
|
||||||
|
args.putLong("chat_id", discussion)
|
||||||
|
thread = ChatActivity(args)
|
||||||
|
thread.setCurrentAccount(account)
|
||||||
|
from java.util import ArrayList
|
||||||
|
|
||||||
|
starts = ArrayList()
|
||||||
|
starts.add(start)
|
||||||
|
thread.setThreadMessages(starts, group, jint(top), jint(0), jint(0), None)
|
||||||
|
fragment = get_last_fragment()
|
||||||
|
if fragment is None:
|
||||||
|
return
|
||||||
|
read_only.add(int(thread.getClassGuid()))
|
||||||
|
fragment.presentFragment(thread)
|
||||||
|
make_read_only(thread)
|
||||||
|
# Again once the screen settled (its first layout re-runs the
|
||||||
|
# overlay animation).
|
||||||
|
on_ui(lambda: make_read_only(thread), 600)
|
||||||
|
open_as._opened_as(thread, account)
|
||||||
|
if reader is None:
|
||||||
|
on_ui(
|
||||||
|
lambda: _bulletin("Media unavailable: sign in an account of the logger's groups"),
|
||||||
|
600,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _into_thread(self, msg, item, discussion, top, sources):
|
||||||
|
"""Makes a mock comment a message of the discussion thread."""
|
||||||
|
from org.telegram.tgnet import TLRPC
|
||||||
|
|
||||||
|
msg.id = int(item["source_message_id"])
|
||||||
|
msg.dialog_id = -int(discussion)
|
||||||
|
msg.peer_id = TLRPC.TL_peerChannel()
|
||||||
|
msg.peer_id.channel_id = int(discussion)
|
||||||
|
reply = TLRPC.TL_messageReplyHeader()
|
||||||
|
target = int(item.get("reply_to") or 0)
|
||||||
|
reply.reply_to_msg_id = target if target in sources else top
|
||||||
|
reply.reply_to_top_id = top
|
||||||
|
reply.flags |= 2
|
||||||
|
msg.reply_to = reply
|
||||||
|
msg.flags |= 8
|
||||||
|
|
||||||
|
def _start_message(self, original, discussion, top, count):
|
||||||
|
"""The post as its discussion copy (forwarded from the channel), the
|
||||||
|
thread's start message."""
|
||||||
|
from org.telegram.tgnet import TLRPC
|
||||||
|
|
||||||
|
owner = original.messageOwner
|
||||||
|
msg = TLRPC.TL_message()
|
||||||
|
msg.id = top
|
||||||
|
msg.dialog_id = -int(discussion)
|
||||||
|
msg.peer_id = TLRPC.TL_peerChannel()
|
||||||
|
msg.peer_id.channel_id = int(discussion)
|
||||||
|
msg.from_id = owner.peer_id
|
||||||
|
msg.flags |= 256
|
||||||
|
fwd = TLRPC.TL_messageFwdHeader()
|
||||||
|
fwd.from_id = owner.peer_id
|
||||||
|
fwd.channel_post = int(original.getId())
|
||||||
|
fwd.saved_from_peer = owner.peer_id
|
||||||
|
fwd.saved_from_msg_id = int(original.getId())
|
||||||
|
fwd.date = int(owner.date)
|
||||||
|
fwd.flags |= 1 | 4 | 16
|
||||||
|
msg.fwd_from = fwd
|
||||||
|
msg.flags |= 4
|
||||||
|
msg.date = int(owner.date)
|
||||||
|
msg.message = owner.message or ""
|
||||||
|
msg.entities = owner.entities
|
||||||
|
if owner.media is not None:
|
||||||
|
msg.media = owner.media
|
||||||
|
msg.flags |= 512
|
||||||
|
replies = TLRPC.TL_messageReplies()
|
||||||
|
replies.replies = int(count)
|
||||||
|
msg.replies = replies
|
||||||
|
msg.flags |= 1 << 23
|
||||||
|
return msg
|
||||||
|
|
||||||
|
# one comment's content
|
||||||
|
|
||||||
|
def _base(self, original, item):
|
||||||
|
from org.telegram.messenger import MessagesController
|
||||||
|
from org.telegram.tgnet import TLRPC
|
||||||
|
|
||||||
|
owner = original.messageOwner
|
||||||
|
msg = TLRPC.TL_message()
|
||||||
|
msg.id = -int(item.get("source_message_id") or 1)
|
||||||
|
msg.dialog_id = int(original.getDialogId())
|
||||||
|
msg.peer_id = owner.peer_id
|
||||||
|
sender = item.get("sender_id")
|
||||||
|
controller = MessagesController.getInstance(int(original.currentAccount))
|
||||||
|
msg.from_id = _peer(controller, sender) if sender else owner.peer_id
|
||||||
|
msg.flags |= 256
|
||||||
|
msg.out = False
|
||||||
|
msg.date = _epoch(item.get("date")) or int(owner.date)
|
||||||
|
return msg
|
||||||
|
|
||||||
|
def _from_copy(self, original, item, copy):
|
||||||
|
"""The copy without its header (From, links...)."""
|
||||||
|
from java.util import ArrayList
|
||||||
|
|
||||||
|
from hooks.chat import _rich_plain_text, _strip_rich_header
|
||||||
|
|
||||||
|
msg = super()._from_copy(original, item, copy)
|
||||||
|
rich = getattr(copy, "rich_message", None)
|
||||||
|
if rich is not None:
|
||||||
|
_strip_rich_header(rich)
|
||||||
|
plain = _rich_plain_text(rich)
|
||||||
|
if plain: # text only: a normal bubble
|
||||||
|
msg.message = plain
|
||||||
|
msg.entities = ArrayList()
|
||||||
|
msg.rich_message = None
|
||||||
|
msg.flags2 &= ~(1 << 12)
|
||||||
|
return msg
|
||||||
|
text, entities = header.strip(
|
||||||
|
msg.message or "",
|
||||||
|
[(int(e.offset), int(e.length), e) for e in java_list(msg.entities)],
|
||||||
|
)
|
||||||
|
msg.message = text
|
||||||
|
msg.entities = ArrayList()
|
||||||
|
for offset, length, entity in entities:
|
||||||
|
entity.offset = offset
|
||||||
|
entity.length = length
|
||||||
|
msg.entities.add(entity)
|
||||||
|
return msg
|
||||||
+29
-14
@@ -217,6 +217,25 @@ class History:
|
|||||||
# the sheet
|
# the sheet
|
||||||
|
|
||||||
def _show(self, original, versions, reader, found, topic):
|
def _show(self, original, versions, reader, found, topic):
|
||||||
|
entries = []
|
||||||
|
last = len(versions) - 1
|
||||||
|
for index, version in enumerate(versions):
|
||||||
|
label = "Original" if version["version"] == 0 else f"Edit {version['version']}"
|
||||||
|
if index == last:
|
||||||
|
label += " · current"
|
||||||
|
when = _when(version.get("edit_date"))
|
||||||
|
entries.append((f"{label} · {when}" if when else label, version))
|
||||||
|
self._sheet(original, "Edit history", entries, reader, found, topic)
|
||||||
|
|
||||||
|
def _message_for(self, original, item, copy, reader):
|
||||||
|
"""(TL message, account to draw it as) of an entry: from its copy in
|
||||||
|
the activity group when readable, else from the server's text."""
|
||||||
|
if copy is not None and not isinstance(copy, _message_empty()):
|
||||||
|
return self._from_copy(original, item, copy), reader
|
||||||
|
return self._from_text(original, item), int(original.currentAccount)
|
||||||
|
|
||||||
|
def _sheet(self, original, title_text, entries, reader, found, topic):
|
||||||
|
"""The mock chat: a service pill (label) and a bubble per entry."""
|
||||||
from android.view import Gravity
|
from android.view import Gravity
|
||||||
from android.widget import LinearLayout, ScrollView, TextView
|
from android.widget import LinearLayout, ScrollView, TextView
|
||||||
from client_utils import get_last_fragment
|
from client_utils import get_last_fragment
|
||||||
@@ -232,21 +251,17 @@ class History:
|
|||||||
column = LinearLayout(context)
|
column = LinearLayout(context)
|
||||||
column.setOrientation(LinearLayout.VERTICAL)
|
column.setOrientation(LinearLayout.VERTICAL)
|
||||||
column.setPadding(0, dp(8), 0, dp(12))
|
column.setPadding(0, dp(8), 0, dp(12))
|
||||||
last = len(versions) - 1
|
for label, item in entries:
|
||||||
for index, version in enumerate(versions):
|
column.addView(self._service(context, label))
|
||||||
label = "Original" if version["version"] == 0 else f"Edit {version['version']}"
|
copy = (
|
||||||
if index == last:
|
found.get(int(item.get("message_id") or 0)) if reader is not None else None
|
||||||
label += " · current"
|
)
|
||||||
when = _when(version.get("edit_date"))
|
|
||||||
column.addView(self._service(context, f"{label} · {when}" if when else label))
|
|
||||||
copy = found.get(int(version.get("message_id") or 0)) if reader is not None else None
|
|
||||||
if copy is not None and not isinstance(copy, _message_empty()):
|
|
||||||
msg, account = self._from_copy(original, version, copy), reader
|
|
||||||
else:
|
|
||||||
msg, account = self._from_text(original, version), int(original.currentAccount)
|
|
||||||
try:
|
try:
|
||||||
|
msg, account = self._message_for(original, item, copy, reader)
|
||||||
column.addView(
|
column.addView(
|
||||||
self._bubble(context, account, self._message_object(account, original, msg), version)
|
self._bubble(
|
||||||
|
context, account, self._message_object(account, original, msg), item
|
||||||
|
)
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
diag.error("history.bubble", exc)
|
diag.error("history.bubble", exc)
|
||||||
@@ -265,7 +280,7 @@ class History:
|
|||||||
header.setOrientation(LinearLayout.HORIZONTAL)
|
header.setOrientation(LinearLayout.HORIZONTAL)
|
||||||
header.setGravity(Gravity.CENTER_VERTICAL)
|
header.setGravity(Gravity.CENTER_VERTICAL)
|
||||||
title = TextView(context)
|
title = TextView(context)
|
||||||
title.setText("Edit history" + ("" if reader is not None else " · media unavailable"))
|
title.setText(title_text + ("" if reader is not None else " · media unavailable"))
|
||||||
title.setTextSize(17)
|
title.setTextSize(17)
|
||||||
title.setTextColor(jint(Theme.getColor(Theme.key_dialogTextBlack)))
|
title.setTextColor(jint(Theme.getColor(Theme.key_dialogTextBlack)))
|
||||||
title.setPadding(dp(20), dp(14), dp(12), dp(12))
|
title.setPadding(dp(20), dp(14), dp(12), dp(12))
|
||||||
|
|||||||
@@ -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,39 @@ def main(argv=None) -> int:
|
|||||||
f"removed={removed} left={left}",
|
f"removed={removed} left={left}",
|
||||||
)
|
)
|
||||||
check("age purge runs once a day", store.purge_old(1) == 0)
|
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.hide(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),
|
||||||
|
)
|
||||||
for name in ("cached_deleted", "coverage", "set_coverage", "expire_coverage", "replace_window"):
|
for name in ("cached_deleted", "coverage", "set_coverage", "expire_coverage", "replace_window"):
|
||||||
check(f"store.{name} exists", callable(getattr(store, name, None)))
|
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")
|
api = load("api")
|
||||||
client = api.Api(BasePlugin())
|
client = api.Api(BasePlugin())
|
||||||
check("API unconfigured without URL and token", not client.configured)
|
check("API unconfigured without URL and token", not client.configured)
|
||||||
|
|||||||
Reference in new issue
Block a user