generated from exteraGramPlugins/pluginBase
Replace the sample plugin with an AI chat summarizer that renders its
output as a native RichMessage injected into the chat.
Summarization pipeline:
- Fetch chat/topic history via paginated TL requests.
- Send a system prompt, the user's instructions and an anonymized JSON
transcript as three separate roles. Only message text and attachment
names leave the device; senders and message ids are replaced by
indices, and out-of-range reply parents are included with negative ids
so replies keep their context.
- Stream the response over SSE, reading reasoning from reasoning_content,
reasoning or inline <think> tags.
- Substitute the model's {userN}/{msgN} placeholders back into real names
and deep links. Placeholders are used instead of Markdown links because
a half-streamed link renders as broken literal text.
Rendering:
- Compile Markdown into TL_iv page blocks (headings, lists, quotes, code,
dividers, inline styles) since the app's own converter works off
Android Spanned spans rather than Markdown source.
- Show progress stages immediately on click, animate reasoning while it
streams, then collapse it into a tappable details block.
- Attribute the card to a client-side mock sender and insert it directly
after the newest analyzed message, scrolling it into view.
Persistence:
- Store summaries in SQLite keyed by (dialog, topic, anchor) and
re-inject them when the chat is reopened.
Also add tools/release.py and a Gitea workflow that builds, verifies and
publishes dist/<id>.plugin to the Releases tab, and extend
tools/verify_bundle.py to 104 offline checks covering the transcript,
streaming, rendering, injection and storage behaviour.
138 lines
7.6 KiB
Markdown
138 lines
7.6 KiB
Markdown
# exteraGram Plugin Base
|
|
|
|
A build toolchain and ADB automation for writing [exteraGram](https://exteragram.app) plugins
|
|
as ordinary multi-file Python projects instead of hand-maintaining one giant `.py` file.
|
|
|
|
## 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
|
|
`docs/sdk-notes.md` for why.
|
|
|
|
## Repo layout
|
|
|
|
```
|
|
plugin.json single source of truth for plugin metadata
|
|
src/ the plugin, written as an ordinary Python package
|
|
main.py entry module — exactly one BasePlugin subclass, absolute imports only
|
|
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)
|
|
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/ bash automation for the ADB debug loop (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`.
|
|
|
|
## 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.
|
|
|
|
## Publishing a release
|
|
|
|
`.gitea/workflows/release.yml` builds, verifies and publishes the `.plugin` to the repo's
|
|
Releases tab. `dist/` is gitignored, so the artifact is always built by CI rather than
|
|
committed.
|
|
|
|
```bash
|
|
# tag a version and push it -> release named after the tag
|
|
git tag v1.0.1 && git push origin v1.0.1
|
|
```
|
|
|
|
The workflow also accepts a manual run (Actions → Publish plugin → Run workflow), which
|
|
derives the tag from `version` in `plugin.json`. Either way it runs `--check`, builds with
|
|
`--release`, and gates the upload on `verify_bundle.py`, so a bundle that fails verification
|
|
is never published.
|
|
|
|
Re-running against an existing tag reuses that release and replaces the attachment instead of
|
|
piling up duplicates.
|
|
|
|
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.
|
|
|
|
If you would rather not manage a token, Gitea also injects a per-job `secrets.GITEA_TOKEN`
|
|
that can create releases in its own repo — swap the `env:` line in the workflow 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. Edit `plugin.json` (id, name, description, etc.).
|
|
2. 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).
|
|
3. `python3 tools/bundle.py --release && python3 tools/verify_bundle.py dist/<id>.plugin`
|
|
(substitute your `id` from `plugin.json`).
|
|
4. `bash scripts/deploy.sh --enable` with a device connected.
|