# exteraGram plugin SDK — condensed notes Gathered from , 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: . | 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 - `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 `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 `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 `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 `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. - `ui.bulletin` — transient bottom bulletins/toasts. - `intents.IntentsManager` — Android intent integration, **exteraGram 12.6.4+ only**. ## Host internals relied on by the summary card Verified against exteraGram's `ChatActivity`/`MessagesController` sources and the 12.5.x dex. - **Scrolling.** `ChatActivity.scrollToMessageId(id, 0, false, 0, true, 0)`: for an id that is not loaded it requests history around it and jumps there when it arrives; for a loaded one it only `smoothScrollBy`s from the current cell positions, which are stale right after an insert. To land on a row, set the layout manager's pending position instead: `chatLayoutManager.scrollToPositionWithOffset(chatAdapter.messagesStartRow + index, offset, false)` (`bottom=false` counts `offset` from the top edge). `messagesStartRow` is public and current after `notifyDataSetChanged()`, which runs `updateRowsInternal()`. - **Deleting.** Every delete goes through `MessagesController.deleteMessages` (4 overloads, all starting `(messages, randoms, encryptedChat, dialogId, …)`). Only ids `> 0` are sent to the server; the rest are deleted from the local cache and posted in `NotificationCenter.messagesDeleted`. `ChatActivity` removes deleted rows by looking them up in `messagesDict`, which injected cards are never added to, so the plugin removes them. - **Hashtags in rich text.** `TL_iv.textHashtag` (single field `text`) is rendered by `RichMessageLayout.formatText` exactly like a `TL_messageEntityHashtag` in a normal message (`URLSpanNoUnderline`). Long-pressing it offers Open / Copy hashtag. A `textUrl` whose URL starts with `#` is not a hashtag: it goes through the external-URL path and does nothing. - **Search.** In-chat and hashtag search are server-side (`messages.search`); local results exist only for Saved Messages tags. A locally injected message cannot appear in them. ## SDK UI details used here - `ui.settings.Text(text, subtext=, icon=, accent=, red=, on_click=)`, where `on_click` receives the clicked `View`. `set_setting(key, value, reload_settings=True)` re-renders the settings page. - `AlertDialogBuilder(activity)`; button listeners receive `(builder, which)`; `make_button_red(AlertDialogBuilder.BUTTON_POSITIVE)` after `show()`. A dismissed builder cannot be shown again. - `BulletinHelper.show_*` post to the UI thread themselves. - `android_utils.run_on_ui_thread(fn, delay)`, where `delay` is in milliseconds. ## On-device dev server protocol 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: `{"@": , "#": , ...}`; 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.