generated from exteraGramPlugins/pluginBase
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b3a4bece3 | ||
|
|
f58e157b70 | ||
|
|
b885f2179f | ||
|
|
af23d2e682 |
No files matched your search
@@ -1,219 +1,44 @@
|
|||||||
# exteraGram Plugin Base
|
<p align="center">
|
||||||
|
<img src="images/icon.svg" width="128" height="128" alt="Chat Summaries icon">
|
||||||
|
</p>
|
||||||
|
|
||||||
A build toolchain and ADB automation for writing [exteraGram](https://exteragram.app) plugins
|
<h1 align="center">Chat Summaries</h1>
|
||||||
as ordinary multi-file Python projects instead of hand-maintaining one giant `.py` file.
|
|
||||||
|
|
||||||
## How exteraGram plugins actually work
|
**TL;DR:** an [exteraGram](https://exteragram.app) plugin that summarizes the last N
|
||||||
|
messages of any chat, group, channel or topic with AI, right inside the chat.
|
||||||
|
|
||||||
A plugin, as exteraGram loads it, is exactly **one Python file** on the device:
|
## Description
|
||||||
`/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
|
Chat Summaries sends a chat's recent history to any OpenAI-compatible endpoint (OpenAI,
|
||||||
assignments (`__id__`, `__name__`, `__description__`, `__author__`, `__version__`,
|
OpenRouter, a local Ollama, …) and streams the answer into a summary card placed after the
|
||||||
`__icon__`, `__app_version__`, `__sdk_version__`, `__requirements__`) without executing the
|
newest summarized message. Summaries are saved on the device and come back whenever the chat
|
||||||
file. Only `__id__` and `__name__` are required.
|
is reopened.
|
||||||
|
|
||||||
The file must define exactly one class inheriting `BasePlugin` (`from base_plugin import
|
## Features
|
||||||
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
|
- **Summarize any chat.** ⋮ → Plugins → Summarize, or hold the unread button to summarize
|
||||||
`docs/sdk-notes.md` for why.
|
the unread messages. Pick how many messages (1–1000) and an offset to skip the newest ones.
|
||||||
|
- **Streaming summary card.** Reasoning and answer stream into a card in the chat, which
|
||||||
|
jumps to it once. The finished summary ends with an `#aisummaries` hashtag.
|
||||||
|
- **Any OpenAI-compatible endpoint.** Set the base URL, API key (optional, e.g. for local
|
||||||
|
Ollama) and model in the settings.
|
||||||
|
- **Custom prompts.** A default prompt, with per-chat overrides.
|
||||||
|
- **Anonymized transcript.** Senders are sent to the model as numbers, not names; attached
|
||||||
|
file names are included only if you enable it.
|
||||||
|
- **Saved locally.** Summaries are restored when the chat is reopened. Delete a card like any
|
||||||
|
message to forget it; the settings show how many are saved and how much space they take,
|
||||||
|
and offer *Clear all summaries*.
|
||||||
|
- **Clean failures.** If anything fails before the answer starts, you only get a bulletin
|
||||||
|
and the chat is left untouched.
|
||||||
|
|
||||||
## What the plugin does
|
Summaries exist only on this device, so Telegram's search, which runs on the server, does
|
||||||
|
not find them, not even by `#aisummaries`.
|
||||||
|
|
||||||
**Summarize** (⋮ → Plugins → Summarize, or hold the unread button) runs in this order:
|
## Documentation
|
||||||
|
|
||||||
1. A bulletin confirms the press, then the history is fetched.
|
- [How Chat Summaries works](docs/how-it-works.md): the summarize flow step by step
|
||||||
2. The transcript streams to the configured OpenAI-compatible endpoint.
|
- [Development](docs/development.md): getting started, repo layout, building and verifying, icon export
|
||||||
3. Only when the first chunk arrives is a summary card injected, directly after the newest
|
- [How exteraGram plugins work](docs/how-plugins-work.md): the plugin format and lifecycle
|
||||||
analyzed message. If that message is not loaded (an offset, or history scrolled away),
|
- [Device setup and ADB automation](docs/device-and-adb.md): deploy, watch, logs, debugger
|
||||||
the chat is asked to load it first. The chat then jumps to the card once.
|
- [Versioning and releases](docs/versioning-and-releases.md): git hooks, auto-bump, CI publishing
|
||||||
4. Reasoning and answer stream into the card. The finished summary ends with a native
|
- [SDK notes](docs/sdk-notes.md): condensed exteraGram SDK API surface
|
||||||
`#aisummaries` hashtag and is saved to a local SQLite store, from which it is restored
|
|
||||||
whenever the chat is reopened.
|
|
||||||
|
|
||||||
Any failure before step 3 (fetch error, empty history, unreachable host, bad key, HTTP
|
|
||||||
error) is reported as a bulletin only: the chat is not modified and the view does not move.
|
|
||||||
A failure mid-stream is written onto the card, and a summary saved earlier for the same
|
|
||||||
range is kept.
|
|
||||||
|
|
||||||
Deleting a card like any other message removes its saved row. The settings page shows how
|
|
||||||
many summaries are saved and roughly how much space they take, and offers **Clear all
|
|
||||||
summaries** (with a confirmation step).
|
|
||||||
|
|
||||||
Summaries exist only on this device, so Telegram's own search, which runs on the server,
|
|
||||||
does not find them, including a search for `#aisummaries`.
|
|
||||||
|
|
||||||
## 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
|
|
||||||
config.py setting keys, defaults and tunables
|
|
||||||
diagnostics.py bounded in-memory log, mirrored to a file (logcat is restricted here)
|
|
||||||
providers/ endpoint settings, prompt assembly and SSE streaming
|
|
||||||
services/ history fetch, anonymized transcript, markdown→RichMessage, injection, SQLite store
|
|
||||||
hooks/ Xposed hooks (unread-button entry points, summary restore on chat open,
|
|
||||||
forgetting a summary when its card is deleted)
|
|
||||||
ui/ summarize dialog, bulletin and theme helpers
|
|
||||||
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.
|
|
||||||
|
|
||||||
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,70 @@
|
|||||||
|
# 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
|
||||||
|
config.py setting keys, defaults and tunables
|
||||||
|
diagnostics.py bounded in-memory log, mirrored to a file (logcat is restricted here)
|
||||||
|
providers/ endpoint settings, prompt assembly and SSE streaming
|
||||||
|
services/ history fetch, anonymized transcript, markdown→RichMessage, injection, SQLite store
|
||||||
|
hooks/ Xposed hooks (unread-button entry points, summary restore on chat open,
|
||||||
|
forgetting a summary when its card is deleted)
|
||||||
|
ui/ summarize dialog, bulletin and theme helpers
|
||||||
|
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,26 @@
|
|||||||
|
# How Chat Summaries works
|
||||||
|
|
||||||
|
## Summarize flow
|
||||||
|
|
||||||
|
**Summarize** (⋮ → Plugins → Summarize, or hold the unread button) runs in this order:
|
||||||
|
|
||||||
|
1. A bulletin confirms the press, then the history is fetched.
|
||||||
|
2. The transcript streams to the configured OpenAI-compatible endpoint.
|
||||||
|
3. Only when the first chunk arrives is a summary card injected, directly after the newest
|
||||||
|
analyzed message. If that message is not loaded (an offset, or history scrolled away),
|
||||||
|
the chat is asked to load it first. The chat then jumps to the card once.
|
||||||
|
4. Reasoning and answer stream into the card. The finished summary ends with a native
|
||||||
|
`#aisummaries` hashtag and is saved to a local SQLite store, from which it is restored
|
||||||
|
whenever the chat is reopened.
|
||||||
|
|
||||||
|
Any failure before step 3 (fetch error, empty history, unreachable host, bad key, HTTP
|
||||||
|
error) is reported as a bulletin only: the chat is not modified and the view does not move.
|
||||||
|
A failure mid-stream is written onto the card, and a summary saved earlier for the same
|
||||||
|
range is kept.
|
||||||
|
|
||||||
|
Deleting a card like any other message removes its saved row. The settings page shows how
|
||||||
|
many summaries are saved and roughly how much space they take, and offers **Clear all
|
||||||
|
summaries** (with a confirmation step).
|
||||||
|
|
||||||
|
Summaries exist only on this device, so Telegram's own search, which runs on the server,
|
||||||
|
does not find them, including a search for `#aisummaries`.
|
||||||
@@ -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,83 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
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>
|
||||||
|
<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)"/>
|
||||||
|
|
||||||
|
<!-- back bubble: the chat being summarized -->
|
||||||
|
<path filter="url(#sh)" d="M250,128 H452 A44,44 0 0 1 496,172 V300 A44,44 0 0 1 452,344 H446 L462,392 L398,344 H250 A44,44 0 0 1 206,300 V172 A44,44 0 0 1 250,128 Z"
|
||||||
|
fill="#FFFFFF" fill-opacity="0.45" stroke="#FFFFFF" stroke-opacity="0.7" stroke-width="5" stroke-linejoin="round"/>
|
||||||
|
|
||||||
|
<!-- front bubble: the summary -->
|
||||||
|
<g filter="url(#sh)">
|
||||||
|
<path d="M190,196 H418 A48,48 0 0 1 466,244 V420 A48,48 0 0 1 418,468 H262 L178,526 L196,468 H190 A48,48 0 0 1 142,420 V244 A48,48 0 0 1 190,196 Z"
|
||||||
|
fill="url(#paper)" stroke="#B9B9B9" stroke-width="6" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<g stroke-linecap="round">
|
||||||
|
<line x1="186" y1="258" x2="372" y2="258" stroke="#555555" stroke-width="18"/>
|
||||||
|
<circle cx="194" cy="320" r="10" fill="#F7724A"/>
|
||||||
|
<line x1="224" y1="320" x2="420" y2="320" stroke="#8C8C8C" stroke-width="13"/>
|
||||||
|
<circle cx="194" cy="370" r="10" fill="#F7724A"/>
|
||||||
|
<line x1="224" y1="370" x2="392" y2="370" stroke="#8C8C8C" stroke-width="13"/>
|
||||||
|
<circle cx="194" cy="420" r="10" fill="#F7724A"/>
|
||||||
|
<line x1="224" y1="420" x2="336" y2="420" stroke="#8C8C8C" stroke-width="13"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- sparkles -->
|
||||||
|
<g filter="url(#sh)" stroke="#F59A2E" stroke-width="7" stroke-linejoin="round" fill="url(#star)">
|
||||||
|
<path d="M470,150 C476,196 488,208 534,214 C488,220 476,232 470,278 C464,232 452,220 406,214 C452,208 464,196 470,150 Z"/>
|
||||||
|
<path d="M532,282 C535,304 541,310 563,313 C541,316 535,322 532,344 C529,322 523,316 501,313 C523,310 529,304 532,282 Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
+6
-3
@@ -3,9 +3,12 @@
|
|||||||
"name": "Chat Summaries",
|
"name": "Chat Summaries",
|
||||||
"description": "Summarize the last N messages (with offset) in any chat, group, channel, or topic using AI.",
|
"description": "Summarize the last N messages (with offset) in any chat, group, channel, or topic using AI.",
|
||||||
"author": "@lyuksovannyy",
|
"author": "@lyuksovannyy",
|
||||||
"version": "1.1.0",
|
"version": "1.1.2",
|
||||||
"icon": "exteraPlugins/1",
|
"icon": "exteraGramPluginIcons/2",
|
||||||
"app_version": ">=12.5.1",
|
"app_version": ">=12.5.1",
|
||||||
"sdk_version": ">=1.4.4.3",
|
"sdk_version": ">=1.4.4.3",
|
||||||
"requirements": ["requests", "debugpy"]
|
"requirements": [
|
||||||
|
"requests",
|
||||||
|
"debugpy"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
+11
-3
@@ -57,6 +57,14 @@ SUMMARY_HASHTAG = "#aisummaries"
|
|||||||
|
|
||||||
# A summary card can only be placed directly after its newest analyzed
|
# A summary card can only be placed directly after its newest analyzed
|
||||||
# message, which with an offset may not be loaded yet. Placement asks the chat
|
# message, which with an offset may not be loaded yet. Placement asks the chat
|
||||||
# to load it, then retries this many times, this many ms apart.
|
# to load it, then retries this many times, this many ms apart (~10 s), asking
|
||||||
PLACEMENT_RETRIES = 25
|
# again every PLACEMENT_RELOAD_EVERY retries in case a request was dropped.
|
||||||
PLACEMENT_RETRY_DELAY_MS = 120
|
PLACEMENT_RETRIES = 80
|
||||||
|
PLACEMENT_RETRY_DELAY_MS = 125
|
||||||
|
PLACEMENT_RELOAD_EVERY = 16
|
||||||
|
|
||||||
|
# After jumping to a fresh card, check at these delays (ms) that it is still on
|
||||||
|
# screen and jump again if not. Loading a far anchor makes ChatActivity run its
|
||||||
|
# own postponed scroll, and a cache-then-server reload can re-scroll it, either
|
||||||
|
# of which can land after the jump and carry the view away from the card.
|
||||||
|
JUMP_SETTLE_DELAYS_MS = (250, 700, 1500, 3000)
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
"""Keeps the page-down button's unread counter right away from the newest messages.
|
||||||
|
|
||||||
|
ChatActivity shows `newUnreadMessageCount` on the page-down badge. It seeds it
|
||||||
|
from the dialog's unread count (everything after the read marker) and only
|
||||||
|
decrements it as messages are marked read. Away from the newest messages that
|
||||||
|
number is wrong: after a jump into history (a summary anchor, a reply, a
|
||||||
|
pinned message, search) messages between the read marker and the jump target
|
||||||
|
stay counted, so the badge can say 5k while only 3k messages lie below what is
|
||||||
|
on screen.
|
||||||
|
|
||||||
|
What the badge should show is how many messages are newer than the newest
|
||||||
|
visible one. The server answers that exactly: messages.getHistory with
|
||||||
|
offset_id=<that id>, add_offset=-1, limit=1 returns `offset_id_offset`, the
|
||||||
|
number of messages newer than offset_id. The hook asks while the chat is not
|
||||||
|
showing the newest messages, at most once per REFRESH_SECONDS per chat and only
|
||||||
|
after the visible position moved, then writes the result through the same
|
||||||
|
setter the app uses. At the bottom of the chat it never interferes.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
import diagnostics
|
||||||
|
from hooks.restore_hook import _find_methods_by_name
|
||||||
|
|
||||||
|
# The badge only needs to be right, not instant: one tiny history request per
|
||||||
|
# chat every few seconds while scrolling history is negligible next to what
|
||||||
|
# scrolling itself loads.
|
||||||
|
REFRESH_SECONDS = 3.0
|
||||||
|
|
||||||
|
|
||||||
|
def _get(obj: Any, name: str) -> Any:
|
||||||
|
try:
|
||||||
|
from hook_utils import get_private_field
|
||||||
|
|
||||||
|
return get_private_field(obj, name)
|
||||||
|
except Exception:
|
||||||
|
return getattr(obj, name, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_int(obj: Any, name: str, value: int) -> bool:
|
||||||
|
"""Writes a Java int field through reflection.
|
||||||
|
|
||||||
|
hook_utils.set_private_field returns without error here but leaves the
|
||||||
|
field unchanged (verified on device), so the field is written directly.
|
||||||
|
"""
|
||||||
|
cls = obj.getClass()
|
||||||
|
while cls is not None:
|
||||||
|
try:
|
||||||
|
field = cls.getDeclaredField(name)
|
||||||
|
field.setAccessible(True)
|
||||||
|
field.setInt(obj, int(value))
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
cls = cls.getSuperclass()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _first(value: Any) -> Any:
|
||||||
|
"""First element of a Java array exposed through Chaquopy, or None."""
|
||||||
|
try:
|
||||||
|
return value[0]
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _newest_visible_id(chat_activity: Any) -> int:
|
||||||
|
"""Largest real message id among the rows on screen, or 0."""
|
||||||
|
try:
|
||||||
|
from services.rich_injector import get_chat_adapter, get_messages_list
|
||||||
|
|
||||||
|
layout_manager = _get(chat_activity, "chatLayoutManager")
|
||||||
|
adapter = get_chat_adapter(chat_activity)
|
||||||
|
messages = get_messages_list(chat_activity)
|
||||||
|
if layout_manager is None or adapter is None or messages is None:
|
||||||
|
return 0
|
||||||
|
first = int(layout_manager.findFirstVisibleItemPosition())
|
||||||
|
last = int(layout_manager.findLastVisibleItemPosition())
|
||||||
|
if first < 0 or last < 0:
|
||||||
|
return 0
|
||||||
|
start = int(adapter.messagesStartRow)
|
||||||
|
size = int(messages.size())
|
||||||
|
best = 0
|
||||||
|
for position in range(min(first, last), max(first, last) + 1):
|
||||||
|
index = position - start
|
||||||
|
if 0 <= index < size:
|
||||||
|
owner = getattr(messages.get(index), "messageOwner", None)
|
||||||
|
msg_id = int(getattr(owner, "id", 0) or 0) if owner is not None else 0
|
||||||
|
# Injected summary cards are negative, date rows are 0.
|
||||||
|
if msg_id > best:
|
||||||
|
best = msg_id
|
||||||
|
return best
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("badge_hook.visible_id", exc)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _read_inbox_max_id(dialog_id: int) -> int:
|
||||||
|
try:
|
||||||
|
from client_utils import get_messages_controller
|
||||||
|
|
||||||
|
controller = get_messages_controller()
|
||||||
|
dialog = controller.dialogs_dict.get(dialog_id) if controller else None
|
||||||
|
return int(getattr(dialog, "read_inbox_max_id", 0) or 0) if dialog is not None else 0
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
class BadgeHook:
|
||||||
|
def __init__(self, plugin):
|
||||||
|
self.plugin = plugin
|
||||||
|
self._unhooks: List[Any] = []
|
||||||
|
# dialog_id -> (monotonic time, visible id) of the last server query.
|
||||||
|
self._asked_at: Dict[int, tuple] = {}
|
||||||
|
# dialog_id -> True while a query is in flight.
|
||||||
|
self._in_flight: Dict[int, bool] = {}
|
||||||
|
# Strong references to RequestCallback wrappers until they fire.
|
||||||
|
self._pending: List[Any] = []
|
||||||
|
|
||||||
|
def install(self) -> None:
|
||||||
|
try:
|
||||||
|
from base_plugin import MethodHook
|
||||||
|
from hook_utils import find_class
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("badge_hook.install:imports", exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
chat_activity_class = find_class("org.telegram.ui.ChatActivity")
|
||||||
|
if not chat_activity_class:
|
||||||
|
diagnostics.note("badge_hook.install: ChatActivity not found")
|
||||||
|
return
|
||||||
|
methods = _find_methods_by_name(chat_activity_class, "updateMessagesVisiblePart")
|
||||||
|
diagnostics.note(f"badge_hook.install: updateMessagesVisiblePart methods found={len(methods)}")
|
||||||
|
|
||||||
|
outer = self
|
||||||
|
|
||||||
|
class _VisiblePartHook(MethodHook):
|
||||||
|
def after_hooked_method(self, param):
|
||||||
|
try:
|
||||||
|
outer._check(param.thisObject)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("badge_hook.check", exc)
|
||||||
|
|
||||||
|
for method in methods:
|
||||||
|
try:
|
||||||
|
ref = self.plugin.hook_method(method, _VisiblePartHook())
|
||||||
|
if ref:
|
||||||
|
self._unhooks.append(ref)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("badge_hook.install:hook_method", exc)
|
||||||
|
|
||||||
|
def uninstall(self) -> None:
|
||||||
|
for ref in self._unhooks:
|
||||||
|
try:
|
||||||
|
self.plugin.unhook_method(ref)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("badge_hook.uninstall", exc)
|
||||||
|
self._unhooks = []
|
||||||
|
self._pending = []
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _check(self, chat_activity: Any) -> None:
|
||||||
|
if chat_activity is None:
|
||||||
|
return
|
||||||
|
# Topics, threads, scheduled and saved views keep their own counters.
|
||||||
|
for flag in ("isTopic", "isThreadChat", "isComments"):
|
||||||
|
if bool(_get(chat_activity, flag) or False):
|
||||||
|
return
|
||||||
|
if int(_get(chat_activity, "chatMode") or 0) != 0:
|
||||||
|
return
|
||||||
|
if int(_get(chat_activity, "newUnreadMessageCount") or 0) <= 0:
|
||||||
|
return
|
||||||
|
# Showing the newest messages: the app's own count is accurate.
|
||||||
|
if bool(_first(_get(chat_activity, "forwardEndReached")) or False):
|
||||||
|
return
|
||||||
|
|
||||||
|
dialog_id = int(chat_activity.getDialogId())
|
||||||
|
if self._in_flight.get(dialog_id):
|
||||||
|
return
|
||||||
|
visible_id = _newest_visible_id(chat_activity)
|
||||||
|
if visible_id <= 0:
|
||||||
|
return
|
||||||
|
# Viewing a message marks everything up to it read, so the backlog
|
||||||
|
# starts after whichever is newer: the read marker or the screen.
|
||||||
|
visible_id = max(visible_id, _read_inbox_max_id(dialog_id))
|
||||||
|
now = time.monotonic()
|
||||||
|
last_at, last_id = self._asked_at.get(dialog_id, (0.0, 0))
|
||||||
|
if visible_id == last_id or now - last_at < REFRESH_SECONDS:
|
||||||
|
return
|
||||||
|
if len(self._asked_at) > 64:
|
||||||
|
self._asked_at.clear()
|
||||||
|
self._asked_at[dialog_id] = (now, visible_id)
|
||||||
|
self._query(chat_activity, dialog_id, visible_id)
|
||||||
|
|
||||||
|
def _query(self, chat_activity: Any, dialog_id: int, visible_id: int) -> None:
|
||||||
|
try:
|
||||||
|
from client_utils import RequestCallback, get_messages_controller, send_request
|
||||||
|
from org.telegram.tgnet import TLRPC
|
||||||
|
from services.message_fetcher import MessageFetcher
|
||||||
|
|
||||||
|
controller = get_messages_controller()
|
||||||
|
peer = MessageFetcher._resolve_input_peer(controller, TLRPC, dialog_id) if controller else None
|
||||||
|
if peer is None:
|
||||||
|
return
|
||||||
|
req = TLRPC.TL_messages_getHistory()
|
||||||
|
req.peer = peer
|
||||||
|
req.offset_id = visible_id
|
||||||
|
req.offset_date = 0
|
||||||
|
# -1 returns the anchor itself; offset_id_offset then counts the
|
||||||
|
# messages strictly newer than it.
|
||||||
|
req.add_offset = -1
|
||||||
|
req.limit = 1
|
||||||
|
req.max_id = 0
|
||||||
|
req.min_id = 0
|
||||||
|
req.hash = 0
|
||||||
|
|
||||||
|
box: List[Any] = []
|
||||||
|
|
||||||
|
def on_complete(response, error):
|
||||||
|
try:
|
||||||
|
self._in_flight.pop(dialog_id, None)
|
||||||
|
for proxy in box:
|
||||||
|
if proxy in self._pending:
|
||||||
|
self._pending.remove(proxy)
|
||||||
|
if error is not None or response is None:
|
||||||
|
return
|
||||||
|
newer = getattr(response, "offset_id_offset", None)
|
||||||
|
if newer is None:
|
||||||
|
return
|
||||||
|
self._apply(chat_activity, dialog_id, int(newer), visible_id)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("badge_hook.on_complete", exc)
|
||||||
|
|
||||||
|
proxy = RequestCallback(on_complete)
|
||||||
|
box.append(proxy)
|
||||||
|
self._pending.append(proxy)
|
||||||
|
self._in_flight[dialog_id] = True
|
||||||
|
send_request(req, proxy)
|
||||||
|
except Exception as exc:
|
||||||
|
self._in_flight.pop(dialog_id, None)
|
||||||
|
diagnostics.record_error("badge_hook.query", exc)
|
||||||
|
|
||||||
|
def _apply(self, chat_activity: Any, dialog_id: int, newer: int, visible_id: int) -> None:
|
||||||
|
def run():
|
||||||
|
try:
|
||||||
|
if int(chat_activity.getDialogId()) != dialog_id:
|
||||||
|
return
|
||||||
|
# Never raise the badge: when the user already read past what
|
||||||
|
# is on screen, the app's lower count is the right one.
|
||||||
|
current = int(_get(chat_activity, "newUnreadMessageCount") or 0)
|
||||||
|
if current <= 0:
|
||||||
|
return
|
||||||
|
value = max(0, min(newer, current))
|
||||||
|
if value == current:
|
||||||
|
return
|
||||||
|
if not _set_int(chat_activity, "newUnreadMessageCount", value):
|
||||||
|
return
|
||||||
|
_set_int(chat_activity, "prevSetUnreadCount", value)
|
||||||
|
layout = _get(chat_activity, "sideControlsButtonsLayout")
|
||||||
|
if layout is not None:
|
||||||
|
# Button 1 is the page-down button; the app animates only
|
||||||
|
# when the button is already on screen.
|
||||||
|
layout.setButtonCount(1, value, True)
|
||||||
|
diagnostics.note(
|
||||||
|
f"badge_hook: dialog {dialog_id} badge {current} -> {value} "
|
||||||
|
f"({newer} messages newer than visible {visible_id})"
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("badge_hook.apply", exc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from android_utils import run_on_ui_thread
|
||||||
|
|
||||||
|
run_on_ui_thread(run)
|
||||||
|
except Exception:
|
||||||
|
run()
|
||||||
+16
-1
@@ -24,6 +24,7 @@ from config import (
|
|||||||
SETTING_STORAGE_REVISION,
|
SETTING_STORAGE_REVISION,
|
||||||
STREAM_UPDATE_INTERVAL,
|
STREAM_UPDATE_INTERVAL,
|
||||||
)
|
)
|
||||||
|
from hooks.badge_hook import BadgeHook
|
||||||
from hooks.delete_hook import DeleteHook
|
from hooks.delete_hook import DeleteHook
|
||||||
from hooks.restore_hook import RestoreHook
|
from hooks.restore_hook import RestoreHook
|
||||||
from hooks.unread_hook import UnreadHook
|
from hooks.unread_hook import UnreadHook
|
||||||
@@ -40,6 +41,7 @@ class ChatSummariesPlugin(BasePlugin):
|
|||||||
self.unread_hook = UnreadHook(self)
|
self.unread_hook = UnreadHook(self)
|
||||||
self.restore_hook = RestoreHook(self)
|
self.restore_hook = RestoreHook(self)
|
||||||
self.delete_hook = DeleteHook(self)
|
self.delete_hook = DeleteHook(self)
|
||||||
|
self.badge_hook = BadgeHook(self)
|
||||||
self.summary_db = None
|
self.summary_db = None
|
||||||
|
|
||||||
def _get_summary_db(self):
|
def _get_summary_db(self):
|
||||||
@@ -90,11 +92,16 @@ class ChatSummariesPlugin(BasePlugin):
|
|||||||
self.delete_hook.install()
|
self.delete_hook.install()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
diagnostics.record_error("on_plugin_load:delete_hook.install", exc)
|
diagnostics.record_error("on_plugin_load:delete_hook.install", exc)
|
||||||
|
try:
|
||||||
|
self.badge_hook.install()
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("on_plugin_load:badge_hook.install", exc)
|
||||||
|
|
||||||
def on_plugin_unload(self) -> None:
|
def on_plugin_unload(self) -> None:
|
||||||
self.unread_hook.uninstall()
|
self.unread_hook.uninstall()
|
||||||
self.restore_hook.uninstall()
|
self.restore_hook.uninstall()
|
||||||
self.delete_hook.uninstall()
|
self.delete_hook.uninstall()
|
||||||
|
self.badge_hook.uninstall()
|
||||||
log("chat_summaries unloaded")
|
log("chat_summaries unloaded")
|
||||||
|
|
||||||
def create_settings(self):
|
def create_settings(self):
|
||||||
@@ -274,6 +281,9 @@ class ChatSummariesPlugin(BasePlugin):
|
|||||||
bulletin and leaves the chat exactly as the user left it: no error
|
bulletin and leaves the chat exactly as the user left it: no error
|
||||||
bubble to dismiss, no jump away from where they were reading.
|
bubble to dismiss, no jump away from where they were reading.
|
||||||
"""
|
"""
|
||||||
|
diagnostics.note(
|
||||||
|
f"summary: dialog={dialog_id} topic={topic_id} count={count} offset={offset}"
|
||||||
|
)
|
||||||
BulletinHelper.show_info(f"Chat Summaries: reading {count} messages…", fragment)
|
BulletinHelper.show_info(f"Chat Summaries: reading {count} messages…", fragment)
|
||||||
|
|
||||||
def on_fetched(messages, error):
|
def on_fetched(messages, error):
|
||||||
@@ -292,6 +302,7 @@ class ChatSummariesPlugin(BasePlugin):
|
|||||||
self.fetcher.fetch_history(dialog_id, count, offset, topic_id, on_fetched)
|
self.fetcher.fetch_history(dialog_id, count, offset, topic_id, on_fetched)
|
||||||
|
|
||||||
def _report_error(self, message: str, fragment: Any) -> None:
|
def _report_error(self, message: str, fragment: Any) -> None:
|
||||||
|
diagnostics.note(f"summary failed: {message}")
|
||||||
BulletinHelper.show_error(f"Chat Summaries: {message}", fragment)
|
BulletinHelper.show_error(f"Chat Summaries: {message}", fragment)
|
||||||
|
|
||||||
def _stream_into(
|
def _stream_into(
|
||||||
@@ -310,7 +321,11 @@ class ChatSummariesPlugin(BasePlugin):
|
|||||||
"""
|
"""
|
||||||
from services.rich_injector import SummaryInjection, virtual_id_for
|
from services.rich_injector import SummaryInjection, virtual_id_for
|
||||||
|
|
||||||
anchor_msg_id = max(int(getattr(m, "id", 0) or 0) for m in messages)
|
ids = [int(getattr(m, "id", 0) or 0) for m in messages]
|
||||||
|
anchor_msg_id = max(ids)
|
||||||
|
diagnostics.note(
|
||||||
|
f"summary: fetched {len(messages)} messages, ids {min(ids)}..{anchor_msg_id}"
|
||||||
|
)
|
||||||
db = self._get_summary_db()
|
db = self._get_summary_db()
|
||||||
state = {
|
state = {
|
||||||
"answer": "",
|
"answer": "",
|
||||||
|
|||||||
@@ -16,7 +16,13 @@ Verified against exteraGram 12.5.x (dex inspection):
|
|||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
import diagnostics
|
import diagnostics
|
||||||
from config import PLACEMENT_RETRIES, PLACEMENT_RETRY_DELAY_MS, SUMMARY_HASHTAG
|
from config import (
|
||||||
|
JUMP_SETTLE_DELAYS_MS,
|
||||||
|
PLACEMENT_RELOAD_EVERY,
|
||||||
|
PLACEMENT_RETRIES,
|
||||||
|
PLACEMENT_RETRY_DELAY_MS,
|
||||||
|
SUMMARY_HASHTAG,
|
||||||
|
)
|
||||||
from services.rich_markdown import build_rich_message, hashtag_block, markdown_to_plain
|
from services.rich_markdown import build_rich_message, hashtag_block, markdown_to_plain
|
||||||
|
|
||||||
# Above Telegram's issued id range, so this user exists only locally.
|
# Above Telegram's issued id range, so this user exists only locally.
|
||||||
@@ -379,10 +385,18 @@ class SummaryInjection:
|
|||||||
else:
|
else:
|
||||||
index = find_index_for_anchor(messages_list, self.anchor_msg_id)
|
index = find_index_for_anchor(messages_list, self.anchor_msg_id)
|
||||||
if index < 0:
|
if index < 0:
|
||||||
if retries_left == PLACEMENT_RETRIES:
|
attempt = PLACEMENT_RETRIES - retries_left
|
||||||
|
if retries_left > 0 and attempt % PLACEMENT_RELOAD_EVERY == 0:
|
||||||
# The analyzed range ends outside the loaded window
|
# The analyzed range ends outside the loaded window
|
||||||
# (an offset, or history scrolled away). Have the chat
|
# (an offset, or history scrolled away). Have the chat
|
||||||
# load and show it, then place once it is in the list.
|
# load and show it, then place once it is in the list.
|
||||||
|
# Asked again periodically: scrollToMessageId drops the
|
||||||
|
# request outright while waitingForGetDifference, and
|
||||||
|
# a load can be superseded by another one.
|
||||||
|
diagnostics.note(
|
||||||
|
f"injector: anchor {self.anchor_msg_id} not loaded "
|
||||||
|
f"({_id_span(messages_list)}), requesting it (attempt {attempt})"
|
||||||
|
)
|
||||||
self._load_anchor()
|
self._load_anchor()
|
||||||
if retries_left > 0:
|
if retries_left > 0:
|
||||||
_on_ui(lambda: self._place(retries_left - 1), PLACEMENT_RETRY_DELAY_MS)
|
_on_ui(lambda: self._place(retries_left - 1), PLACEMENT_RETRY_DELAY_MS)
|
||||||
@@ -398,16 +412,87 @@ class SummaryInjection:
|
|||||||
messages_list.set(index, self.message_object)
|
messages_list.set(index, self.message_object)
|
||||||
self.placed = True
|
self.placed = True
|
||||||
self._placing = False
|
self._placing = False
|
||||||
|
diagnostics.note(
|
||||||
|
f"injector: card placed after anchor {self.anchor_msg_id} at index {index} "
|
||||||
|
f"(retries left {retries_left})"
|
||||||
|
)
|
||||||
adapter = get_chat_adapter(self.chat_activity)
|
adapter = get_chat_adapter(self.chat_activity)
|
||||||
if adapter is not None:
|
if adapter is not None:
|
||||||
adapter.notifyDataSetChanged()
|
adapter.notifyDataSetChanged()
|
||||||
# Once, on placement only -- scrolling on every streaming update
|
# On placement only -- scrolling on every streaming update would
|
||||||
# would fight the user for control of the list.
|
# fight the user for control of the list. The settle checks right
|
||||||
self.scroll_into_view()
|
# after only undo scrolls the app itself makes in that window.
|
||||||
|
if self.scroll_into_view():
|
||||||
|
self._schedule_settle(0)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._placing = False
|
self._placing = False
|
||||||
diagnostics.record_error("injector.place", exc)
|
diagnostics.record_error("injector.place", exc)
|
||||||
|
|
||||||
|
def _schedule_settle(self, step: int) -> None:
|
||||||
|
if step >= len(JUMP_SETTLE_DELAYS_MS):
|
||||||
|
return
|
||||||
|
previous = JUMP_SETTLE_DELAYS_MS[step - 1] if step else 0
|
||||||
|
_on_ui(lambda: self._settle(step), JUMP_SETTLE_DELAYS_MS[step] - previous)
|
||||||
|
|
||||||
|
def _settle(self, step: int) -> None:
|
||||||
|
"""Re-jumps if something scrolled the fresh card off screen.
|
||||||
|
|
||||||
|
Stops for good as soon as the user touches the list: from then on
|
||||||
|
where the chat is scrolled is theirs to decide.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
list_view = _chat_list_view(self.chat_activity)
|
||||||
|
if list_view is not None and int(list_view.getScrollState()) == 1: # SCROLL_STATE_DRAGGING
|
||||||
|
diagnostics.note("injector: user is scrolling, jump settled")
|
||||||
|
return
|
||||||
|
span = self._visible_span()
|
||||||
|
if span is not None:
|
||||||
|
position, first, last = span
|
||||||
|
if not first <= position <= last:
|
||||||
|
diagnostics.note(
|
||||||
|
f"injector: card row {position} scrolled away (visible {first}..{last}), "
|
||||||
|
f"jumping back (check {step})"
|
||||||
|
)
|
||||||
|
self._jump_to_card()
|
||||||
|
elif self.placed and find_existing(get_messages_list(self.chat_activity), self.virtual_id) < 0:
|
||||||
|
# The list was reloaded under the card. Put it back.
|
||||||
|
messages_list = get_messages_list(self.chat_activity)
|
||||||
|
index = find_index_for_anchor(messages_list, self.anchor_msg_id)
|
||||||
|
if index >= 0:
|
||||||
|
diagnostics.note(f"injector: list reloaded, re-placing card (check {step})")
|
||||||
|
messages_list.add(index, self.message_object)
|
||||||
|
adapter = get_chat_adapter(self.chat_activity)
|
||||||
|
if adapter is not None:
|
||||||
|
adapter.notifyDataSetChanged()
|
||||||
|
self._jump_to_card()
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("injector.settle", exc)
|
||||||
|
self._schedule_settle(step + 1)
|
||||||
|
|
||||||
|
def _visible_span(self) -> Optional[tuple]:
|
||||||
|
"""(card adapter position, first visible, last visible), or None."""
|
||||||
|
try:
|
||||||
|
from hook_utils import get_private_field
|
||||||
|
|
||||||
|
messages_list = get_messages_list(self.chat_activity)
|
||||||
|
adapter = get_chat_adapter(self.chat_activity)
|
||||||
|
layout_manager = getattr(self.chat_activity, "chatLayoutManager", None) or get_private_field(
|
||||||
|
self.chat_activity, "chatLayoutManager"
|
||||||
|
)
|
||||||
|
if messages_list is None or adapter is None or layout_manager is None:
|
||||||
|
return None
|
||||||
|
index = find_existing(messages_list, self.virtual_id)
|
||||||
|
if index < 0:
|
||||||
|
return None
|
||||||
|
first = int(layout_manager.findFirstVisibleItemPosition())
|
||||||
|
last = int(layout_manager.findLastVisibleItemPosition())
|
||||||
|
if first < 0 or last < 0:
|
||||||
|
return None
|
||||||
|
return int(adapter.messagesStartRow) + index, min(first, last), max(first, last)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("injector.visible_span", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
def _load_anchor(self) -> None:
|
def _load_anchor(self) -> None:
|
||||||
"""Asks the chat to load the anchor and scroll to it.
|
"""Asks the chat to load the anchor and scroll to it.
|
||||||
|
|
||||||
@@ -464,6 +549,7 @@ class SummaryInjection:
|
|||||||
# bottom=False: offset counts from the top edge, as in
|
# bottom=False: offset counts from the top edge, as in
|
||||||
# ChatActivity.scrollToMessageId's own scrollToPosition call.
|
# ChatActivity.scrollToMessageId's own scrollToPosition call.
|
||||||
layout_manager.scrollToPositionWithOffset(position, offset, False)
|
layout_manager.scrollToPositionWithOffset(position, offset, False)
|
||||||
|
diagnostics.note(f"injector: jump to row {position} offset {offset}")
|
||||||
return True
|
return True
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
diagnostics.record_error("injector.jump_to_card", exc)
|
diagnostics.record_error("injector.jump_to_card", exc)
|
||||||
@@ -583,6 +669,21 @@ def _chat_list_view(chat_activity: Any) -> Optional[Any]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _id_span(messages_list: Any) -> str:
|
||||||
|
"""'loaded ids A..B, N rows' for diagnostics."""
|
||||||
|
try:
|
||||||
|
ids = [
|
||||||
|
int(getattr(getattr(messages_list.get(i), "messageOwner", None), "id", 0) or 0)
|
||||||
|
for i in range(messages_list.size())
|
||||||
|
]
|
||||||
|
real = [i for i in ids if i > 0]
|
||||||
|
if not real:
|
||||||
|
return f"no messages, {len(ids)} rows"
|
||||||
|
return f"loaded ids {min(real)}..{max(real)}, {len(ids)} rows"
|
||||||
|
except Exception:
|
||||||
|
return "loaded ids unknown"
|
||||||
|
|
||||||
|
|
||||||
def remove_cards(chat_activity: Any, virtual_ids) -> None:
|
def remove_cards(chat_activity: Any, virtual_ids) -> None:
|
||||||
"""Drops injected cards from an open chat's list.
|
"""Drops injected cards from an open chat's list.
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
+88
-2
@@ -1845,6 +1845,10 @@ def main(argv=None) -> int:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.cells = []
|
self.cells = []
|
||||||
self.stopped = 0
|
self.stopped = 0
|
||||||
|
self.scroll_state = 0
|
||||||
|
|
||||||
|
def getScrollState(self):
|
||||||
|
return self.scroll_state
|
||||||
|
|
||||||
def getMeasuredHeight(self):
|
def getMeasuredHeight(self):
|
||||||
return 900
|
return 900
|
||||||
@@ -1859,11 +1863,30 @@ def main(argv=None) -> int:
|
|||||||
return self.cells[index]
|
return self.cells[index]
|
||||||
|
|
||||||
class _FakeLayoutManager:
|
class _FakeLayoutManager:
|
||||||
|
"""Records jumps and reports the visible row range they produce.
|
||||||
|
|
||||||
|
`hijack` simulates ChatActivity's own postponed scroll landing
|
||||||
|
right after a jump and carrying the view elsewhere.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.jumps = []
|
self.jumps = []
|
||||||
|
self.visible = (-1, -1)
|
||||||
|
self.hijack = 0
|
||||||
|
|
||||||
def scrollToPositionWithOffset(self, position, offset, bottom):
|
def scrollToPositionWithOffset(self, position, offset, bottom):
|
||||||
self.jumps.append((position, offset, bottom))
|
self.jumps.append((position, offset, bottom))
|
||||||
|
if self.hijack:
|
||||||
|
self.hijack -= 1
|
||||||
|
self.visible = (40, 46)
|
||||||
|
else:
|
||||||
|
self.visible = (max(0, position - 3), position + 3)
|
||||||
|
|
||||||
|
def findFirstVisibleItemPosition(self):
|
||||||
|
return self.visible[0]
|
||||||
|
|
||||||
|
def findLastVisibleItemPosition(self):
|
||||||
|
return self.visible[1]
|
||||||
|
|
||||||
class _FakeChatActivity:
|
class _FakeChatActivity:
|
||||||
def __init__(self, ids):
|
def __init__(self, ids):
|
||||||
@@ -2088,6 +2111,64 @@ def main(argv=None) -> int:
|
|||||||
f"ids={far_ids}",
|
f"ids={far_ids}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Loading a far anchor makes ChatActivity run its own postponed scroll,
|
||||||
|
# which can land after the jump and carry the view away from the card.
|
||||||
|
# The settle checks must bring it back.
|
||||||
|
hijacked = _FakeChatActivity([900, 800])
|
||||||
|
hijacked.loadable[300] = [310, 300, 290]
|
||||||
|
hijacked.chatLayoutManager.hijack = 1
|
||||||
|
hijacked_card = injector.virtual_id_for(7, 300)
|
||||||
|
injector.SummaryInjection(hijacked, 0, -4242, 0, 300, hijacked_card).insert(
|
||||||
|
thinking="chunk", streaming=True
|
||||||
|
)
|
||||||
|
card_row = 2 + hijacked.ids().index(hijacked_card)
|
||||||
|
first, last = hijacked.chatLayoutManager.visible
|
||||||
|
check(
|
||||||
|
"a jump overridden by the app's own scroll is redone until the card is on screen",
|
||||||
|
len(hijacked.chatLayoutManager.jumps) == 2 and first <= card_row <= last,
|
||||||
|
f"jumps={hijacked.chatLayoutManager.jumps} visible={(first, last)} row={card_row}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Once the user takes hold of the list, the plugin stops steering it.
|
||||||
|
dragged = _FakeChatActivity([600, 555, 500])
|
||||||
|
dragged.chatLayoutManager.hijack = 1
|
||||||
|
dragged.chatListView.scroll_state = 1
|
||||||
|
injector.SummaryInjection(dragged, 0, -4242, 0, 555, injector.virtual_id_for(8, 555)).insert(
|
||||||
|
thinking="chunk", streaming=True
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"no re-jump while the user is dragging the list",
|
||||||
|
len(dragged.chatLayoutManager.jumps) == 1,
|
||||||
|
f"jumps={dragged.chatLayoutManager.jumps}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# A cache-then-server reload can replace the list under a fresh card.
|
||||||
|
reloaded = _FakeChatActivity([600, 555, 500])
|
||||||
|
reloaded_card = injector.virtual_id_for(9, 555)
|
||||||
|
reloaded_inj = injector.SummaryInjection(reloaded, 0, -4242, 0, 555, reloaded_card)
|
||||||
|
real_on_ui_for_reload = injector._on_ui
|
||||||
|
dropped = []
|
||||||
|
|
||||||
|
def _reloading_on_ui(fn, delay_ms=0):
|
||||||
|
if delay_ms and not dropped:
|
||||||
|
# First settle check: the chat reloads its window first.
|
||||||
|
reloaded.messages[:] = [m for m in reloaded.messages if m.messageOwner.id > 0]
|
||||||
|
dropped.append(True)
|
||||||
|
fn()
|
||||||
|
|
||||||
|
injector._on_ui = _reloading_on_ui
|
||||||
|
try:
|
||||||
|
reloaded_inj.insert(thinking="chunk", streaming=True)
|
||||||
|
finally:
|
||||||
|
injector._on_ui = real_on_ui_for_reload
|
||||||
|
reloaded_ids = reloaded.ids()
|
||||||
|
check(
|
||||||
|
"a card dropped by a list reload right after placement is put back",
|
||||||
|
dropped and reloaded_ids.count(reloaded_card) == 1
|
||||||
|
and reloaded_ids.index(reloaded_card) == reloaded_ids.index(555) - 1,
|
||||||
|
f"ids={reloaded_ids}",
|
||||||
|
)
|
||||||
|
|
||||||
# An anchor that never loads: no card anywhere, rather than a card
|
# An anchor that never loads: no card anywhere, rather than a card
|
||||||
# attached to messages it did not analyze.
|
# attached to messages it did not analyze.
|
||||||
never = _FakeChatActivity([900, 800])
|
never = _FakeChatActivity([900, 800])
|
||||||
@@ -2097,8 +2178,13 @@ def main(argv=None) -> int:
|
|||||||
never_inj.update(answer_markdown="more", streaming=True)
|
never_inj.update(answer_markdown="more", streaming=True)
|
||||||
check(
|
check(
|
||||||
"an anchor that never loads places no card at a wrong position",
|
"an anchor that never loads places no card at a wrong position",
|
||||||
never_card not in never.ids() and not never_inj.placed and never.scrolled_to == [300],
|
never_card not in never.ids() and not never_inj.placed,
|
||||||
f"ids={never.ids()} scrolled_to={never.scrolled_to}",
|
f"ids={never.ids()}",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"a far anchor that does not arrive is requested again, not just once",
|
||||||
|
len(never.scrolled_to) >= 2 and set(never.scrolled_to) == {300},
|
||||||
|
f"scrolled_to={never.scrolled_to}",
|
||||||
)
|
)
|
||||||
# ...and the card shows up once the user scrolls the anchor in.
|
# ...and the card shows up once the user scrolls the anchor in.
|
||||||
for mid in (310, 300):
|
for mid in (310, 300):
|
||||||
|
|||||||
Reference in new issue
Block a user