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.
This commit is contained in:
lyuksovannyy committed 2026-09-12 23:52:15 +02:00
1 parent a3b0b264c1
commit 196da742de
27 files changed
+5261 -121

No files matched your search

+567
View File
@@ -0,0 +1,567 @@
"""Client-side injection of summary messages rendered as RichMessage.
A summary is not a real Telegram message: it is a locally built
`TLRPC.TL_message` carrying `rich_message`, attributed to a mock sender that
exists only in this client's `MessagesController` cache. It is inserted into the
open `ChatActivity`'s message list right after the newest analyzed message.
Verified against exteraGram 12.5.x (dex inspection):
- `Message.rich_message` + `flags2 |= 1 << 12` makes `MessageObject.setType`
return 36, which is the gate for `RichMessageLayout`.
- `RichMessage.part` makes the layout fade its bottom edge and hide the
timestamp, i.e. the app's own "still streaming" presentation.
- The mock sender id MUST be positive: negative ids are chat/channel space, so
`getUser()` can never resolve one and the cell falls back to "DELETED".
"""
from typing import Any, Optional
import diagnostics
from services.rich_markdown import build_rich_message
# Above Telegram's issued id range, so this user exists only locally.
MOCK_SENDER_ID = 8000000000000001
MOCK_SENDER_NAME = "AI Summary"
MOCK_SENDER_USERNAME = "ai_summary"
FLAGS2_RICH_MESSAGE = 1 << 12
# Telegram's animated robot custom emoji, used as the mock sender's status.
ROBOT_EMOJI_DOCUMENT_ID = 5397634513026043777
def _tlrpc():
from org.telegram.tgnet import TLRPC
return TLRPC
def virtual_id_for(summary_id: int, anchor_msg_id: int) -> int:
"""Deterministic negative id, so re-injection is idempotent and detectable."""
return -abs(int(anchor_msg_id) * 1000 + (int(summary_id) % 1000)) or -1
def register_mock_sender(account: int) -> Optional[Any]:
"""Puts the mock sender into the local user cache so the cell renders a name."""
try:
TLRPC = _tlrpc()
from org.telegram.messenger import MessagesController
user = TLRPC.TL_user()
user.id = MOCK_SENDER_ID
user.first_name = MOCK_SENDER_NAME
user.last_name = ""
user.username = MOCK_SENDER_USERNAME
user.bot = True
user.verified = True
user.deleted = False
user.access_hash = 0
_attach_robot_status(user, account, TLRPC)
MessagesController.getInstance(account).putUser(user, False)
return user
except Exception as exc:
diagnostics.record_error("injector.register_mock_sender", exc)
return None
def _attach_robot_status(user: Any, account: int, TLRPC: Any) -> None:
try:
from org.telegram.messenger import AnimatedEmojiDrawable
if AnimatedEmojiDrawable.findDocument(account, ROBOT_EMOJI_DOCUMENT_ID) is None:
return
status = TLRPC.TL_emojiStatus()
status.document_id = ROBOT_EMOJI_DOCUMENT_ID
user.emoji_status = status
except Exception:
# Purely cosmetic; a missing emoji pack must not break injection.
pass
def peer_for_dialog(dialog_id: int) -> Any:
"""Builds the Peer subtype matching the dialog kind.
The subtype matters: `MessageObject.isOutOwner` inspects it, and a
`TL_peerUser` in a group makes the summary render as an outgoing message.
"""
TLRPC = _tlrpc()
dialog_id = int(dialog_id)
if dialog_id > 0:
peer = TLRPC.TL_peerUser()
peer.user_id = dialog_id
return peer
if dialog_id <= -1000000000000:
peer = TLRPC.TL_peerChannel()
peer.channel_id = -dialog_id - 1000000000000
return peer
peer = TLRPC.TL_peerChat()
peer.chat_id = -dialog_id
return peer
def build_summary_message(
account: int,
dialog_id: int,
topic_id: int,
anchor_msg_id: int,
virtual_id: int,
answer_markdown: str,
thinking: str = "",
streaming: bool = False,
status: str = "",
) -> Optional[Any]:
"""Builds the local MessageObject for a summary, or None on failure."""
try:
import time
TLRPC = _tlrpc()
from org.telegram.messenger import MessageObject
msg = TLRPC.TL_message()
msg.id = int(virtual_id)
msg.dialog_id = int(dialog_id)
msg.date = int(time.time())
msg.out = False
msg.unread = False
msg.post = False
msg.flags = 0
msg.message = ""
msg.peer_id = peer_for_dialog(dialog_id)
sender = TLRPC.TL_peerUser()
sender.user_id = MOCK_SENDER_ID
msg.from_id = sender
# Reply header points at the newest analyzed message, so the summary
# visually attaches to the range it covers and taps navigate there.
if anchor_msg_id:
try:
header = TLRPC.TL_messageReplyHeader()
header.reply_to_msg_id = int(anchor_msg_id)
if topic_id:
header.reply_to_top_id = int(topic_id)
msg.reply_to = header
except Exception as exc:
diagnostics.record_error("injector.reply_header", exc)
msg.rich_message = build_rich_message(answer_markdown, thinking, streaming, status)
msg.flags2 = int(getattr(msg, "flags2", 0) or 0) | FLAGS2_RICH_MESSAGE
message_object = MessageObject(account, msg, True, True)
# Pin attribution rather than let it be derived: in Saved Messages
# isOutOwner() returns true for anything without forward info.
message_object.isOutOwnerCached = False
return message_object
except Exception as exc:
diagnostics.record_error("injector.build_summary_message", exc)
return None
def get_messages_list(chat_activity: Any) -> Optional[Any]:
"""Returns the ChatActivity's live ArrayList<MessageObject>, or None."""
try:
from hook_utils import get_private_field
candidate = getattr(chat_activity, "messages", None) or get_private_field(chat_activity, "messages")
if candidate is not None and hasattr(candidate, "size"):
return candidate
diagnostics.note(
f"injector.get_messages_list: unusable candidate={type(candidate).__name__ if candidate is not None else None}"
)
except Exception as exc:
diagnostics.record_error("injector.get_messages_list", exc)
return None
def get_chat_adapter(chat_activity: Any) -> Optional[Any]:
try:
from hook_utils import get_private_field
return getattr(chat_activity, "chatAdapter", None) or get_private_field(chat_activity, "chatAdapter")
except Exception:
return None
def find_index_for_anchor(messages_list: Any, anchor_msg_id: int) -> int:
"""Index at which to insert so the summary sits directly after the anchor.
ChatActivity.messages is newest-first, so "directly after the anchor"
chronologically means "immediately before it" in list order.
The anchor is the newest message that was actually analyzed, which is not
the newest message in the chat whenever a non-zero offset was used. If the
anchor itself is not loaded (paged out, or never fetched into this view),
fall back to ordering by id rather than to index 0: index 0 is the bottom
of the chat and would place the card after messages the summary never saw.
"""
anchor_msg_id = int(anchor_msg_id)
try:
size = messages_list.size()
for i in range(size):
owner = getattr(messages_list.get(i), "messageOwner", None)
if owner is not None and int(getattr(owner, "id", 0) or 0) == anchor_msg_id:
return i
# Anchor absent: insert before the first real message whose id is at
# most the anchor's, i.e. the newest message not newer than the
# analyzed range. Injected cards carry negative ids and are skipped so
# they cannot drag the position around.
for i in range(size):
owner = getattr(messages_list.get(i), "messageOwner", None)
msg_id = int(getattr(owner, "id", 0) or 0) if owner is not None else 0
if 0 < msg_id <= anchor_msg_id:
return i
# Every loaded message is newer than the anchor, so the analyzed range
# ends above everything here: the card belongs at the very top.
return size
except Exception as exc:
diagnostics.record_error("injector.find_index_for_anchor", exc)
return 0
def find_existing(messages_list: Any, virtual_id: int) -> int:
"""Index of an already-injected message with this virtual id, else -1."""
try:
size = messages_list.size()
for i in range(size):
owner = getattr(messages_list.get(i), "messageOwner", None)
if owner is not None and int(getattr(owner, "id", 0) or 0) == int(virtual_id):
return i
except Exception:
pass
return -1
def notify_adapter(chat_activity: Any) -> None:
adapter = get_chat_adapter(chat_activity)
if adapter is None:
return
def run():
try:
adapter.notifyDataSetChanged()
except Exception as exc:
diagnostics.record_error("injector.notifyDataSetChanged", exc)
try:
from android_utils import run_on_ui_thread
run_on_ui_thread(run)
except Exception:
run()
class SummaryInjection:
"""A single injected summary message, updatable in place while streaming."""
def __init__(self, chat_activity: Any, account: int, dialog_id: int, topic_id: int, anchor_msg_id: int, virtual_id: int):
self.chat_activity = chat_activity
self.account = int(account)
self.dialog_id = int(dialog_id)
self.topic_id = int(topic_id)
self.anchor_msg_id = int(anchor_msg_id)
self.virtual_id = int(virtual_id)
self.message_object: Optional[Any] = None
# Every id this injection has ever placed in the list. reanchor()
# changes the id mid-flight, so an update must be able to find and
# remove the card under its previous id instead of adding a second one.
self._used_ids = {int(virtual_id)}
def insert(
self,
answer_markdown: str = "",
thinking: str = "",
streaming: bool = True,
status: str = "",
) -> bool:
messages_list = get_messages_list(self.chat_activity)
if messages_list is None:
diagnostics.note("injector: ChatActivity messages list not found")
return False
register_mock_sender(self.account)
message_object = build_summary_message(
self.account,
self.dialog_id,
self.topic_id,
self.anchor_msg_id,
self.virtual_id,
answer_markdown,
thinking,
streaming,
status,
)
if message_object is None:
return False
self.message_object = message_object
existing = find_existing(messages_list, self.virtual_id)
index = existing if existing >= 0 else find_index_for_anchor(messages_list, self.anchor_msg_id)
def run():
try:
if existing >= 0:
messages_list.set(index, message_object)
else:
messages_list.add(index, message_object)
adapter = get_chat_adapter(self.chat_activity)
if adapter is not None:
adapter.notifyDataSetChanged()
# The analyzed range can end far up the history, so the card
# may be injected off-screen. Bring it into view once, on the
# first insert only -- scrolling on every streaming update
# would fight the user for control of the list.
if existing < 0:
self.scroll_into_view()
except Exception as exc:
diagnostics.record_error("injector.insert", exc)
_on_ui(run)
return True
def scroll_into_view(self) -> bool:
"""Scrolls the chat so the summary card is visible.
Targets the anchor, not the card: the anchor is a real message the app
can resolve and load, and the card sits directly above it, so both end
up on screen. ChatActivity.scrollToMessageId(id, fromMessageId,
select, loadIndex, forceScroll, unreadCount) -- arity verified in the
APK.
"""
if not self.anchor_msg_id:
return False
try:
self.chat_activity.scrollToMessageId(
int(self.anchor_msg_id), 0, False, 0, True, 0
)
return True
except Exception as exc:
diagnostics.record_error("injector.scroll_into_view", exc)
return False
def update(
self,
answer_markdown: str = "",
thinking: str = "",
streaming: bool = True,
status: str = "",
) -> None:
"""Re-renders the card in place, without rebuilding the whole list.
Mutating rich_message alone is not enough: MessageObject caches
messageText, type, and its measured layout, and ChatMessageCell keeps
its own cached sizes, so a bare swap renders as an empty bubble.
The app's own live-update path (ChatActivity.applyFullRichMessageLocally)
does exactly three things: set rich_message, null richLayout, then call
chatMessageCellDelegate.forceUpdate(cell, true, true). That repaints the
single affected cell and is what keeps a streaming card visually stable.
notifyDataSetChanged() rebinds every row instead, which is what made the
card flicker and lose scroll position on each delta; it is only used as
a fallback when the live cell cannot be found.
"""
if self.message_object is None:
self.insert(answer_markdown, thinking, streaming, status)
return
replacement = build_summary_message(
self.account,
self.dialog_id,
self.topic_id,
self.anchor_msg_id,
self.virtual_id,
answer_markdown,
thinking,
streaming,
status,
)
if replacement is None:
return
self.message_object = replacement
def run():
try:
messages_list = get_messages_list(self.chat_activity)
inserted = False
if messages_list is not None:
# Replace at the current id; drop any card left behind
# under an id this injection used earlier, so a reanchor
# mid-stream cannot leave two cards on screen.
index = find_existing(messages_list, self.virtual_id)
if index >= 0:
messages_list.set(index, replacement)
else:
index = find_index_for_anchor(messages_list, self.anchor_msg_id)
messages_list.add(index, replacement)
inserted = True
for stale_id in self._used_ids:
if stale_id == self.virtual_id:
continue
stale = find_existing(messages_list, stale_id)
if stale >= 0:
messages_list.remove(stale)
inserted = True
# A row was added or removed, so the adapter's item count
# changed and only a full rebind is correct.
if inserted or not self._force_update_cell(replacement):
adapter = get_chat_adapter(self.chat_activity)
if adapter is not None:
adapter.notifyDataSetChanged()
except Exception as exc:
diagnostics.record_error("injector.update", exc)
_on_ui(run)
def _force_update_cell(self, message_object: Any) -> bool:
"""Repaints just this card's cell. Returns False if it is not on screen.
Mirrors ChatActivity.applyFullRichMessageLocally: locate the live
ChatMessageCell bound to this message and hand it to
chatMessageCellDelegate.forceUpdate, which remeasures and redraws that
one row instead of rebinding the whole list.
"""
try:
from hook_utils import get_private_field
delegate = getattr(self.chat_activity, "chatMessageCellDelegate", None) or get_private_field(
self.chat_activity, "chatMessageCellDelegate"
)
list_view = getattr(self.chat_activity, "chatListView", None) or get_private_field(
self.chat_activity, "chatListView"
)
if delegate is None or list_view is None:
return False
for i in range(list_view.getChildCount()):
child = list_view.getChildAt(i)
if child is None or not hasattr(child, "getMessageObject"):
continue
bound = child.getMessageObject()
owner = getattr(bound, "messageOwner", None) if bound is not None else None
if owner is None or int(getattr(owner, "id", 0) or 0) != self.virtual_id:
continue
# setMessageObject(messageObject, groupedMessages, pinnedBottom,
# pinnedTop, firstInChat) -- verified arity against the APK.
child.setMessageObject(message_object, None, False, False, False)
delegate.forceUpdate(child, False, True)
child.invalidate()
return True
except Exception as exc:
diagnostics.record_error("injector._force_update_cell", exc)
return False
def reanchor(self, anchor_msg_id: int, virtual_id: int) -> None:
"""Re-targets an already-visible card once the analyzed range is known.
The card is injected on click, before any messages are fetched, so both
the anchor and the DB row id only become known later. Moving it keeps
the promise that a summary sits directly after its newest analyzed
message.
"""
anchor_msg_id = int(anchor_msg_id)
virtual_id = int(virtual_id)
if anchor_msg_id == self.anchor_msg_id and virtual_id == self.virtual_id:
return
message_object = self.message_object
old_virtual_id = self.virtual_id
self.anchor_msg_id = anchor_msg_id
self.virtual_id = virtual_id
self._used_ids.add(virtual_id)
if message_object is None:
return
def run():
try:
owner = message_object.messageOwner
messages_list = get_messages_list(self.chat_activity)
# Locate the card by its OLD id before rewriting it, otherwise
# the lookup misses and the card is never moved.
current = find_existing(messages_list, old_virtual_id) if messages_list is not None else -1
owner.id = virtual_id
try:
header = _tlrpc().TL_messageReplyHeader()
header.reply_to_msg_id = anchor_msg_id
if self.topic_id:
header.reply_to_top_id = int(self.topic_id)
owner.reply_to = header
except Exception as exc:
diagnostics.record_error("injector.reanchor.reply_header", exc)
if messages_list is not None and current >= 0:
messages_list.remove(current)
# A card may already sit under the new id -- e.g. the
# restore hook re-injected this summary's saved row. Claim
# that id rather than ending up with two cards.
duplicate = find_existing(messages_list, virtual_id)
if duplicate >= 0:
messages_list.remove(duplicate)
target = find_index_for_anchor(messages_list, anchor_msg_id)
messages_list.add(target, message_object)
adapter = get_chat_adapter(self.chat_activity)
if adapter is not None:
adapter.notifyDataSetChanged()
# The card just moved to the real end of the analyzed range,
# which may be somewhere else entirely, so follow it there.
self.scroll_into_view()
except Exception as exc:
diagnostics.record_error("injector.reanchor", exc)
_on_ui(run)
def _on_ui(fn) -> None:
try:
from android_utils import run_on_ui_thread
run_on_ui_thread(fn)
except Exception:
fn()
def restore_saved_summaries(chat_activity: Any, account: int, dialog_id: int, topic_id: int, summaries) -> int:
"""Re-injects persisted summaries into a freshly opened chat. Returns count."""
messages_list = get_messages_list(chat_activity)
if messages_list is None or not summaries:
return 0
register_mock_sender(account)
pending = []
for summary in summaries:
virtual_id = virtual_id_for(summary.id, summary.anchor_msg_id)
if find_existing(messages_list, virtual_id) >= 0:
continue
message_object = build_summary_message(
account,
dialog_id,
topic_id,
summary.anchor_msg_id,
virtual_id,
summary.text,
summary.thinking,
False,
)
if message_object is not None:
pending.append((summary.anchor_msg_id, message_object))
if not pending:
return 0
# Newest anchor first: each insert shifts everything below it, so placing
# the newest card first keeps the remaining lookups pointing at unmoved
# rows. The index is recomputed per card against the live list anyway.
pending.sort(key=lambda item: item[0], reverse=True)
def run():
try:
for anchor_msg_id, message_object in pending:
messages_list.add(find_index_for_anchor(messages_list, anchor_msg_id), message_object)
adapter = get_chat_adapter(chat_activity)
if adapter is not None:
adapter.notifyDataSetChanged()
except Exception as exc:
diagnostics.record_error("injector.restore", exc)
_on_ui(run)
return len(pending)