3 Commits
Author SHA1 Message Date
lyuksovannyy 7b3a4bece3 Correct the unread badge after jumping away from the newest messages
Publish plugin / publish (push) Successful in 3s
2026-10-12 19:27:44 +02:00
lyuksovannyyandClaude Opus 5.5 f58e157b70 [no bump] Shift plugin icon index down by one
Publish plugin / publish (push) Successful in 4s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:20:24 +02:00
lyuksovannyyandClaude Opus 5.5 b885f2179f [no bump] Move technical docs to docs/, add icon and PNG export tool
Publish plugin / publish (push) Successful in 4s
README now carries the icon, TL;DR, description and features; the
technical sections live in docs/. tools/icon_png.py renders
images/icon.svg to a square PNG (512x512 by default, --resolution N).
plugin.json points at the plugin's own icon.

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

No files matched your search

+35 -210
View File
@@ -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.
+70
View File
@@ -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.
+32
View File
@@ -0,0 +1,32 @@
# Device setup and ADB automation
## Device prerequisites
- exteraGram 12.5.1+.
- In-app: Preferences → Plugins → enable the plugin engine, then enable developer mode.
- USB debugging enabled, device authorized (`adb devices`).
Release-build APKs disallow `adb run-as`, so you cannot `adb push` a file straight into the
app's private plugin directory. The on-device **dev server** (`tools/devclient.py`,
`scripts/deploy.sh`) is the actual install path — it talks to the running app over a TCP
socket forwarded with `adb forward`, and the app writes the file into its own storage.
## ADB automation
All scripts live in `scripts/`, are runnable from any working directory, and honor the
standard `ANDROID_SERIAL` env var for device selection.
- `scripts/adb-connect.sh [--debug]` — starts adb, waits for the device, (re)creates the
`tcp:42690` port forward (and `tcp:5678` with `--debug`), pings the dev server.
- `scripts/deploy.sh [--release] [--enable] [--no-connect]` — bundles, connects, writes the
plugin to the device, optionally enables it, reloads it.
- `scripts/watch.sh [deploy args…]` — watches `src/` and `plugin.json`, redeploys on change
(uses `inotifywait` if available, otherwise polls).
- `scripts/logs.sh [-c] [-t] [-g PATTERN]` — streams logcat for the running app (`-t` for the
raw Chaquopy stdio tags, `-g` to grep, `-c` to clear first).
- `scripts/debug.sh start|stop [--platform vscode|pycharm]` — forwards the debugger port and
tells the dev server to start/stop the in-app debugger. Requires `debugpy` (vscode) or
`pydevd-pycharm` (pycharm) present in the app's Python runtime — add it to `requirements` in
`plugin.json` if it's missing.
- `scripts/fetch-sdk-stubs.sh [--beta]` — downloads the latest SDK type stubs into `stubs/`
for editor autocomplete.
+26
View File
@@ -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`.
+26
View File
@@ -0,0 +1,26 @@
# How exteraGram plugins work
## How exteraGram plugins actually work
A plugin, as exteraGram loads it, is exactly **one Python file** on the device:
`/data/user/0/com.exteragram.messenger/files/plugins/<plugin_id>.py`. The distribution
convention names the same content `<id>.plugin`.
The app reads plugin metadata **statically via AST** — it looks for top-level constant
assignments (`__id__`, `__name__`, `__description__`, `__author__`, `__version__`,
`__icon__`, `__app_version__`, `__sdk_version__`, `__requirements__`) without executing the
file. Only `__id__` and `__name__` are required.
The file must define exactly one class inheriting `BasePlugin` (`from base_plugin import
BasePlugin`). Lifecycle methods (`on_plugin_load`, `on_plugin_unload`, `create_settings`) and
hooks (`on_send_message_hook`, `pre_request_hook`, `post_request_hook`, `on_update_hook(s)`,
`on_app_event`) are all called by the host; hooks must be registered inside
`on_plugin_load` (e.g. `self.add_on_send_message_hook()`). A hook returns a
`HookResult(strategy=HookStrategy.MODIFY, params=params)` to apply changes, or a bare
`HookResult()` to pass through. Settings rows returned from `create_settings` come from
`ui.settings` (`Header`, `Input`, `Switch`, `Text`, `Selector`, `Divider`) and persist through
`self.get_setting`/`self.set_setting`. Logging goes through `android_utils.log`; Chaquopy also
redirects `sys.stdout`/`sys.stderr` to the logcat tags `python.stdout`/`python.stderr`.
This repo does not use the official Elyx/ElyxBuilder archive format — see
[`sdk-notes.md`](sdk-notes.md) for why.
+83
View File
@@ -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.
+51
View File
@@ -0,0 +1,51 @@
<svg xmlns="http://www.w3.org/2000/svg" width="640" height="640" viewBox="0 0 640 640">
<defs>
<linearGradient id="bg" x1="0.3" y1="0" x2="0.7" y2="1">
<stop offset="0" stop-color="#F89B2E"/>
<stop offset="0.5" stop-color="#F7724A"/>
<stop offset="1" stop-color="#F5517A"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.45" r="0.5">
<stop offset="0" stop-color="#FF5A3C" stop-opacity="0.18"/>
<stop offset="1" stop-color="#FF5A3C" stop-opacity="0"/>
</radialGradient>
<linearGradient id="paper" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#F7F7F7"/>
<stop offset="1" stop-color="#E2E2E2"/>
</linearGradient>
<linearGradient id="star" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFE27A"/>
<stop offset="1" stop-color="#FFC53D"/>
</linearGradient>
<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

+2 -2
View File
@@ -3,8 +3,8 @@
"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.1", "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": [ "requirements": [
+276
View File
@@ -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()
+7
View File
@@ -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):
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Renders images/icon.svg to a square PNG (512x512 by default).
Stdlib only: the actual rasterising is delegated to the first renderer found,
in this order: the cairosvg Python package, rsvg-convert (librsvg),
ImageMagick (magick/convert), Inkscape. Install any one of them.
Usage:
tools/icon_png.py # images/icon.svg -> images/icon.png, 512x512
tools/icon_png.py --resolution 1024 # 1024x1024
tools/icon_png.py other.svg -o out.png # explicit input/output
The output is always PNG, always <resolution>x<resolution>.
"""
from __future__ import annotations
import argparse
import shutil
import subprocess
import sys
from pathlib import Path
from typing import List, Optional
REPO_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_SVG = REPO_ROOT / "images" / "icon.svg"
DEFAULT_RESOLUTION = 512
def fail(message: str) -> "None":
print(f"error: {message}", file=sys.stderr)
raise SystemExit(1)
def cli_command(svg: Path, png: Path, size: int) -> Optional[List[str]]:
"""Returns the command line for the first installed CLI renderer, if any."""
if shutil.which("rsvg-convert"):
return ["rsvg-convert", "-w", str(size), "-h", str(size), "-f", "png",
"-o", str(png), str(svg)]
magick = shutil.which("magick") or shutil.which("convert")
if magick:
# A high density rasterises the vector sharply before scaling down;
# "!" forces the exact size.
return [magick, "-background", "none", "-density", "1200", str(svg),
"-resize", f"{size}x{size}!", f"png:{png}"]
if shutil.which("inkscape"):
return ["inkscape", str(svg), "--export-type=png", f"--export-filename={png}",
"-w", str(size), "-h", str(size)]
return None
def render(svg: Path, png: Path, size: int) -> str:
"""Renders svg into png at size x size, returning the renderer's name."""
try:
import cairosvg # type: ignore[import-not-found]
except ImportError:
pass
else:
cairosvg.svg2png(url=str(svg), write_to=str(png),
output_width=size, output_height=size)
return "cairosvg"
command = cli_command(svg, png, size)
if command is None:
fail("no SVG renderer found; install one of: cairosvg (pip), "
"rsvg-convert (librsvg), ImageMagick, Inkscape")
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode != 0:
fail(f"{Path(command[0]).name} failed:\n{result.stderr.strip()}")
return Path(command[0]).name
def main() -> None:
parser = argparse.ArgumentParser(description="Render an SVG icon to a square PNG.")
parser.add_argument("svg", nargs="?", type=Path, default=DEFAULT_SVG,
help="input SVG (default: images/icon.svg)")
parser.add_argument("-r", "--resolution", type=int, default=DEFAULT_RESOLUTION,
help=f"width and height in pixels (default: {DEFAULT_RESOLUTION})")
parser.add_argument("-o", "--output", type=Path,
help="output PNG (default: the input path with a .png suffix)")
args = parser.parse_args()
if args.resolution <= 0:
fail("--resolution must be a positive integer")
if not args.svg.is_file():
fail(f"{args.svg} not found")
png = args.output or args.svg.with_suffix(".png")
if png.suffix.lower() != ".png":
fail(f"output must be a .png file, got {png}")
png.parent.mkdir(parents=True, exist_ok=True)
renderer = render(args.svg, png, args.resolution)
print(f"{png} ({args.resolution}x{args.resolution}, via {renderer})")
if __name__ == "__main__":
main()