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.
exteraGram Plugin Base
A build toolchain and ADB automation for writing exteraGram 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
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 thetcp:42690port forward (andtcp:5678with--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…]— watchessrc/andplugin.json, redeploys on change (usesinotifywaitif available, otherwise polls).scripts/logs.sh [-c] [-t] [-g PATTERN]— streams logcat for the running app (-tfor the raw Chaquopy stdio tags,-gto grep,-cto 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. Requiresdebugpy(vscode) orpydevd-pycharm(pycharm) present in the app's Python runtime — add it torequirementsinplugin.jsonif it's missing.scripts/fetch-sdk-stubs.sh [--beta]— downloads the latest SDK type stubs intostubs/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
- Edit
plugin.json(id, name, description, etc.). - Write your plugin under
src/, withsrc/main.pyas the entry module (absolute imports only — it's executed at plugin-module top level, not inside a package). python3 tools/bundle.py --release && python3 tools/verify_bundle.py dist/<id>.plugin(substitute youridfromplugin.json).bash scripts/deploy.sh --enablewith a device connected.