Initial commit

This commit is contained in:
exteraGramPlugins committed 2026-09-12 02:03:47 +00:00
commit a3b0b264c1
27 files changed
+1699

No files matched your search

+179
View File
@@ -0,0 +1,179 @@
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# exteraGram plugin toolchain
/stubs/
/.venv/
+10
View File
@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+34
View File
@@ -0,0 +1,34 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="PyStringConversionWithoutDunderMethodInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredTypes">
<list>
<option value="types.NoneType" />
<option value="_io.TextIOWrapper" />
<option value="str" />
<option value="int" />
<option value="float" />
<option value="complex" />
<option value="set" />
<option value="frozenset" />
<option value="bytes" />
<option value="bytearray" />
<option value="memoryview" />
<option value="slice" />
<option value="list" />
<option value="dict" />
<option value="bool" />
<option value="range" />
<option value="tuple" />
<option value="pathlib.PurePath" />
<option value="uuid.UUID" />
<option value="decimal.Decimal" />
<option value="fractions.Fraction" />
<option value="schemas.token.JWTToken" />
</list>
</option>
</inspection_tool>
</profile>
</component>
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="ASK" />
<option name="description" value="" />
<option name="applicationTheme" value="default" />
<option name="iconsTheme" value="default" />
<option name="button1Title" value="" />
<option name="button1Url" value="" />
<option name="button2Title" value="" />
<option name="button2Url" value="" />
<option name="customApplicationId" value="" />
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/exteraGramPluginBase.iml" filepath="$PROJECT_DIR$/.idea/exteraGramPluginBase.iml" />
</modules>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+15
View File
@@ -0,0 +1,15 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "exteraGram plugin: attach debugpy",
"type": "debugpy",
"request": "attach",
"connect": {
"host": "127.0.0.1",
"port": 5678
},
"justMyCode": false
}
]
}
+18
View File
@@ -0,0 +1,18 @@
MIT License
Copyright (c) 2026 exteraGramPlugins
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
+101
View File
@@ -0,0 +1,101 @@
# 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/<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
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/<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.
## 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.
+114
View File
@@ -0,0 +1,114 @@
# exteraGram plugin SDK — condensed notes
Gathered from <https://plugins.exteragram.app/docs>, the SDK type stubs (`stubs.zip` from
`exteraSquad/plugins-pysdk-builds`), the official `exteragram-utils` PyPI package
(`exteragram_utils/dev_client.py`), and the app's `gradle.properties`. Not exhaustive — see the
full docs site for anything not covered here.
## Plugin identity and metadata
Read statically via AST from top-level constant assignments in the plugin's single `.py` file
— the app never executes the file just to read metadata. Docs:
<https://plugins.exteragram.app/docs/metadata>.
| Constant | Required | Notes |
|---|---|---|
| `__id__` | yes | 2–32 chars, starts with a letter, only `A-Za-z0-9_-` |
| `__name__` | yes | display name |
| `__description__` | no | |
| `__author__` | no | |
| `__version__` | no | |
| `__icon__` | no | e.g. `"exteraPlugins/1"` |
| `__app_version__` | no | e.g. `">=12.5.1"`; legacy alias `__min_version__` |
| `__sdk_version__` | no | e.g. `">=1.4.4.3"` |
| `__requirements__` | no | list of pip package specs the host installs before loading |
## `BasePlugin` lifecycle and hooks
<https://plugins.exteragram.app/docs/base-plugin>
- `on_plugin_load(self)` — called once when the plugin is enabled/loaded. Hooks must be
registered here (e.g. `self.add_on_send_message_hook()`); registering later has no effect.
- `on_plugin_unload(self)` — called on disable/removal; release resources here.
- `create_settings(self)` — returns a list of `ui.settings` rows rendered in the plugin's
settings screen.
- Hooks: `on_send_message_hook(self, account, params)`, `pre_request_hook(self, ...)`,
`post_request_hook(self, ...)`, `on_update_hook(s)(self, ...)`, `on_app_event(self, ...)`.
Each returns a `HookResult`.
- `HookResult(strategy=HookStrategy.MODIFY, params=params)` applies the mutated `params` back
to the host; `HookStrategy.DEFAULT` (a bare `HookResult()`) passes through unchanged;
`HookStrategy.CANCEL` drops the action; `HookStrategy.MODIFY_FINAL` applies changes and
short-circuits any later hooks.
- Settings persistence: `self.get_setting(key, default)` / `self.set_setting(key, value)`.
## `ui.settings` row types
<https://plugins.exteragram.app/docs/ui-settings>
`Header`, `Input`, `Switch`, `Text`, `Selector`, `Divider` — plain kwargs-holding row
descriptors consumed by the host's settings renderer; construct and return them from
`create_settings`.
## Logging
<https://plugins.exteragram.app/docs/logging>
`android_utils.log(...)` writes to logcat. Chaquopy additionally redirects
`sys.stdout`/`sys.stderr` to the logcat tags `python.stdout` / `python.stderr` — useful when a
dependency logs with plain `print()`.
## Background work
<https://plugins.exteragram.app/docs/client-utils>
`client_utils.run_on_queue(...)` schedules work off the UI thread; use it for anything that
shouldn't block message handling.
## Reflection / private API access
<https://plugins.exteragram.app/docs/hook-utils>
`hook_utils.find_class(...)` and related helpers locate app classes and private fields for
plugins that need to reach beyond the public SDK surface.
## UI helpers
- `ui.alert.AlertDialogBuilder` — native alert dialogs. <https://plugins.exteragram.app/docs/ui-alert>
- `ui.bulletin` — transient bottom bulletins/toasts. <https://plugins.exteragram.app/docs/ui-bulletin>
- `intents.IntentsManager` — Android intent integration, **exteraGram 12.6.4+ only**.
<https://plugins.exteragram.app/docs/intents>
## On-device dev server protocol
<https://plugins.exteragram.app/docs/dev-server>
TCP `127.0.0.1:42690`, reachable from a PC via `adb forward tcp:42690 tcp:42690`. Wire format
is consecutive UTF-8 JSON objects with **no length framing** — parse with
`json.JSONDecoder().raw_decode` over an accumulating buffer, never assume one JSON object per
`recv()`. Requests: `{"@": <command>, "#": <id>, ...}`; responses echo `"#"`.
Commands used by this repo's `tools/devclient.py`: `ping`, `get_plugins`, `enable_plugin`,
`disable_plugin`, `reload_plugin`, `remove_plugin` (field `plugin_id`), `write_plugin`
(`plugin_id`, `content` — installs or updates), `start_debugger`/`stop_debugger` (`host`,
`port`, `platform` ∈ `vscode`|`pycharm`). The official `exteragram-utils` client (PyPI) uses
integer request ids from 1, a 30 s response timeout, a 1.0 s socket timeout, a
`write_plugin` → sleep 0.3 s → `reload_plugin` deploy sequence, and `adb forward` (not
`reverse`) for both the dev-server port and the debugger port. `elyx_*` commands exist for the
Elyx engine and are out of scope here.
## Runtime
Chaquopy 16 / Python 3.11. Anything shipped in a bundle must be 3.11-compatible.
## Why not Elyx/ElyxBuilder
The official structured plugin format (Elyx/ElyxBuilder) is a real alternative to a single
`.plugin` file, but it:
- requires the separate Elyx engine to be installed and enabled on the device;
- ships archives, not the `.plugin` files the base plugin engine understands;
- needs a manual first install before live reload works.
This repo's bundler instead compiles a normal multi-file `src/` project down to the one
`.plugin` file the base engine already knows how to load, so the dev-server write/reload loop
works from the first deploy with no extra on-device engine.
+11
View File
@@ -0,0 +1,11 @@
{
"id": "hello_bundle",
"name": "Hello Bundle",
"description": "Multi-file exteraGram plugin built by tools/bundle.py",
"author": "@yourhandle",
"version": "1.0.0",
"icon": "exteraPlugins/1",
"app_version": ">=12.5.1",
"sdk_version": ">=1.4.4.3",
"requirements": []
}
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
source scripts/lib.sh
need_cmd "$ADB" "$PY"
DEBUG=0
for arg in "$@"; do
case "$arg" in
--debug) DEBUG=1 ;;
*) die "unknown argument: $arg" ;;
esac
done
"$ADB" start-server
timeout 60 "$ADB" wait-for-device || die "timed out waiting for a device (ANDROID_SERIAL=${ANDROID_SERIAL:-<unset>})"
"$ADB" forward --remove "tcp:$DEV_PORT" >/dev/null 2>&1 || true
"$ADB" forward "tcp:$DEV_PORT" "tcp:$DEV_PORT"
if [[ "$DEBUG" -eq 1 ]]; then
"$ADB" forward --remove "tcp:$DEBUG_PORT" >/dev/null 2>&1 || true
"$ADB" forward "tcp:$DEBUG_PORT" "tcp:$DEBUG_PORT"
fi
if "$PY" tools/devclient.py ping >/dev/null 2>&1; then
echo "connected"
else
die "ping failed — is the plugin engine + developer mode enabled in exteraGram Preferences > Plugins?"
fi
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
source scripts/lib.sh
need_cmd "$ADB" "$PY"
usage() {
cat >&2 <<'EOF'
usage: scripts/debug.sh start|stop [--platform vscode|pycharm]
Requires the device-side debugger package to be present in the app's Python
runtime: `debugpy` for vscode, `pydevd-pycharm` for pycharm. If it is not
installed, add it to "requirements" in plugin.json so the app's pip
controller installs it on next load.
EOF
}
[[ $# -ge 1 ]] || { usage; exit 1; }
ACTION="$1"
shift
PLATFORM=vscode
while [[ $# -gt 0 ]]; do
case "$1" in
--platform)
PLATFORM="${2:-}"
shift 2
;;
--platform=*)
PLATFORM="${1#--platform=}"
shift
;;
*)
die "unknown argument: $1"
;;
esac
done
case "$ACTION" in
start|stop) ;;
*) usage; exit 1 ;;
esac
case "$PLATFORM" in
vscode|pycharm) ;;
*) die "--platform must be vscode or pycharm" ;;
esac
scripts/adb-connect.sh --debug
"$PY" tools/devclient.py debugger "$ACTION" --platform "$PLATFORM"
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
source scripts/lib.sh
need_cmd "$PY"
MODE=--dev
ENABLE=0
CONNECT=1
for arg in "$@"; do
case "$arg" in
--release) MODE=--release ;;
--enable) ENABLE=1 ;;
--no-connect) CONNECT=0 ;;
*) die "unknown argument: $arg" ;;
esac
done
"$PY" tools/bundle.py "$MODE" --out dist
BUNDLE="dist/${PLUGIN_ID}.plugin"
[[ -f "$BUNDLE" ]] || die "expected bundle not found at $BUNDLE"
echo "bundle: $BUNDLE ($(stat -c '%s' "$BUNDLE" 2>/dev/null || stat -f '%z' "$BUNDLE") bytes)"
if [[ "$CONNECT" -eq 1 ]]; then
need_cmd "$ADB"
scripts/adb-connect.sh
fi
DEPLOY_ARGS=(deploy "$BUNDLE" --id "$PLUGIN_ID")
if [[ "$ENABLE" -eq 1 ]]; then
DEPLOY_ARGS+=(--enable)
fi
"$PY" tools/devclient.py "${DEPLOY_ARGS[@]}"
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
source scripts/lib.sh
need_cmd "$PY" curl unzip sha256sum
BETA=0
for arg in "$@"; do
case "$arg" in
--beta) BETA=1 ;;
*) die "unknown argument: $arg" ;;
esac
done
API_URL="https://api.github.com/repos/exteraSquad/plugins-pysdk-builds/releases?per_page=20"
RELEASE_JSON="$(mktemp)"
trap 'rm -rf "$RELEASE_JSON" "$WORKDIR"' EXIT
curl -fsSL "$API_URL" -o "$RELEASE_JSON" || die "failed to query GitHub releases API"
read -r TAG ASSET_URL SHA_URL < <("$PY" - "$RELEASE_JSON" "$BETA" <<'PYEOF'
import json, sys
path, allow_beta = sys.argv[1], sys.argv[2] == "1"
with open(path, encoding="utf-8") as f:
releases = json.load(f)
for rel in releases:
if rel.get("prerelease") and not allow_beta:
continue
assets = {a["name"]: a["browser_download_url"] for a in rel.get("assets", [])}
if "stubs.zip" in assets:
sha_url = assets.get("stubs.zip.sha256", "")
print(rel["tag_name"], assets["stubs.zip"], sha_url)
break
else:
sys.exit("no release with a stubs.zip asset found")
PYEOF
) || die "no release with a stubs.zip asset found"
[[ -n "$TAG" ]] || die "could not determine a release to install"
WORKDIR="$(mktemp -d)"
curl -fsSL "$ASSET_URL" -o "$WORKDIR/stubs.zip" || die "failed to download stubs.zip"
if [[ -n "$SHA_URL" ]]; then
curl -fsSL "$SHA_URL" -o "$WORKDIR/stubs.zip.sha256" || die "failed to download stubs.zip.sha256"
(cd "$WORKDIR" && sha256sum -c stubs.zip.sha256) || die "checksum verification failed for stubs.zip"
else
echo "warning: no stubs.zip.sha256 asset published for $TAG; skipping checksum verification" >&2
fi
rm -rf stubs
mkdir -p stubs
unzip -q "$WORKDIR/stubs.zip" -d "$WORKDIR/outer"
INNER="$WORKDIR/outer/pyi-files.zip"
[[ -f "$INNER" ]] || die "expected pyi-files.zip inside stubs.zip, not found"
unzip -q "$INNER" -d stubs
echo "installed SDK stubs from release $TAG into stubs/"
+26
View File
@@ -0,0 +1,26 @@
# Shared helpers for scripts/*.sh. Source, do not execute:
# source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PKG=com.exteragram.messenger
DEV_PORT=42690
DEBUG_PORT=5678
PY="${PYTHON:-python3}"
ADB="${ADB:-adb}"
PLUGIN_ID="$("$PY" -c "import json,sys; print(json.load(open(sys.argv[1]))['id'])" "$REPO_ROOT/plugin.json")"
die() {
echo "error: $*" >&2
exit 1
}
need_cmd() {
for cmd in "$@"; do
command -v "$cmd" >/dev/null 2>&1 || die "required command not found on PATH: $cmd"
done
}
app_pid() {
"$ADB" shell pidof -s "$PKG" | tr -d '\r'
}
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
source scripts/lib.sh
need_cmd "$ADB"
CLEAR=0
STDIO=0
PATTERN=""
while [[ $# -gt 0 ]]; do
case "$1" in
-c) CLEAR=1; shift ;;
-t) STDIO=1; shift ;;
-g) PATTERN="${2:-}"; shift 2 ;;
*) die "unknown argument: $1" ;;
esac
done
if [[ "$CLEAR" -eq 1 ]]; then
"$ADB" logcat -c
fi
run_logcat() {
if [[ "$STDIO" -eq 1 ]]; then
"$ADB" logcat -s python.stdout:'*' python.stderr:'*'
else
local pid
pid="$(app_pid)"
[[ -n "$pid" ]] || die "exteraGram is not running — open the app first"
"$ADB" logcat --pid="$pid"
fi
}
if [[ -n "$PATTERN" ]]; then
run_logcat | grep --line-buffered -E "$PATTERN"
else
run_logcat
fi
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
source scripts/lib.sh
need_cmd "$PY"
checksum() {
find src plugin.json -type f -printf '%T@ %p\n' 2>/dev/null | sha256sum
}
run_deploy() {
if ! scripts/deploy.sh "$@"; then
echo "deploy failed, still watching..." >&2
fi
}
echo "watching src/ and plugin.json for changes..."
run_deploy "$@"
if command -v inotifywait >/dev/null 2>&1; then
while true; do
inotifywait -qq -r -e close_write,create,delete,move src plugin.json 2>/dev/null || true
sleep 0.3
run_deploy "$@"
done
else
echo "inotifywait not found; falling back to polling" >&2
last="$(checksum)"
while true; do
sleep 1
current="$(checksum)"
if [[ "$current" != "$last" ]]; then
last="$current"
sleep 0.3
run_deploy "$@"
fi
done
fi
+1
View File
@@ -0,0 +1 @@
from .template import render, TemplateError
+13
View File
@@ -0,0 +1,13 @@
from textutil import normalize
class TemplateError(ValueError):
pass
def render(template: str, name: str) -> str:
normalized = normalize(name)
try:
return template.format(name=normalized)
except (KeyError, IndexError, ValueError):
raise TemplateError(f"unsupported placeholder in {template!r}")
+50
View File
@@ -0,0 +1,50 @@
from base_plugin import BasePlugin, HookResult, HookStrategy
from android_utils import log
from ui.settings import Header, Input, Text
from greeting import render, TemplateError
DEFAULT_TEMPLATE = "Hello, {name}!"
class HelloPlugin(BasePlugin):
def on_plugin_load(self):
log("hello_bundle loaded")
self.add_on_send_message_hook()
def on_plugin_unload(self):
log("hello_bundle unloaded")
def create_settings(self):
return [
Header(text="Hello Bundle"),
Input(
key="template",
text="Greeting template",
default=DEFAULT_TEMPLATE,
subtext="Use {name} as placeholder.",
icon="msg_edit",
),
Text(
text="Example",
subtext=".hello alice smith -> Hello, Alice Smith!",
icon="msg_info",
),
]
def on_send_message_hook(self, account, params):
message = getattr(params, "message", None)
if not isinstance(message, str):
return HookResult()
stripped = message.strip()
if not stripped.startswith(".hello"):
return HookResult()
parts = stripped.split(" ", 1)
name = parts[1] if len(parts) > 1 else ""
if not name.strip():
params.message = "Usage: .hello <name>"
else:
try:
params.message = render(self.get_setting("template", DEFAULT_TEMPLATE), name)
except TemplateError as exc:
params.message = "Bad template: " + str(exc)
return HookResult(strategy=HookStrategy.MODIFY, params=params)
+2
View File
@@ -0,0 +1,2 @@
def normalize(name: str) -> str:
return " ".join(w[:1].upper() + w[1:] for w in name.split())
+345
View File
@@ -0,0 +1,345 @@
#!/usr/bin/env python3
"""Bundle a multi-file exteraGram plugin (plugin.json + src/) into a single .plugin file.
Usage: python3 tools/bundle.py [--dev|--release] [--out DIR] [--check]
"""
from __future__ import annotations
import argparse
import ast
import base64
import json
import re
import sys
import zlib
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SRC_DIR = REPO_ROOT / "src"
PLUGIN_JSON = REPO_ROOT / "plugin.json"
RECOGNIZED_KEYS = {
"id",
"name",
"description",
"author",
"version",
"icon",
"app_version",
"sdk_version",
"requirements",
}
REQUIRED_KEYS = {"id", "name"}
ID_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]{1,31}$")
KEY_TO_DUNDER = {
"id": "__id__",
"name": "__name__",
"description": "__description__",
"author": "__author__",
"version": "__version__",
"icon": "__icon__",
"app_version": "__app_version__",
"sdk_version": "__sdk_version__",
"requirements": "__requirements__",
}
METADATA_DUNDERS = set(KEY_TO_DUNDER.values()) | {"__min_version__"}
RUNTIME_PREAMBLE = '''\
import base64 as _b64, builtins as _bi, json as _j, linecache as _lc, types as _t, zlib as _z
_BUNDLE = _j.loads(_z.decompress(_b64.b64decode(_BUNDLE_BLOB)).decode("utf-8"))
_MODS = _BUNDLE["modules"]
_LOADED = {}
_ROOTS = {n.partition(".")[0] for n in _MODS}
_REAL_IMPORT = _bi.__import__
def _bundle_register_source(info):
lines = info["src"].splitlines(True)
_lc.cache[info["file"]] = (len(info["src"]), None, lines, info["file"])
def _bundle_load(name):
mod = _LOADED.get(name)
if mod is not None:
return mod
info = _MODS.get(name)
if info is None:
raise ImportError("no bundled module named %r" % name)
parent = name.rpartition(".")[0]
if parent:
_bundle_load(parent)
mod = _t.ModuleType(name)
mod.__file__ = info["file"]
mod.__package__ = name if info["pkg"] else parent
if info["pkg"]:
mod.__path__ = []
builtins_ns = dict(vars(_bi))
builtins_ns["__import__"] = _bundle_import
mod.__dict__["__builtins__"] = builtins_ns
_LOADED[name] = mod
_bundle_register_source(info)
try:
exec(compile(info["src"], info["file"], "exec"), mod.__dict__)
except BaseException:
_LOADED.pop(name, None)
raise
if parent:
setattr(_LOADED[parent], name.rpartition(".")[2], mod)
return mod
def _bundle_import(name, globals=None, locals=None, fromlist=(), level=0):
if level:
pkg = (globals or {}).get("__package__") or ""
parts = pkg.split(".") if pkg else []
if level > 1:
parts = parts[: 1 - level]
if not parts:
return _REAL_IMPORT(name, globals, locals, fromlist, level)
full = ".".join(parts) + ("." + name if name else "")
else:
full = name
if full.partition(".")[0] not in _ROOTS or full not in _MODS:
return _REAL_IMPORT(name, globals, locals, fromlist, level)
mod = _bundle_load(full)
if not fromlist:
return mod if level else _bundle_load(full.partition(".")[0])
for item in fromlist:
if item != "*" and not hasattr(mod, item) and full + "." + item in _MODS:
_bundle_load(full + "." + item)
return mod
'''
ENTRY_EXECUTION = '''\
_ENTRY = _MODS[_BUNDLE["entry"]]
_bundle_register_source(_ENTRY)
_bundle_builtins = dict(vars(_bi))
_bundle_builtins["__import__"] = _bundle_import
globals()["__builtins__"] = _bundle_builtins
exec(compile(_ENTRY["src"], _ENTRY["file"], "exec"), globals())
'''
class ValidationError(Exception):
pass
def load_metadata() -> dict:
if not PLUGIN_JSON.is_file():
raise ValidationError(f"plugin.json not found at {PLUGIN_JSON}")
try:
text = PLUGIN_JSON.read_text(encoding="utf-8")
except OSError as exc:
raise ValidationError(f"cannot read plugin.json: {exc}")
try:
data = json.loads(text)
except json.JSONDecodeError as exc:
raise ValidationError(f"plugin.json is not valid JSON: {exc}")
if not isinstance(data, dict):
raise ValidationError("plugin.json must be a JSON object")
unknown = set(data) - RECOGNIZED_KEYS
if unknown:
raise ValidationError(
"plugin.json has unrecognized keys: " + ", ".join(sorted(unknown))
)
missing = REQUIRED_KEYS - set(data)
if missing:
raise ValidationError(
"plugin.json is missing required keys: " + ", ".join(sorted(missing))
)
plugin_id = data["id"]
if not isinstance(plugin_id, str) or not ID_RE.match(plugin_id):
raise ValidationError(
f"plugin.json id {plugin_id!r} is invalid: must be 2-32 chars, start with a "
"letter, and contain only A-Za-z0-9_-"
)
if not isinstance(data["name"], str) or not data["name"]:
raise ValidationError("plugin.json name must be a non-empty string")
return data
def collect_modules() -> dict:
if not SRC_DIR.is_dir():
raise ValidationError(f"src/ directory not found at {SRC_DIR}")
modules: dict = {}
for path in sorted(SRC_DIR.rglob("*")):
if path.is_dir():
continue
rel = path.relative_to(SRC_DIR)
if any(part.startswith(".") for part in rel.parts):
continue
if "__pycache__" in rel.parts:
continue
if path.suffix == ".pyc":
continue
if path.suffix != ".py":
print(
f"warning: skipping non-Python file {path.relative_to(REPO_ROOT)} "
"(single-file plugins cannot carry assets)",
file=sys.stderr,
)
continue
is_pkg = rel.name == "__init__.py"
dotted_parts = rel.parts[:-1] if is_pkg else rel.parts[:-1] + (rel.stem,)
name = ".".join(dotted_parts)
if not name:
raise ValidationError(f"cannot derive a module name for {path}")
try:
src = path.read_text(encoding="utf-8")
except OSError as exc:
raise ValidationError(f"cannot read {path}: {exc}")
modules[name] = {"src": src, "path": path, "pkg": is_pkg}
return modules
def validate_entry(modules: dict) -> None:
main_path = SRC_DIR / "main.py"
if not main_path.is_file():
raise ValidationError("src/main.py does not exist")
entry = modules.get("main")
if entry is None:
raise ValidationError("src/main.py could not be collected as the 'main' module")
try:
tree = ast.parse(entry["src"], filename=str(main_path))
except SyntaxError as exc:
raise ValidationError(f"src/main.py has a syntax error: {exc}")
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.level > 0:
raise ValidationError(
"src/main.py must use absolute imports only (found a relative import); "
"it is executed at plugin-module top level"
)
if isinstance(node, (ast.Assign, ast.AnnAssign)):
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
for target in targets:
if isinstance(target, ast.Name) and target.id in METADATA_DUNDERS:
raise ValidationError(
f"src/main.py must not assign metadata dunder {target.id!r} "
"(metadata belongs to plugin.json only)"
)
def _base_names(class_node: ast.ClassDef) -> set:
names = set()
for base in class_node.bases:
if isinstance(base, ast.Name):
names.add(base.id)
elif isinstance(base, ast.Attribute):
names.add(base.attr)
return names
plugin_classes = [
node.name
for node in tree.body
if isinstance(node, ast.ClassDef) and "BasePlugin" in _base_names(node)
]
if len(plugin_classes) != 1:
raise ValidationError(
"src/main.py must contain exactly one class inheriting BasePlugin, found "
f"{len(plugin_classes)}"
)
def validate_modules_syntax(modules: dict) -> None:
for name, info in modules.items():
try:
ast.parse(info["src"], filename=str(info["path"]))
except SyntaxError as exc:
raise ValidationError(f"{info['path']} has a syntax error: {exc}")
def format_metadata_constants(data: dict) -> str:
lines = []
for key, dunder in KEY_TO_DUNDER.items():
if key not in data:
continue
value = data[key]
if key in ("description", "icon") and value == "":
continue
if key == "requirements" and value == []:
continue
lines.append(f"{dunder} = {value!r}")
return "\n".join(lines)
def build_payload(modules: dict, release: bool) -> dict:
mod_dict = {}
for name, info in modules.items():
if release:
file_value = str(info["path"].relative_to(REPO_ROOT))
else:
file_value = str(info["path"].resolve())
mod_dict[name] = {"src": info["src"], "file": file_value, "pkg": info["pkg"]}
return {"modules": mod_dict, "entry": "main"}
def encode_payload(payload: dict) -> str:
data = json.dumps(payload, sort_keys=True).encode("utf-8")
compressed = zlib.compress(data, 9)
return base64.b64encode(compressed).decode("ascii")
def format_blob_literal(blob: str, width: int = 96) -> str:
if not blob:
return '_BUNDLE_BLOB = ""'
lines = [blob[i : i + width] for i in range(0, len(blob), width)]
body = "\n".join(f' "{line}"' for line in lines)
return "_BUNDLE_BLOB = (\n" + body + "\n)"
def assemble_bundle(data: dict, modules: dict, release: bool) -> str:
header = "# Generated by tools/bundle.py from plugin.json + src/ — do not edit; edit the sources.\n"
metadata = format_metadata_constants(data)
payload = build_payload(modules, release)
blob_literal = format_blob_literal(encode_payload(payload))
text = "\n".join([header, metadata, "", blob_literal, "", RUNTIME_PREAMBLE, ENTRY_EXECUTION])
if not text.endswith("\n"):
text += "\n"
return text
def parse_args(argv):
parser = argparse.ArgumentParser(
description="Bundle a multi-file exteraGram plugin into a single .plugin file."
)
mode = parser.add_mutually_exclusive_group()
mode.add_argument(
"--dev", action="store_true", help="embed absolute local paths for debugging (default)"
)
mode.add_argument(
"--release", action="store_true", help="embed repo-relative paths for distribution"
)
parser.add_argument("--out", default="dist", help="output directory (default: dist)")
parser.add_argument("--check", action="store_true", help="validate only; do not write output")
return parser.parse_args(argv)
def main(argv=None) -> int:
args = parse_args(sys.argv[1:] if argv is None else argv)
release = bool(args.release)
try:
data = load_metadata()
modules = collect_modules()
validate_entry(modules)
validate_modules_syntax(modules)
except ValidationError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
if args.check:
print("ok")
return 0
text = assemble_bundle(data, modules, release)
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
out_path = out_dir / f"{data['id']}.plugin"
out_path.write_text(text, encoding="utf-8")
print(f"wrote {out_path} ({out_path.stat().st_size} bytes)")
return 0
if __name__ == "__main__":
sys.exit(main())
+219
View File
@@ -0,0 +1,219 @@
#!/usr/bin/env python3
"""Client for the exteraGram on-device plugin dev server.
Speaks the wire protocol at 127.0.0.1:42690 (after `adb forward tcp:42690 tcp:42690`):
consecutive UTF-8 JSON objects, no length framing. Requests are
{"@": <command>, "#": <id>, ...}; responses echo "#".
"""
from __future__ import annotations
import argparse
import json
import socket
import sys
import time
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
PLUGIN_JSON = REPO_ROOT / "plugin.json"
CONNECT_ATTEMPTS = 5
CONNECT_RETRY_DELAY = 1.0
SOCKET_TIMEOUT = 1.0
RESPONSE_TIMEOUT = 30.0
UNREACHABLE_MESSAGE = (
"dev server unreachable — run scripts/adb-connect.sh, and enable exteraGram "
"Preferences > Plugins > plugin engine + developer mode"
)
def default_plugin_id() -> str | None:
try:
data = json.loads(PLUGIN_JSON.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
return data.get("id")
class DevClient:
def __init__(self, host: str, port: int):
self.host = host
self.port = port
self._sock: socket.socket | None = None
self._buf = b""
self._decoder = json.JSONDecoder()
self._next_id = 1
def connect(self) -> None:
last_exc = None
for attempt in range(CONNECT_ATTEMPTS):
try:
sock = socket.create_connection((self.host, self.port), timeout=SOCKET_TIMEOUT)
sock.settimeout(SOCKET_TIMEOUT)
self._sock = sock
return
except OSError as exc:
last_exc = exc
if attempt < CONNECT_ATTEMPTS - 1:
time.sleep(CONNECT_RETRY_DELAY)
print(UNREACHABLE_MESSAGE, file=sys.stderr)
raise SystemExit(1) from last_exc
def close(self) -> None:
if self._sock is not None:
self._sock.close()
self._sock = None
def _read_one(self, deadline: float) -> dict:
while True:
self._buf = self._buf.lstrip()
if self._buf:
try:
text = self._buf.decode("utf-8")
except UnicodeDecodeError:
text = None
if text:
try:
obj, idx = self._decoder.raw_decode(text)
consumed = len(text[:idx].encode("utf-8"))
self._buf = self._buf[consumed:]
return obj
except json.JSONDecodeError:
pass
if time.monotonic() >= deadline:
print("error: timed out waiting for dev server response", file=sys.stderr)
raise SystemExit(1)
try:
chunk = self._sock.recv(4096)
except socket.timeout:
continue
except OSError as exc:
print(f"error: connection lost: {exc}", file=sys.stderr)
raise SystemExit(1)
if not chunk:
print("error: dev server closed the connection", file=sys.stderr)
raise SystemExit(1)
self._buf += chunk
def request(self, command: str, **fields) -> dict:
req_id = self._next_id
self._next_id += 1
payload = {"@": command, "#": req_id, **fields}
self._sock.sendall(json.dumps(payload).encode("utf-8"))
deadline = time.monotonic() + RESPONSE_TIMEOUT
while True:
resp = self._read_one(deadline)
if resp.get("#") == req_id:
if resp.get("error") or resp.get("errors"):
print(json.dumps(resp, indent=2), file=sys.stderr)
raise SystemExit(1)
return resp
# response to a different (stale) request id: keep draining
def cmd_ping(client: DevClient, args) -> int:
resp = client.request("ping")
print(json.dumps(resp, indent=2))
return 0
def cmd_plugins(client: DevClient, args) -> int:
resp = client.request("get_plugins")
print(json.dumps(resp, indent=2))
return 0
def cmd_deploy(client: DevClient, args) -> int:
plugin_id = args.id or default_plugin_id()
if not plugin_id:
print("error: could not determine plugin id (pass --id or fix plugin.json)", file=sys.stderr)
return 1
try:
content = Path(args.file).read_text(encoding="utf-8")
except OSError as exc:
print(f"error: cannot read {args.file}: {exc}", file=sys.stderr)
return 1
client.request("write_plugin", plugin_id=plugin_id, content=content)
time.sleep(0.3)
if args.enable:
client.request("enable_plugin", plugin_id=plugin_id)
if not args.no_reload:
client.request("reload_plugin", plugin_id=plugin_id)
print(f"deployed {args.file} as {plugin_id}")
return 0
def cmd_simple_id(command: str):
def handler(client: DevClient, args) -> int:
client.request(command, plugin_id=args.id)
print(f"{command} {args.id}: ok")
return 0
return handler
def cmd_debugger(client: DevClient, args) -> int:
if args.action == "start":
client.request(
"start_debugger",
host=args.debug_host,
port=args.debug_port,
platform=args.platform,
)
print(f"debugger start requested ({args.platform} -> {args.debug_host}:{args.debug_port})")
else:
client.request("stop_debugger", platform=args.platform)
print(f"debugger stop requested ({args.platform})")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="exteraGram plugin dev server client")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=42690)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("ping").set_defaults(func=cmd_ping)
sub.add_parser("plugins").set_defaults(func=cmd_plugins)
p_deploy = sub.add_parser("deploy")
p_deploy.add_argument("file")
p_deploy.add_argument("--id")
p_deploy.add_argument("--enable", action="store_true")
p_deploy.add_argument("--no-reload", action="store_true")
p_deploy.set_defaults(func=cmd_deploy)
for name, command in (
("enable", "enable_plugin"),
("disable", "disable_plugin"),
("reload", "reload_plugin"),
("remove", "remove_plugin"),
):
p = sub.add_parser(name)
p.add_argument("id")
p.set_defaults(func=cmd_simple_id(command))
p_debugger = sub.add_parser("debugger")
p_debugger.add_argument("action", choices=["start", "stop"])
p_debugger.add_argument("--platform", choices=["vscode", "pycharm"], default="vscode")
p_debugger.add_argument("--debug-host", default="127.0.0.1")
p_debugger.add_argument("--debug-port", type=int, default=5678)
p_debugger.set_defaults(func=cmd_debugger)
return parser
def main(argv=None) -> int:
parser = build_parser()
args = parser.parse_args(sys.argv[1:] if argv is None else argv)
client = DevClient(args.host, args.port)
client.connect()
try:
return args.func(client, args)
finally:
client.close()
if __name__ == "__main__":
sys.exit(main())
+263
View File
@@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""Offline verification harness for a built .plugin bundle. Runs without a phone.
Usage: python3 tools/verify_bundle.py <bundle.plugin>
"""
from __future__ import annotations
import ast
import json
import sys
import traceback
import types
from dataclasses import dataclass
from enum import Enum, auto
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
PLUGIN_JSON = REPO_ROOT / "plugin.json"
_FAILURES = []
def check(name: str, condition: bool, detail: str = "") -> None:
if condition:
print(f"PASS {name}")
else:
message = f"FAIL {name}"
if detail:
message += f": {detail}"
print(message, file=sys.stderr)
_FAILURES.append(name)
class HookStrategy(Enum):
DEFAULT = auto()
MODIFY = auto()
CANCEL = auto()
MODIFY_FINAL = auto()
@dataclass
class HookResult:
strategy: HookStrategy = HookStrategy.DEFAULT
params: object = None
LOG_LINES: list[str] = []
class BasePlugin:
def __init__(self):
self._settings = {}
def get_setting(self, key, default=None):
return self._settings.get(key, default)
def set_setting(self, key, value):
self._settings[key] = value
def add_on_send_message_hook(self):
pass
def on_plugin_load(self):
pass
def on_plugin_unload(self):
pass
def create_settings(self):
return []
def fake_log(*args, **kwargs):
LOG_LINES.append(" ".join(str(a) for a in args))
class _SettingsRow:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
def install_fakes() -> None:
base_plugin_mod = types.ModuleType("base_plugin")
base_plugin_mod.BasePlugin = BasePlugin
base_plugin_mod.HookResult = HookResult
base_plugin_mod.HookStrategy = HookStrategy
sys.modules["base_plugin"] = base_plugin_mod
android_utils_mod = types.ModuleType("android_utils")
android_utils_mod.log = fake_log
sys.modules["android_utils"] = android_utils_mod
ui_mod = types.ModuleType("ui")
ui_mod.__path__ = []
sys.modules["ui"] = ui_mod
ui_settings_mod = types.ModuleType("ui.settings")
for cls_name in ("Header", "Input", "Text", "Switch"):
setattr(ui_settings_mod, cls_name, type(cls_name, (_SettingsRow,), {}))
sys.modules["ui.settings"] = ui_settings_mod
ui_mod.settings = ui_settings_mod
class SimpleParams:
def __init__(self, message):
self.message = message
KEY_TO_DUNDER = {
"id": "__id__",
"name": "__name__",
"description": "__description__",
"author": "__author__",
"version": "__version__",
"icon": "__icon__",
"app_version": "__app_version__",
"sdk_version": "__sdk_version__",
"requirements": "__requirements__",
}
def expected_dunders(meta: dict) -> dict:
result = {}
for key, dunder in KEY_TO_DUNDER.items():
if key not in meta:
continue
value = meta[key]
if key in ("description", "icon") and value == "":
continue
if key == "requirements" and value == []:
continue
result[dunder] = value
return result
def main(argv=None) -> int:
argv = sys.argv[1:] if argv is None else argv
if len(argv) != 1:
print("usage: tools/verify_bundle.py <bundle.plugin>", file=sys.stderr)
return 1
bundle_path = Path(argv[0])
if not bundle_path.is_file():
print(f"error: bundle not found at {bundle_path}", file=sys.stderr)
return 1
install_fakes()
source = bundle_path.read_text(encoding="utf-8")
modules_before = set(sys.modules)
ns = {"__name__": "hello_bundle"}
exec(compile(source, str(bundle_path), "exec"), ns)
new_modules = set(sys.modules) - modules_before
meta = json.loads(PLUGIN_JSON.read_text(encoding="utf-8"))
expected = expected_dunders(meta)
mismatches = [
f"{dunder}: expected {value!r}, got {ns.get(dunder)!r}"
for dunder, value in expected.items()
if ns.get(dunder) != value
]
check("metadata constants match plugin.json", not mismatches, "; ".join(mismatches))
tree = ast.parse(source, filename=str(bundle_path))
top_level_assigned = {
target.id
for node in tree.body
if isinstance(node, ast.Assign)
for target in node.targets
if isinstance(target, ast.Name)
}
check(
"metadata exposed as top-level literal assignments (AST)",
set(expected) <= top_level_assigned,
f"missing: {set(expected) - top_level_assigned}",
)
plugin_classes = [
value
for value in ns.values()
if isinstance(value, type) and issubclass(value, BasePlugin) and value is not BasePlugin
]
check(
"exactly one BasePlugin subclass defined",
len(plugin_classes) == 1,
f"found {len(plugin_classes)}",
)
plugin_cls = plugin_classes[0] if len(plugin_classes) == 1 else None
if plugin_cls is not None:
check(
"plugin class __module__ matches executed namespace",
plugin_cls.__module__ == ns["__name__"],
f"__module__={plugin_cls.__module__!r} != {ns['__name__']!r}",
)
plugin = plugin_cls()
LOG_LINES.clear()
plugin.on_plugin_load()
check(
"on_plugin_load emits load log line",
any("hello_bundle loaded" in line for line in LOG_LINES),
f"log lines: {LOG_LINES}",
)
params = SimpleParams(".hello alice smith")
result = plugin.on_send_message_hook(account=0, params=params)
check(
"'.hello alice smith' renders greeting",
params.message == "Hello, Alice Smith!",
f"got {params.message!r}",
)
check(
"on_send_message_hook returns MODIFY strategy",
result.strategy == HookStrategy.MODIFY,
f"got {result.strategy}",
)
plugin.set_setting("template", "{nope}")
params2 = SimpleParams(".hello someone")
plugin.on_send_message_hook(account=0, params=params2)
check(
"bad template produces 'Bad template:' message",
params2.message.startswith("Bad template:"),
f"got {params2.message!r}",
)
# main.py imports `render`/`TemplateError` by name, so they live directly
# in the executed namespace — call render() directly to inspect the raised
# traceback (proves linecache registration for bundled, non-file modules).
render = ns.get("render")
template_error = ns.get("TemplateError")
traceback_text = ""
if render is not None and template_error is not None:
try:
render("{nope}", "x")
except template_error:
traceback_text = traceback.format_exc()
check(
"render() traceback names src/greeting/template.py",
"src/greeting/template.py" in traceback_text,
f"traceback:\n{traceback_text}",
)
check(
"traceback includes the raising source line text",
"unsupported placeholder" in traceback_text,
f"traceback:\n{traceback_text}",
)
check(
"sys.modules gained no entry for bundled module names",
not ({"greeting", "textutil", "main"} & new_modules),
f"leaked: {({'greeting', 'textutil', 'main'} & new_modules)}",
)
if _FAILURES:
print(f"\n{len(_FAILURES)} check(s) failed: {', '.join(_FAILURES)}", file=sys.stderr)
return 1
print("\nall checks passed")
return 0
if __name__ == "__main__":
sys.exit(main())