generated from exteraGramPlugins/pluginBase
Publish plugin / publish (push) Successful in 3s
Pressing Summarize used to inject a status card immediately, at a guessed anchor, and move it once the fetch returned. Any failure before the model answered (fetch error, empty history, unreachable host, bad key, HTTP error) then left an error bubble in the chat and had already scrolled the user away from where they were reading. The pipeline is now fetch -> stream -> card. A bulletin acknowledges the press; every failure before the first chunk is a bulletin only and leaves the chat untouched. On the first chunk the card is created with its final anchor and virtual id and placed strictly after the newest analyzed message. If that message is not loaded, the chat is asked to load it and placement retries; the card never falls back to the chat bottom. Placement then hard-jumps the layout manager to the card's row: scrollToMessageId only smooth-scrolls from cell positions that are stale right after an insert. The provisional anchor and reanchor() machinery is gone. The row is reserved with INSERT .. DO NOTHING, so re-summarizing a saved range no longer blanks its text before the new answer arrives; a fresh row whose stream breaks is deleted instead of left empty. Also: - Finished summaries end with #aisummaries as a native TL_iv.textHashtag (rendered by RichMessageLayout like a hashtag entity) and carry a plain-text body with a TL_messageEntityHashtag. - Deleting a card (MessagesController.deleteMessages, all overloads) removes its saved row and the card, with a "Summary deleted" bulletin. - Settings show the saved summary count and approximate size, and a confirmed "Clear all summaries" action. - Version 1.1.0. Verified on device: no card before streaming, correct placement and jump, restore on reopen, deletion, and the hashtag rendering. Telegram's search is server-side, so local cards do not appear in its results. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
150 lines
7.5 KiB
Markdown
150 lines
7.5 KiB
Markdown
# 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>
|
||
|
||
## 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
|
||
|
||
<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.
|