# 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/.py`. The distribution convention names the same content `.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/.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/.plugin python3 tools/verify_bundle.py dist/.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. 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` 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. 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. 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/.plugin` (substitute your `id` from `plugin.json`). 4. `bash scripts/deploy.sh --enable` with a device connected.