# 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. ## What the plugin does **Summarize** (⋮ → Plugins → Summarize, or hold the unread button) runs in this order: 1. A bulletin confirms the press, then the history is fetched. 2. The transcript streams to the configured OpenAI-compatible endpoint. 3. Only when the first chunk arrives is a summary card injected, directly after the newest analyzed message. If that message is not loaded (an offset, or history scrolled away), the chat is asked to load it first. The chat then jumps to the card once. 4. Reasoning and answer stream into the card. The finished summary ends with a native `#aisummaries` hashtag and is saved to a local SQLite store, from which it is restored whenever the chat is reopened. Any failure before step 3 (fetch error, empty history, unreachable host, bad key, HTTP error) is reported as a bulletin only: the chat is not modified and the view does not move. A failure mid-stream is written onto the card, and a summary saved earlier for the same range is kept. Deleting a card like any other message removes its saved row. The settings page shows how many summaries are saved and roughly how much space they take, and offers **Clear all summaries** (with a confirmation step). Summaries exist only on this device, so Telegram's own search, which runs on the server, does not find them, including a search for `#aisummaries`. ## 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, forgetting a summary when its card is deleted) ui/ summarize dialog, bulletin and theme helpers 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/ ADB debug-loop automation and the git hooks (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`. Its first run in a clone also enables the repo's git hooks (see [Git hooks and versioning](#git-hooks-and-versioning)). ## 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. ## Git hooks and versioning `scripts/pre-commit` bumps the patch number of `version` in `plugin.json` on every commit (`1.0.0` → `1.0.1`) and stages it into that same commit. `scripts/post-commit` re-stages `plugin.json` afterwards, because commits of a file selection (`git commit -- `, which some IDEs use) are built from a temporary index and would otherwise leave it looking modified. **The hooks enable themselves.** Git never activates hooks from repository content, so a fresh clone, or a repo created from this template, starts without them. The first run of `tools/bundle.py` fixes that by running `git config core.hooksPath scripts`, and every workflow runs it: directly, or through `scripts/deploy.sh` and `scripts/watch.sh`. That includes `--check`, so after cloning run ```bash python3 tools/bundle.py --check ``` once, before your first commit. A commit made before any build runs without the hooks. `bundle.py` leaves `core.hooksPath` alone when it is already set (so pointing it elsewhere on purpose sticks), skips the step in CI (whenever `CI` is set) and outside a git work tree, and never fails a build over it. To turn the hooks off for a clone, run `git config core.hooksPath .git/hooks`; `git config --unset core.hooksPath` only lasts until the next build. The bump is skipped when: - the commit message contains `[no bump]`, `[skip bump]`, `[no version bump]` or `[skip version]` (case-insensitive; `nobump`, `no-bump` and `no_bump` also work), - `NO_BUMP=1` (or `GIT_NO_BUMP=1`) is set, e.g. `NO_BUMP=1 git commit`, which is the only way for commits written in an editor, since their message doesn't exist yet when the hook runs, - `version` in `plugin.json` already differs from `HEAD` (you bumped it by hand, e.g. for a minor or major release), - a merge, rebase, cherry-pick or revert is in progress. Because every version change publishes a release (see below), a normal commit + push to `main` ships a new patch release. Use a stop word for commits that should not. ## 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` 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. With the git hooks enabled every commit bumps it, so every push of a normal commit cuts a new patch 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. Run `python3 tools/bundle.py --check` once. It enables the git hooks that auto-bump the version on commit (see [Git hooks and versioning](#git-hooks-and-versioning)). 2. Edit `plugin.json` (id, name, description, etc.). 3. 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). 4. `python3 tools/bundle.py --release && python3 tools/verify_bundle.py dist/.plugin` (substitute your `id` from `plugin.json`). 5. `bash scripts/deploy.sh --enable` with a device connected.