Inject the summary card only once the answer starts streaming
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>
This commit is contained in:
lyuksovannyyandClaude Opus 5.5 committed 2026-10-06 23:19:06 +02:00
1 parent b2105ca753
commit 16bf8de84c
10 files changed
+1123 -382

No files matched your search

+233 -174
View File
@@ -16,7 +16,8 @@ Verified against exteraGram 12.5.x (dex inspection):
from typing import Any, Optional
import diagnostics
from services.rich_markdown import build_rich_message
from config import PLACEMENT_RETRIES, PLACEMENT_RETRY_DELAY_MS, SUMMARY_HASHTAG
from services.rich_markdown import build_rich_message, hashtag_block, markdown_to_plain
# Above Telegram's issued id range, so this user exists only locally.
MOCK_SENDER_ID = 8000000000000001
@@ -108,8 +109,15 @@ def build_summary_message(
thinking: str = "",
streaming: bool = False,
status: str = "",
tagged: bool = False,
) -> Optional[Any]:
"""Builds the local MessageObject for a summary, or None on failure."""
"""Builds the local MessageObject for a summary, or None on failure.
`tagged` marks a finished summary: SUMMARY_HASHTAG is appended to the
card as a native hashtag node (TL_iv.textHashtag), and to the message's
plain-text body with a TL_messageEntityHashtag. The tag is added here rather than stored, so every
summary restored from the DB carries it too.
"""
try:
import time
@@ -124,7 +132,6 @@ def build_summary_message(
msg.unread = False
msg.post = False
msg.flags = 0
msg.message = ""
msg.peer_id = peer_for_dialog(dialog_id)
sender = TLRPC.TL_peerUser()
@@ -143,7 +150,12 @@ def build_summary_message(
except Exception as exc:
diagnostics.record_error("injector.reply_header", exc)
tagged = tagged and bool((answer_markdown or "").strip())
_set_plain_body(msg, answer_markdown, tagged, TLRPC)
msg.rich_message = build_rich_message(answer_markdown, thinking, streaming, status)
if tagged:
msg.rich_message.blocks.add(hashtag_block(SUMMARY_HASHTAG))
msg.flags2 = int(getattr(msg, "flags2", 0) or 0) | FLAGS2_RICH_MESSAGE
message_object = MessageObject(account, msg, True, True)
@@ -156,6 +168,33 @@ def build_summary_message(
return None
def _utf16_len(text: str) -> int:
"""Length in UTF-16 code units, which is what Telegram entity offsets count."""
return len(text.encode("utf-16-le")) // 2
def _set_plain_body(msg: Any, answer_markdown: str, tagged: bool, TLRPC: Any) -> None:
"""Fills `message` with the summary as plain text.
The card renders from rich_message, but copy, translate and any text
matching in the app read `message`. Left empty, a summary is invisible to
all of them.
"""
text = markdown_to_plain(answer_markdown)
if tagged:
text = (text + "\n\n" + SUMMARY_HASHTAG) if text else SUMMARY_HASHTAG
msg.message = text
if not tagged:
return
try:
entity = TLRPC.TL_messageEntityHashtag()
entity.offset = _utf16_len(text) - _utf16_len(SUMMARY_HASHTAG)
entity.length = _utf16_len(SUMMARY_HASHTAG)
msg.entities.add(entity)
except Exception as exc:
diagnostics.record_error("injector.hashtag_entity", exc)
def get_messages_list(chat_activity: Any) -> Optional[Any]:
"""Returns the ChatActivity's live ArrayList<MessageObject>, or None."""
try:
@@ -260,7 +299,12 @@ def notify_adapter(chat_activity: Any) -> None:
class SummaryInjection:
"""A single injected summary message, updatable in place while streaming."""
"""A single injected summary message, updatable in place while streaming.
Created only once the summary has started arriving, with its final anchor
and virtual id already known: nothing about it is provisional, so it is
placed exactly once, directly after the newest analyzed message.
"""
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
@@ -270,25 +314,13 @@ class SummaryInjection:
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)}
# True once the card is in the list. Until then renders only replace
# message_object, which placement reads when it finally lands.
self.placed = False
self._placing = False
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(
def _build(self, answer_markdown: str, thinking: str, streaming: bool, status: str, tagged: bool) -> Optional[Any]:
return build_summary_message(
self.account,
self.dialog_id,
self.topic_id,
@@ -298,81 +330,143 @@ class SummaryInjection:
thinking,
streaming,
status,
tagged,
)
def insert(
self,
answer_markdown: str = "",
thinking: str = "",
streaming: bool = True,
status: str = "",
tagged: bool = False,
) -> bool:
"""Places the card after its anchor and jumps the chat to it.
Returns False only when the chat cannot take an injected card at all.
An anchor that is not loaded yet is not a failure: the chat is asked to
load it and placement retries until it arrives.
"""
if get_messages_list(self.chat_activity) is None:
diagnostics.note("injector: ChatActivity messages list not found")
return False
register_mock_sender(self.account)
message_object = self._build(answer_markdown, thinking, streaming, status, tagged)
if message_object is None:
return False
self.message_object = message_object
# The index must be computed on the UI thread, immediately before the
# mutation that uses it. Computing it here would race: loading a large
# range takes long enough for paging or an incoming message to shift the
# list, and the stale index then lands the card after the wrong message.
def run():
try:
messages_list_now = get_messages_list(self.chat_activity)
if messages_list_now is None:
return
existing = find_existing(messages_list_now, self.virtual_id)
if existing >= 0:
messages_list_now.set(existing, message_object)
else:
# A live card is a direct response to the user pressing
# Summarize, so it must become visible even when the
# provisional anchor is not loaded (a large offset reaches
# past the loaded window). Fall back to id ordering, which
# still keeps every newer message below the card, and let
# reanchor() move it to the exact spot once the analyzed
# range is known. Restoring saved summaries stays strict --
# there is no follow-up correction there.
index = find_index_for_anchor(messages_list_now, self.anchor_msg_id)
if index < 0:
index = find_index_for_anchor(
messages_list_now, self.anchor_msg_id, require_anchor=False
)
if index < 0:
# No anchor at all (nothing loaded yet, or the offset
# reached past everything). Show the card at the bottom,
# where the user is looking after pressing Summarize;
# reanchor() relocates it once the range is known.
diagnostics.note(
f"injector: anchor {self.anchor_msg_id} unresolved, "
"showing status card at the bottom"
)
index = 0
messages_list_now.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)
self._placing = True
_on_ui(lambda: self._place(PLACEMENT_RETRIES))
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.
def _place(self, retries_left: int) -> None:
"""Runs on the UI thread. The index is computed immediately before the
mutation that uses it: computed earlier, paging or an incoming message
could shift the list and land the card after the wrong message.
"""
if not self.anchor_msg_id:
return False
try:
self.chat_activity.scrollToMessageId(
int(self.anchor_msg_id), 0, False, 0, True, 0
messages_list = get_messages_list(self.chat_activity)
if messages_list is None:
self._placing = False
return
message_object = self.message_object
index = find_existing(messages_list, self.virtual_id)
if index >= 0:
# A card for this summary is already on screen -- the restore
# hook re-injected the saved row being re-summarized. Claim it.
messages_list.set(index, message_object)
else:
index = find_index_for_anchor(messages_list, self.anchor_msg_id)
if index < 0:
if retries_left == PLACEMENT_RETRIES:
# The analyzed range ends outside the loaded window
# (an offset, or history scrolled away). Have the chat
# load and show it, then place once it is in the list.
self._load_anchor()
if retries_left > 0:
_on_ui(lambda: self._place(retries_left - 1), PLACEMENT_RETRY_DELAY_MS)
else:
self._placing = False
diagnostics.note(
f"injector: anchor {self.anchor_msg_id} never loaded, card not placed"
)
return
messages_list.add(index, message_object)
# A render may have landed while this was queued; show the newest.
if self.message_object is not message_object:
messages_list.set(index, self.message_object)
self.placed = True
self._placing = False
adapter = get_chat_adapter(self.chat_activity)
if adapter is not None:
adapter.notifyDataSetChanged()
# Once, on placement only -- scrolling on every streaming update
# would fight the user for control of the list.
self.scroll_into_view()
except Exception as exc:
self._placing = False
diagnostics.record_error("injector.place", exc)
def _load_anchor(self) -> None:
"""Asks the chat to load the anchor and scroll to it.
ChatActivity.scrollToMessageId(id, fromMessageId, select, loadIndex,
forceScroll, unreadCount) -- arity verified in the APK. For an
unloaded id it requests history around it and jumps there on arrival.
"""
try:
self.chat_activity.scrollToMessageId(int(self.anchor_msg_id), 0, False, 0, True, 0)
except Exception as exc:
diagnostics.record_error("injector.load_anchor", exc)
def scroll_into_view(self) -> bool:
"""Jumps the chat so the card sits a third of the way down the screen,
with its anchor directly above it.
A hard jump, not scrollToMessageId: for a message already in the list
that only does smoothScrollBy() from the current cell positions, which
are stale right after an insert (the layout pass has not run yet), so
it undershoots or does nothing. Setting the layout manager's pending
position is applied on the next layout, after the insert, and is what
ChatActivity itself does when it must land on a specific row.
"""
if self._jump_to_card():
return True
self._load_anchor()
return False
def _jump_to_card(self) -> bool:
try:
from hook_utils import get_private_field
messages_list = get_messages_list(self.chat_activity)
adapter = get_chat_adapter(self.chat_activity)
layout_manager = getattr(self.chat_activity, "chatLayoutManager", None) or get_private_field(
self.chat_activity, "chatLayoutManager"
)
list_view = _chat_list_view(self.chat_activity)
if messages_list is None or adapter is None or layout_manager is None:
return False
index = find_existing(messages_list, self.virtual_id)
if index < 0:
return False
# Public on ChatActivityAdapter; already current, because
# notifyDataSetChanged() runs updateRowsInternal() before returning.
position = int(adapter.messagesStartRow) + index
offset = 0
if list_view is not None:
try:
list_view.stopScroll()
except Exception:
pass
offset = int(list_view.getMeasuredHeight()) // 3
# bottom=False: offset counts from the top edge, as in
# ChatActivity.scrollToMessageId's own scrollToPosition call.
layout_manager.scrollToPositionWithOffset(position, offset, False)
return True
except Exception as exc:
diagnostics.record_error("injector.scroll_into_view", exc)
diagnostics.record_error("injector.jump_to_card", exc)
return False
def update(
@@ -381,6 +475,7 @@ class SummaryInjection:
thinking: str = "",
streaming: bool = True,
status: str = "",
tagged: bool = False,
) -> None:
"""Re-renders the card in place, without rebuilding the whole list.
@@ -397,32 +492,28 @@ class SummaryInjection:
a fallback when the live cell cannot be found.
"""
if self.message_object is None:
self.insert(answer_markdown, thinking, streaming, status)
self.insert(answer_markdown, thinking, streaming, status, tagged)
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,
)
replacement = self._build(answer_markdown, thinking, streaming, status, tagged)
if replacement is None:
return
self.message_object = replacement
if not self.placed:
# Placement is still waiting for the anchor and will show this
# render. If it gave up, give it one more look now: the user may
# have scrolled the anchor in since.
if not self._placing:
self._placing = True
_on_ui(lambda: self._place(0))
return
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)
@@ -434,16 +525,9 @@ class SummaryInjection:
return
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.
# A row was added, 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:
@@ -467,9 +551,7 @@ class SummaryInjection:
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"
)
list_view = _chat_list_view(self.chat_activity)
if delegate is None or list_view is None:
return False
@@ -491,80 +573,56 @@ class SummaryInjection:
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
def _chat_list_view(chat_activity: Any) -> Optional[Any]:
try:
from hook_utils import get_private_field
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
return getattr(chat_activity, "chatListView", None) or get_private_field(chat_activity, "chatListView")
except Exception:
return None
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)
def remove_cards(chat_activity: Any, virtual_ids) -> None:
"""Drops injected cards from an open chat's list.
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)
# The card was just removed, so it must go back somewhere:
# the new anchor came from the loaded list and is normally
# present, but if it paged out in between, fall back to id
# ordering rather than dropping the card entirely.
target = find_index_for_anchor(messages_list, anchor_msg_id)
if target < 0:
target = find_index_for_anchor(
messages_list, anchor_msg_id, require_anchor=False
)
messages_list.add(max(target, 0), message_object)
adapter = get_chat_adapter(self.chat_activity)
ChatActivity removes deleted messages by looking them up in messagesDict,
which injected cards are never added to, so a deleted card would otherwise
stay on screen.
"""
wanted = {int(v) for v in virtual_ids}
if chat_activity is None or not wanted:
return
def run():
try:
messages_list = get_messages_list(chat_activity)
if messages_list is None:
return
removed = 0
for i in range(messages_list.size() - 1, -1, -1):
owner = getattr(messages_list.get(i), "messageOwner", None)
if owner is not None and int(getattr(owner, "id", 0) or 0) in wanted:
messages_list.remove(i)
removed += 1
if removed:
adapter = get_chat_adapter(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)
except Exception as exc:
diagnostics.record_error("injector.remove_cards", exc)
_on_ui(run)
_on_ui(run)
def _on_ui(fn) -> None:
def _on_ui(fn, delay_ms: int = 0) -> None:
try:
from android_utils import run_on_ui_thread
run_on_ui_thread(fn)
if delay_ms:
run_on_ui_thread(fn, delay_ms)
else:
run_on_ui_thread(fn)
except Exception:
fn()
@@ -621,6 +679,7 @@ def restore_saved_summaries(chat_activity: Any, account: int, dialog_id: int, to
summary.text,
summary.thinking,
False,
tagged=True,
)
if message_object is not None:
pending.append((summary.anchor_msg_id, virtual_id, message_object))