Date separators are local MessageObjects that live in the same ChatActivity.messages list as real messages, and ChatActivity builds them with messageOwner.id left at 0 (their stable ids come from a separate dateObjectsStableIds space, so they never collide with injected cards). find_index_for_anchor scanned for an exact id match without requiring a positive id, so looking up anchor 0 returned the index of the first separator and would have placed a card against a divider rather than a message. loaded_message_ids had the matching flaw: it recorded the separators' 0, which made a summary with anchor id 0 look restorable. Reject non-positive anchors outright and keep separators out of the loaded id set. Every real chat interleaves separators, so the harness now models them: they must not be picked as the insertion point, must not satisfy an anchor-loaded check, and summaries anchored on real messages must still restore correctly with separators in between.
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.
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
- 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.