Files
chatSummaries/README.md
T
lyuksovannyy 196da742de Implement Chat Summaries plugin
Replace the sample plugin with an AI chat summarizer that renders its
output as a native RichMessage injected into the chat.

Summarization pipeline:
- Fetch chat/topic history via paginated TL requests.
- Send a system prompt, the user's instructions and an anonymized JSON
  transcript as three separate roles. Only message text and attachment
  names leave the device; senders and message ids are replaced by
  indices, and out-of-range reply parents are included with negative ids
  so replies keep their context.
- Stream the response over SSE, reading reasoning from reasoning_content,
  reasoning or inline <think> tags.
- Substitute the model's {userN}/{msgN} placeholders back into real names
  and deep links. Placeholders are used instead of Markdown links because
  a half-streamed link renders as broken literal text.

Rendering:
- Compile Markdown into TL_iv page blocks (headings, lists, quotes, code,
  dividers, inline styles) since the app's own converter works off
  Android Spanned spans rather than Markdown source.
- Show progress stages immediately on click, animate reasoning while it
  streams, then collapse it into a tappable details block.
- Attribute the card to a client-side mock sender and insert it directly
  after the newest analyzed message, scrolling it into view.

Persistence:
- Store summaries in SQLite keyed by (dialog, topic, anchor) and
  re-inject them when the chat is reopened.

Also add tools/release.py and a Gitea workflow that builds, verifies and
publishes dist/<id>.plugin to the Releases tab, and extend
tools/verify_bundle.py to 104 offline checks covering the transcript,
streaming, rendering, injection and storage behaviour.
2026-09-12 23:52:15 +02:00

7.6 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
  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 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. dist/ is gitignored, so the artifact is always built by CI rather than committed.

# tag a version and push it -> release named after the tag
git tag v1.0.1 && git push origin v1.0.1

The workflow also accepts a manual run (Actions → Publish plugin → Run workflow), which derives the tag from version in plugin.json. Either way it runs --check, builds with --release, and gates the upload on verify_bundle.py, so a bundle that fails verification is never published.

Re-running against an existing tag reuses that release and replaces the attachment instead of piling up duplicates.

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

  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/<id>.plugin (substitute your id from plugin.json).
  4. bash scripts/deploy.sh --enable with a device connected.