Files
lyuksovannyyandClaude Opus 5.5 16bf8de84c
Publish plugin / publish (push) Successful in 3s
Inject the summary card only once the answer starts streaming
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>
2026-10-06 23:19:06 +02:00

7.5 KiB
Raw Permalink Blame History

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

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 smoothScrollBys 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.