The publish step set GITEA_TOKEN from the org-level API_TOKEN secret, but the Actions runner injects its own GITEA_TOKEN into every step and that value overrides a step-level env: entry of the same name. The org secret was silently ignored and the job failed. Pass the secret as RELEASE_TOKEN instead, which the runner does not reserve, and read that name in release.py. --token-env makes the source configurable, so the runner-provided GITEA_TOKEN can still be used deliberately by passing --token-env GITEA_TOKEN.
8.1 KiB
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
greeting/ sample package (proves relative + cross-module imports survive bundling)
textutil.py sample top-level sibling module
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. Nothing in the workflow is plugin-specific — it reads the id and
version straight out of plugin.json, so it keeps working after you rename the plugin.
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.
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 and the publish
step would fail on permissions. Use --token-env if you need a different variable name.
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
- 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.