generated from exteraGramPlugins/pluginBase
Publish plugin / publish (push) Successful in 4s
Trigger the release workflow on pushes to main, not just on v* tags, so every push leaves a downloadable .plugin attached to the current version. The version in plugin.json decides what happens: a new version creates tag v<version> and a new release, while an unchanged version rebuilds the existing release and replaces its attachment. The tag is never moved, so it keeps pointing at the commit that first introduced that version, but the release notes are refreshed to name the commit the build came from. Hand-pushed v* tags and manual runs are unaffected and still use the tag verbatim.
142 lines
8.0 KiB
Markdown
142 lines
8.0 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 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. 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.
|
|
|
|
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.
|