3 Commits
Author SHA1 Message Date
lyuksovannyyandClaude Opus 5.5 af23d2e682 Keep the chat on a far-back summary card after its anchor loads
Publish plugin / publish (push) Successful in 3s
With an offset the newest analyzed message is usually outside the loaded
window, so placement asks the chat to load it first. That path could leave the
view away from the card while offset 0 always worked:

- Placement gave up after ~3 s (25 x 120 ms) and asked for the anchor only
  once. scrollToMessageId drops the request while waitingForGetDifference, and
  a load can be superseded. It now waits ~10 s and re-requests every ~2 s.
- Loading a far anchor makes ChatActivity run its own postponed scroll, and a
  cache-then-server reload can re-scroll or replace the list. Either can land
  after the jump. Settle checks at 0.25/0.7/1.5/3 s re-jump if the card row is
  outside the visible range, and re-place the card if a reload dropped it.
  They stop as soon as the user drags the list.

Also logs each run's count/offset, the fetched id range, pre-stream failures,
load requests, placement and jumps to the diagnostics file, since bulletin-only
errors previously left no trace.

Verified on device with count 1000, offset 1000: anchor loaded on the first
retry, card placed after it and the chat jumped there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:52:24 +02:00
lyuksovannyyandClaude Opus 5.5 16bf8de84c 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>
2026-10-06 23:19:06 +02:00
lyuksovannyyandClaude Opus 5.5 b2105ca753 [no bump] Enable git hooks automatically on first bundle run
Publish plugin / publish (push) Successful in 27s
tools/bundle.py now sets `core.hooksPath scripts` the first time it runs in a
clone (skipped in CI, outside a git work tree, or when core.hooksPath is
already set), so the version auto-bump hooks work in fresh clones and repos
created from the template without a manual setup step. README documents the
hooks, the self-enabling behaviour and the bump stop words.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 12:56:23 +02:00
13 changed files with 1428 additions and 389 deletions

No files matched your search

+75 -7
View File
@@ -28,6 +28,31 @@ redirects `sys.stdout`/`sys.stderr` to the logcat tags `python.stdout`/`python.s
This repo does not use the official Elyx/ElyxBuilder archive format — see This repo does not use the official Elyx/ElyxBuilder archive format — see
`docs/sdk-notes.md` for why. `docs/sdk-notes.md` for why.
## What the plugin does
**Summarize** (⋮ → Plugins → Summarize, or hold the unread button) runs in this order:
1. A bulletin confirms the press, then the history is fetched.
2. The transcript streams to the configured OpenAI-compatible endpoint.
3. Only when the first chunk arrives is a summary card injected, directly after the newest
analyzed message. If that message is not loaded (an offset, or history scrolled away),
the chat is asked to load it first. The chat then jumps to the card once.
4. Reasoning and answer stream into the card. The finished summary ends with a native
`#aisummaries` hashtag and is saved to a local SQLite store, from which it is restored
whenever the chat is reopened.
Any failure before step 3 (fetch error, empty history, unreachable host, bad key, HTTP
error) is reported as a bulletin only: the chat is not modified and the view does not move.
A failure mid-stream is written onto the card, and a summary saved earlier for the same
range is kept.
Deleting a card like any other message removes its saved row. The settings page shows how
many summaries are saved and roughly how much space they take, and offers **Clear all
summaries** (with a confirmation step).
Summaries exist only on this device, so Telegram's own search, which runs on the server,
does not find them, including a search for `#aisummaries`.
## Repo layout ## Repo layout
``` ```
@@ -38,7 +63,8 @@ src/ the plugin, written as an ordinary Python package
diagnostics.py bounded in-memory log, mirrored to a file (logcat is restricted here) diagnostics.py bounded in-memory log, mirrored to a file (logcat is restricted here)
providers/ endpoint settings, prompt assembly and SSE streaming providers/ endpoint settings, prompt assembly and SSE streaming
services/ history fetch, anonymized transcript, markdown→RichMessage, injection, SQLite store services/ history fetch, anonymized transcript, markdown→RichMessage, injection, SQLite store
hooks/ Xposed hooks (unread-button entry points, summary restore on chat open) hooks/ Xposed hooks (unread-button entry points, summary restore on chat open,
forgetting a summary when its card is deleted)
ui/ summarize dialog, bulletin and theme helpers ui/ summarize dialog, bulletin and theme helpers
tools/ tools/
bundle.py compiles plugin.json + src/ into dist/<id>.plugin bundle.py compiles plugin.json + src/ into dist/<id>.plugin
@@ -46,7 +72,7 @@ tools/
verify_bundle.py offline harness that proves a built bundle behaves correctly, no phone needed verify_bundle.py offline harness that proves a built bundle behaves correctly, no phone needed
release.py publishes a built bundle to a Gitea release (used by CI) release.py publishes a built bundle to a Gitea release (used by CI)
.gitea/workflows/ CI that builds, verifies and publishes the .plugin to Releases .gitea/workflows/ CI that builds, verifies and publishes the .plugin to Releases
scripts/ bash automation for the ADB debug loop (see below) scripts/ ADB debug-loop automation and the git hooks (see below)
docs/sdk-notes.md condensed SDK API surface gathered from the official docs docs/sdk-notes.md condensed SDK API surface gathered from the official docs
``` ```
@@ -65,6 +91,8 @@ a private import hook (scoped to a per-module `__builtins__` dict, never touchin
files were still on disk. `--dev` (the default) embeds absolute local paths so a debugger files were still on disk. `--dev` (the default) embeds absolute local paths so a debugger
resolves breakpoints against your real source files; `--release` embeds repo-relative paths resolves breakpoints against your real source files; `--release` embeds repo-relative paths
for distribution. Both modes produce byte-reproducible output for a given `src/` + `plugin.json`. for distribution. Both modes produce byte-reproducible output for a given `src/` + `plugin.json`.
Its first run in a clone also enables the repo's git hooks (see
[Git hooks and versioning](#git-hooks-and-versioning)).
## Device prerequisites ## Device prerequisites
@@ -97,6 +125,43 @@ standard `ANDROID_SERIAL` env var for device selection.
- `scripts/fetch-sdk-stubs.sh [--beta]` — downloads the latest SDK type stubs into `stubs/` - `scripts/fetch-sdk-stubs.sh [--beta]` — downloads the latest SDK type stubs into `stubs/`
for editor autocomplete. for editor autocomplete.
## Git hooks and versioning
`scripts/pre-commit` bumps the patch number of `version` in `plugin.json` on every commit
(`1.0.0` → `1.0.1`) and stages it into that same commit. `scripts/post-commit` re-stages
`plugin.json` afterwards, because commits of a file selection (`git commit -- <paths>`, which
some IDEs use) are built from a temporary index and would otherwise leave it looking modified.
**The hooks enable themselves.** Git never activates hooks from repository content, so a
fresh clone, or a repo created from this template, starts without them. The first run of
`tools/bundle.py` fixes that by running `git config core.hooksPath scripts`, and every
workflow runs it: directly, or through `scripts/deploy.sh` and `scripts/watch.sh`. That
includes `--check`, so after cloning run
```bash
python3 tools/bundle.py --check
```
once, before your first commit. A commit made before any build runs without the hooks.
`bundle.py` leaves `core.hooksPath` alone when it is already set (so pointing it elsewhere on
purpose sticks), skips the step in CI (whenever `CI` is set) and outside a git work tree, and
never fails a build over it. To turn the hooks off for a clone, run
`git config core.hooksPath .git/hooks`; `git config --unset core.hooksPath` only lasts
until the next build.
The bump is skipped when:
- the commit message contains `[no bump]`, `[skip bump]`, `[no version bump]` or
`[skip version]` (case-insensitive; `nobump`, `no-bump` and `no_bump` also work),
- `NO_BUMP=1` (or `GIT_NO_BUMP=1`) is set, e.g. `NO_BUMP=1 git commit`, which is the only way
for commits written in an editor, since their message doesn't exist yet when the hook runs,
- `version` in `plugin.json` already differs from `HEAD` (you bumped it by hand, e.g. for a
minor or major release),
- a merge, rebase, cherry-pick or revert is in progress.
Because every version change publishes a release (see below), a normal commit + push to
`main` ships a new patch release. Use a stop word for commits that should not.
## Publishing a release ## Publishing a release
`.gitea/workflows/release.yml` builds, verifies and publishes the `.plugin` to the repo's `.gitea/workflows/release.yml` builds, verifies and publishes the `.plugin` to the repo's
@@ -111,7 +176,8 @@ The `version` field in `plugin.json` is the single source of truth:
| the same `version` | rebuilds the existing release, replacing its attachment | | the same `version` | rebuilds the existing release, replacing its attachment |
So every push leaves a downloadable build attached to the current version, and bumping So every push leaves a downloadable build attached to the current version, and bumping
`version` is what cuts a new release. Pushing a `v*` tag by hand still works and uses that `version` is what cuts a new release. With the git hooks enabled every commit bumps it,
so every push of a normal commit cuts a new patch release. Pushing a `v*` tag by hand still works and uses that
tag verbatim, as does a manual run (Actions → Publish plugin → Run workflow). tag verbatim, as does a manual run (Actions → Publish plugin → Run workflow).
Every path runs `--check`, builds with `--release`, and gates the upload on Every path runs `--check`, builds with `--release`, and gates the upload on
@@ -143,9 +209,11 @@ you want when publishing needs to reach beyond this repo.
## Writing your own plugin ## Writing your own plugin
1. Edit `plugin.json` (id, name, description, etc.). 1. Run `python3 tools/bundle.py --check` once. It enables the git hooks that auto-bump the
2. Write your plugin under `src/`, with `src/main.py` as the entry module (absolute imports version on commit (see [Git hooks and versioning](#git-hooks-and-versioning)).
2. Edit `plugin.json` (id, name, description, etc.).
3. Write your plugin under `src/`, with `src/main.py` as the entry module (absolute imports
only — it's executed at plugin-module top level, not inside a package). only — it's executed at plugin-module top level, not inside a package).
3. `python3 tools/bundle.py --release && python3 tools/verify_bundle.py dist/<id>.plugin` 4. `python3 tools/bundle.py --release && python3 tools/verify_bundle.py dist/<id>.plugin`
(substitute your `id` from `plugin.json`). (substitute your `id` from `plugin.json`).
4. `bash scripts/deploy.sh --enable` with a device connected. 5. `bash scripts/deploy.sh --enable` with a device connected.
+35
View File
@@ -78,6 +78,41 @@ plugins that need to reach beyond the public SDK surface.
- `intents.IntentsManager` — Android intent integration, **exteraGram 12.6.4+ only**. - `intents.IntentsManager` — Android intent integration, **exteraGram 12.6.4+ only**.
<https://plugins.exteragram.app/docs/intents> <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 ## On-device dev server protocol
<https://plugins.exteragram.app/docs/dev-server> <https://plugins.exteragram.app/docs/dev-server>
+5 -2
View File
@@ -3,9 +3,12 @@
"name": "Chat Summaries", "name": "Chat Summaries",
"description": "Summarize the last N messages (with offset) in any chat, group, channel, or topic using AI.", "description": "Summarize the last N messages (with offset) in any chat, group, channel, or topic using AI.",
"author": "@lyuksovannyy", "author": "@lyuksovannyy",
"version": "1.0.3", "version": "1.1.1",
"icon": "exteraPlugins/1", "icon": "exteraPlugins/1",
"app_version": ">=12.5.1", "app_version": ">=12.5.1",
"sdk_version": ">=1.4.4.3", "sdk_version": ">=1.4.4.3",
"requirements": ["requests", "debugpy"] "requirements": [
"requests",
"debugpy"
]
} }
+2 -1
View File
@@ -1,5 +1,6 @@
#!/bin/sh #!/bin/sh
# Companion to scripts/pre-commit. # Companion to scripts/pre-commit. Both are enabled per clone by tools/bundle.py
# (it sets `core.hooksPath scripts` on its first run).
# #
# `git commit -- <pathspec>` (also how some IDEs commit a file selection) builds # `git commit -- <pathspec>` (also how some IDEs commit a file selection) builds
# the commit from a temporary index. The pre-commit bump therefore lands in the # the commit from a temporary index. The pre-commit bump therefore lands in the
+3
View File
@@ -1,6 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Increment the patch component of plugin.json's version as part of the commit. """Increment the patch component of plugin.json's version as part of the commit.
Enabled per clone by tools/bundle.py, which sets `core.hooksPath scripts` on its
first run (git never enables hooks from repository content by itself).
Runs as a pre-commit hook, which is the only commit hook whose `git add` is Runs as a pre-commit hook, which is the only commit hook whose `git add` is
picked up by the commit currently being created: picked up by the commit currently being created:
+22
View File
@@ -28,6 +28,9 @@ SETTING_DEFAULT_COUNT = "default_count"
SETTING_DEFAULT_OFFSET = "default_offset" SETTING_DEFAULT_OFFSET = "default_offset"
SETTING_GLOBAL_PROMPT = "global_prompt" SETTING_GLOBAL_PROMPT = "global_prompt"
SETTING_INCLUDE_ATTACHMENTS = "include_attachments" SETTING_INCLUDE_ATTACHMENTS = "include_attachments"
# Not a user setting: writing it with reload_settings=True is how the
# settings page is re-rendered after the store changes.
SETTING_STORAGE_REVISION = "storage_revision"
DEFAULT_PROMPT = ( DEFAULT_PROMPT = (
"Summarize the following chat conversation. Focus on key points, " "Summarize the following chat conversation. Focus on key points, "
@@ -46,3 +49,22 @@ LONG_PRESS_SECONDS = 0.4
# rebuilds the RichMessage blocks and rebinds the cell, so pushing on every SSE # rebuilds the RichMessage blocks and rebinds the cell, so pushing on every SSE
# chunk would burn the UI thread for no visible gain. # chunk would burn the UI thread for no visible gain.
STREAM_UPDATE_INTERVAL = 0.25 STREAM_UPDATE_INTERVAL = 0.25
# Appended to every finished summary. Lets a reader tell a local AI summary
# from a real message at a glance, and gives copied or forwarded text a
# searchable marker.
SUMMARY_HASHTAG = "#aisummaries"
# A summary card can only be placed directly after its newest analyzed
# message, which with an offset may not be loaded yet. Placement asks the chat
# to load it, then retries this many times, this many ms apart (~10 s), asking
# again every PLACEMENT_RELOAD_EVERY retries in case a request was dropped.
PLACEMENT_RETRIES = 80
PLACEMENT_RETRY_DELAY_MS = 125
PLACEMENT_RELOAD_EVERY = 16
# After jumping to a fresh card, check at these delays (ms) that it is still on
# screen and jump again if not. Loading a far anchor makes ChatActivity run its
# own postponed scroll, and a cache-then-server reload can re-scroll it, either
# of which can land after the jump and carry the view away from the card.
JUMP_SETTLE_DELAYS_MS = (250, 700, 1500, 3000)
+84
View File
@@ -0,0 +1,84 @@
"""Forgets a summary when the user deletes its card.
Deleting goes through MessagesController.deleteMessages, whichever menu it
starts from. Injected cards have negative ids, which that method never sends
to the server (only ids > 0 are), so letting the call through is harmless; it
just never touches the plugin's own store. This hook does: any deleted id that
is a stored summary's virtual id removes that row, so the card does not come
back the next time the chat is opened.
Every deleteMessages overload is hooked. They delegate to one another, so one
delete can be seen several times; handling is idempotent and only the call
that actually removed a row reports it.
"""
from typing import Any, List
import diagnostics
from hooks.restore_hook import _find_methods_by_name
def _java_ints(value: Any) -> List[int]:
"""ArrayList<Integer> as Chaquopy exposes it: size()/get(), not iterable."""
if value is None:
return []
try:
return [int(value.get(i)) for i in range(value.size())]
except Exception:
try:
return [int(v) for v in value]
except Exception:
return []
class DeleteHook:
def __init__(self, plugin):
self.plugin = plugin
self._unhooks: List[Any] = []
def install(self) -> None:
try:
from base_plugin import MethodHook
from hook_utils import find_class
except Exception as exc:
diagnostics.record_error("delete_hook.install:imports", exc)
return
controller_class = find_class("org.telegram.messenger.MessagesController")
if not controller_class:
diagnostics.note("delete_hook.install: MessagesController not found")
return
methods = _find_methods_by_name(controller_class, "deleteMessages")
diagnostics.note(f"delete_hook.install: deleteMessages overloads found={len(methods)}")
plugin = self.plugin
class _OnDelete(MethodHook):
def before_hooked_method(self, param):
try:
args = list(getattr(param, "args", None) or [])
# Every overload starts (messages, randoms, encryptedChat,
# dialogId, ...).
if len(args) < 4:
return
ids = [i for i in _java_ints(args[0]) if i < 0]
if not ids:
return
plugin.on_cards_deleted(int(args[3] or 0), ids)
except Exception as exc:
diagnostics.record_error("delete_hook.before_hooked_method", exc)
for method in methods:
try:
ref = self.plugin.hook_method(method, _OnDelete())
if ref:
self._unhooks.append(ref)
except Exception as exc:
diagnostics.record_error("delete_hook.install:hook_method", exc)
def uninstall(self) -> None:
for ref in self._unhooks:
try:
ref.unhook()
except Exception:
pass
self._unhooks = []
+210 -97
View File
@@ -21,8 +21,10 @@ from config import (
SETTING_DEFAULT_OFFSET, SETTING_DEFAULT_OFFSET,
SETTING_GLOBAL_PROMPT, SETTING_GLOBAL_PROMPT,
SETTING_INCLUDE_ATTACHMENTS, SETTING_INCLUDE_ATTACHMENTS,
SETTING_STORAGE_REVISION,
STREAM_UPDATE_INTERVAL, STREAM_UPDATE_INTERVAL,
) )
from hooks.delete_hook import DeleteHook
from hooks.restore_hook import RestoreHook from hooks.restore_hook import RestoreHook
from hooks.unread_hook import UnreadHook from hooks.unread_hook import UnreadHook
from providers.dispatcher import Dispatcher from providers.dispatcher import Dispatcher
@@ -37,6 +39,7 @@ class ChatSummariesPlugin(BasePlugin):
self.dispatcher = Dispatcher(self) self.dispatcher = Dispatcher(self)
self.unread_hook = UnreadHook(self) self.unread_hook = UnreadHook(self)
self.restore_hook = RestoreHook(self) self.restore_hook = RestoreHook(self)
self.delete_hook = DeleteHook(self)
self.summary_db = None self.summary_db = None
def _get_summary_db(self): def _get_summary_db(self):
@@ -83,10 +86,15 @@ class ChatSummariesPlugin(BasePlugin):
self.restore_hook.install() self.restore_hook.install()
except Exception as exc: except Exception as exc:
diagnostics.record_error("on_plugin_load:restore_hook.install", exc) diagnostics.record_error("on_plugin_load:restore_hook.install", exc)
try:
self.delete_hook.install()
except Exception as exc:
diagnostics.record_error("on_plugin_load:delete_hook.install", exc)
def on_plugin_unload(self) -> None: def on_plugin_unload(self) -> None:
self.unread_hook.uninstall() self.unread_hook.uninstall()
self.restore_hook.uninstall() self.restore_hook.uninstall()
self.delete_hook.uninstall()
log("chat_summaries unloaded") log("chat_summaries unloaded")
def create_settings(self): def create_settings(self):
@@ -140,9 +148,22 @@ class ChatSummariesPlugin(BasePlugin):
"summary can mention shared files. Message text is sent either way.", "summary can mention shared files. Message text is sent either way.",
icon="msg_photos", icon="msg_photos",
), ),
Divider(text="Storage"),
Text(
text="Saved summaries",
subtext=self._storage_summary(),
icon="msg_storage_usage",
),
Text(
text="Clear all summaries",
icon="msg_delete",
red=True,
on_click=self._on_clear_all_click,
),
Divider(),
Text( Text(
text="About", text="About",
subtext="Summarize via ⋮ → Plugins → Summarize, or hold the unread button to auto-fill unread messages. Diagnostics are also available from that menu.", subtext="Summarize via ⋮ → Plugins → Summarize, or hold the unread button to auto-fill unread messages. Diagnostics are also available from that menu. Delete a summary card like any message to forget it.",
icon="msg_info", icon="msg_info",
), ),
] ]
@@ -245,117 +266,101 @@ class ChatSummariesPlugin(BasePlugin):
offset: int, offset: int,
prompt: str, prompt: str,
) -> None: ) -> None:
"""Shows the summary card immediately, then drives it through every """Fetches history, then streams the summary into a card.
stage: fetching history, contacting the endpoint, streaming reasoning,
streaming the answer.
The card is injected before the fetch so the click has instant visible Nothing is injected into the chat until the endpoint has actually
feedback. Its anchor is provisional until the fetch returns (the newest started answering. Every failure before that point -- history fetch,
analyzed message id is not known yet) and is corrected via reanchor(). unreachable host, bad key, HTTP error, empty history -- is reported as a
bulletin and leaves the chat exactly as the user left it: no error
bubble to dismiss, no jump away from where they were reading.
""" """
from services.rich_injector import SummaryInjection, virtual_id_for diagnostics.note(
f"summary: dialog={dialog_id} topic={topic_id} count={count} offset={offset}"
account = self._get_account()
# With a non-zero offset the analyzed range ends partway up the
# history, so skip that many messages when guessing where the card
# goes. reanchor() corrects it exactly once the fetch returns.
provisional_anchor = self._newest_visible_message_id(fragment, skip=offset)
injection = SummaryInjection(
fragment,
account,
dialog_id,
topic_id,
provisional_anchor,
virtual_id_for(0, provisional_anchor or 1),
) )
if not injection.insert(status="Fetching messages…", streaming=True): BulletinHelper.show_info(f"Chat Summaries: reading {count} messages…", fragment)
BulletinHelper.show_error("Chat Summaries: cannot inject into this chat", fragment)
return
def fail(message: str) -> None:
injection.update(answer_markdown=f"**Error:** {message}", streaming=False)
BulletinHelper.show_error(f"Chat Summaries: {message}", fragment)
def on_fetched(messages, error): def on_fetched(messages, error):
if error: if error:
fail(str(error)) self._report_error(str(error), fragment)
return return
if not messages: if not messages:
fail("no messages found") self._report_error("no messages found", fragment)
return return
try: try:
self._stream_into(injection, fragment, dialog_id, topic_id, prompt, messages) self._stream_into(fragment, dialog_id, topic_id, prompt, messages)
except Exception as exc: except Exception as exc:
diagnostics.record_error("on_fetched:_stream_into", exc) diagnostics.record_error("on_fetched:_stream_into", exc)
fail(str(exc)) self._report_error(str(exc), fragment)
self.fetcher.fetch_history(dialog_id, count, offset, topic_id, on_fetched) self.fetcher.fetch_history(dialog_id, count, offset, topic_id, on_fetched)
def _newest_visible_message_id(self, chat_activity: Any, skip: int = 0) -> int: def _report_error(self, message: str, fragment: Any) -> None:
"""Provisional anchor: the newest real message the analysis will cover. diagnostics.note(f"summary failed: {message}")
BulletinHelper.show_error(f"Chat Summaries: {message}", fragment)
`skip` mirrors the summarize offset, so with an offset the guess lands
where the analyzed range actually ends instead of at the bottom of the
chat. Injected cards carry negative ids and are ignored.
When the offset reaches past the loaded window there is no message to
skip to, so fall back to the oldest loaded message rather than 0: the
card only needs somewhere valid to appear while "Fetching messages…" is
shown, and reanchor() moves it to the exact spot once the range is
known. Returning 0 would make the insert fail and the user would click
Summarize and see nothing happen.
"""
try:
from services.rich_injector import get_messages_list
messages_list = get_messages_list(chat_activity)
if messages_list is None:
return 0
remaining = max(0, int(skip))
oldest = 0
for i in range(messages_list.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 msg_id <= 0:
continue
oldest = msg_id
if remaining:
remaining -= 1
continue
return msg_id
return oldest
except Exception as exc:
diagnostics.record_error("_newest_visible_message_id", exc)
return 0
def _stream_into( def _stream_into(
self, self,
injection: Any,
fragment: Any, fragment: Any,
dialog_id: int, dialog_id: int,
topic_id: int, topic_id: int,
prompt: str, prompt: str,
messages: list, messages: list,
) -> None: ) -> None:
"""Streams reasoning, then the answer, into the already-visible card.""" """Streams reasoning, then the answer, into a card created on the
from services.rich_injector import virtual_id_for first chunk.
anchor_msg_id = max(int(getattr(m, "id", 0) or 0) for m in messages) The card goes directly after the newest analyzed message and the chat
jumps to it once, when it is placed.
"""
from services.rich_injector import SummaryInjection, virtual_id_for
ids = [int(getattr(m, "id", 0) or 0) for m in messages]
anchor_msg_id = max(ids)
diagnostics.note(
f"summary: fetched {len(messages)} messages, ids {min(ids)}..{anchor_msg_id}"
)
db = self._get_summary_db() db = self._get_summary_db()
# Reserve the row now so the virtual id is stable for the whole stream. state = {
summary_id = 0 "answer": "",
if db is not None: "thinking": "",
try: "last_push": 0.0,
summary_id = db.save(dialog_id, topic_id, anchor_msg_id, "", "", len(messages)) "mapping": None,
except Exception as exc: "injection": None,
diagnostics.record_error("_stream_into:db.reserve", exc) "summary_id": 0,
"created_row": False,
}
injection.reanchor(anchor_msg_id, virtual_id_for(summary_id, anchor_msg_id)) def open_card():
injection.update(status=f"Summarizing {len(messages)} messages…", streaming=True) """Creates and places the card. Runs once, on the first chunk."""
if state["injection"] is not None:
state = {"answer": "", "thinking": "", "last_push": 0.0, "mapping": None} return state["injection"]
# Reserve the row now so the virtual id is stable for the whole
# stream, and matches the card the restore hook may already show
# for this range.
if db is not None:
try:
state["summary_id"], state["created_row"] = db.reserve(
dialog_id, topic_id, anchor_msg_id, len(messages)
)
except Exception as exc:
diagnostics.record_error("_stream_into:db.reserve", exc)
injection = SummaryInjection(
fragment,
self._get_account(),
dialog_id,
topic_id,
anchor_msg_id,
virtual_id_for(state["summary_id"], anchor_msg_id),
)
state["injection"] = injection
if not injection.insert(
answer_markdown=rendered("answer"), thinking=rendered("thinking"), streaming=True
):
# The summary is still saved when it finishes, and the restore
# hook shows it once the chat can take it.
BulletinHelper.show_error("Chat Summaries: cannot show the summary in this chat", fragment)
state["last_push"] = _now()
return injection
def rendered(key: str, final: bool = False) -> str: def rendered(key: str, final: bool = False) -> str:
"""Placeholder-substituted view of `answer` or `thinking`. """Placeholder-substituted view of `answer` or `thinking`.
@@ -378,18 +383,17 @@ class ChatSummariesPlugin(BasePlugin):
return text return text
return deanonymize(text, mapping, dialog_id) return deanonymize(text, mapping, dialog_id)
def push(force: bool = False) -> None: def push(force: bool = False, tagged: bool = False) -> None:
"""Rate-limits re-renders; each one rebuilds blocks and rebinds cells.""" """Rate-limits re-renders; each one rebuilds blocks and rebinds cells."""
import time now = _now()
now = time.monotonic()
if not force and now - state["last_push"] < STREAM_UPDATE_INTERVAL: if not force and now - state["last_push"] < STREAM_UPDATE_INTERVAL:
return return
state["last_push"] = now state["last_push"] = now
injection.update( state["injection"].update(
answer_markdown=rendered("answer", final=force), answer_markdown=rendered("answer", final=force),
thinking=rendered("thinking", final=force), thinking=rendered("thinking", final=force),
streaming=not force, streaming=not force,
tagged=tagged,
) )
def on_mapping(mapping) -> None: def on_mapping(mapping) -> None:
@@ -400,18 +404,32 @@ class ChatSummariesPlugin(BasePlugin):
state["thinking"] += text state["thinking"] += text
else: else:
state["answer"] += text state["answer"] += text
push() if state["injection"] is None:
open_card()
else:
push()
def on_done(answer: Optional[str], thinking: Optional[str], error: Optional[str]) -> None: def on_done(answer: Optional[str], thinking: Optional[str], error: Optional[str]) -> None:
if error: if error:
self._report_error(error, fragment)
if state["injection"] is None:
# Failed before the first chunk: the chat was never touched.
return
state["answer"] = (state["answer"] or "") + f"\n\n**Error:** {error}" state["answer"] = (state["answer"] or "") + f"\n\n**Error:** {error}"
push(force=True) push(force=True)
BulletinHelper.show_error(f"Chat Summaries: {error}", fragment) # A row created for this stream would never get text; don't
# leave it behind. A pre-existing summary keeps its text.
if db is not None and state["created_row"] and state["summary_id"]:
try:
db.delete(state["summary_id"])
except Exception as exc:
diagnostics.record_error("_stream_into:db.delete", exc)
return return
state["answer"] = answer or state["answer"] state["answer"] = answer or state["answer"]
state["thinking"] = thinking or state["thinking"] state["thinking"] = thinking or state["thinking"]
push(force=True) open_card()
if db is not None and summary_id: push(force=True, tagged=True)
if db is not None and state["summary_id"]:
try: try:
# Persist real names and links, not placeholders: the # Persist real names and links, not placeholders: the
# mapping is per-request and would be gone by the time the # mapping is per-request and would be gone by the time the
@@ -425,10 +443,7 @@ class ChatSummariesPlugin(BasePlugin):
# The restore gate skips scopes it has already handled, so # The restore gate skips scopes it has already handled, so
# the newly saved row has to invalidate that cache or it # the newly saved row has to invalidate that cache or it
# would not be restored until the chat was reopened. # would not be restored until the chat was reopened.
try: self.restore_hook.invalidate()
self.restore_hook.invalidate()
except Exception:
pass
except Exception as exc: except Exception as exc:
diagnostics.record_error("_stream_into:db.save", exc) diagnostics.record_error("_stream_into:db.save", exc)
@@ -442,6 +457,90 @@ class ChatSummariesPlugin(BasePlugin):
topic_id=topic_id, topic_id=topic_id,
) )
# ------------------------------------------------------------------
# Deleting a card / clearing the store
# ------------------------------------------------------------------
def on_cards_deleted(self, dialog_id: int, deleted_ids: list) -> None:
"""Called by DeleteHook with the negative ids being deleted in a chat."""
from client_utils import get_last_fragment
from services.rich_injector import remove_cards, virtual_id_for
db = self._get_summary_db()
if db is None or not dialog_id:
return
wanted = {int(i) for i in deleted_ids}
removed = []
try:
for summary in db.get_for_dialog(dialog_id):
virtual_id = virtual_id_for(summary.id, summary.anchor_msg_id)
if virtual_id in wanted and db.delete(summary.id):
removed.append(virtual_id)
except Exception as exc:
diagnostics.record_error("on_cards_deleted", exc)
return
if not removed:
return
# The restore cache still holds the deleted rows; without this the
# card would be re-injected on the next frame.
self.restore_hook.invalidate()
fragment = get_last_fragment()
remove_cards(fragment, removed)
BulletinHelper.show_info(
"Summary deleted" if len(removed) == 1 else f"{len(removed)} summaries deleted",
fragment,
)
def _storage_summary(self) -> str:
db = self._get_summary_db()
if db is None:
return "Storage unavailable"
try:
count, size = db.stats()
except Exception as exc:
diagnostics.record_error("_storage_summary", exc)
return "Storage unavailable"
noun = "summary" if count == 1 else "summaries"
return f"{count} {noun} saved · about {_format_size(size)}"
def _on_clear_all_click(self, view: Any = None) -> None:
try:
from client_utils import get_last_fragment
from ui.alert import AlertDialogBuilder
fragment = get_last_fragment()
activity = fragment.getParentActivity() if fragment is not None else None
if activity is None:
return
builder = AlertDialogBuilder(activity)
builder.set_title("Clear all summaries?")
builder.set_message(
"Every saved summary in every chat will be deleted. This cannot be undone."
)
builder.set_positive_button("Clear", lambda b, w: self._clear_all_summaries())
builder.set_negative_button("Cancel", lambda b, w: b.dismiss())
builder.show()
builder.make_button_red(AlertDialogBuilder.BUTTON_POSITIVE)
except Exception as exc:
diagnostics.record_error("_on_clear_all_click", exc)
def _clear_all_summaries(self) -> None:
db = self._get_summary_db()
if db is None:
return
try:
removed = db.clear_all()
except Exception as exc:
diagnostics.record_error("_clear_all_summaries", exc)
BulletinHelper.show_error("Chat Summaries: could not clear summaries")
return
self.restore_hook.invalidate()
# Re-render the settings page so the size row shows the new total.
self.set_setting(SETTING_STORAGE_REVISION, str(_now()), reload_settings=True)
BulletinHelper.show_success(
"Deleted 1 summary" if removed == 1 else f"Deleted {removed} summaries"
)
def _fetch_reply_context(self, messages: list) -> dict: def _fetch_reply_context(self, messages: list) -> dict:
"""Reply parents that fall outside the analyzed range. """Reply parents that fall outside the analyzed range.
@@ -519,3 +618,17 @@ class ChatSummariesPlugin(BasePlugin):
def _save_prompt_for(self, dialog_id: int, topic_id: int, prompt: str) -> None: def _save_prompt_for(self, dialog_id: int, topic_id: int, prompt: str) -> None:
if prompt: if prompt:
self.set_setting(self._prompt_key(dialog_id, topic_id), prompt) self.set_setting(self._prompt_key(dialog_id, topic_id), prompt)
def _now() -> float:
import time
return time.monotonic()
def _format_size(size: int) -> str:
if size < 1024:
return f"{size} B"
if size < 1024 * 1024:
return f"{size / 1024:.0f} KB"
return f"{size / (1024 * 1024):.1f} MB"
+334 -174
View File
@@ -16,7 +16,14 @@ Verified against exteraGram 12.5.x (dex inspection):
from typing import Any, Optional from typing import Any, Optional
import diagnostics import diagnostics
from services.rich_markdown import build_rich_message from config import (
JUMP_SETTLE_DELAYS_MS,
PLACEMENT_RELOAD_EVERY,
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. # Above Telegram's issued id range, so this user exists only locally.
MOCK_SENDER_ID = 8000000000000001 MOCK_SENDER_ID = 8000000000000001
@@ -108,8 +115,15 @@ def build_summary_message(
thinking: str = "", thinking: str = "",
streaming: bool = False, streaming: bool = False,
status: str = "", status: str = "",
tagged: bool = False,
) -> Optional[Any]: ) -> 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: try:
import time import time
@@ -124,7 +138,6 @@ def build_summary_message(
msg.unread = False msg.unread = False
msg.post = False msg.post = False
msg.flags = 0 msg.flags = 0
msg.message = ""
msg.peer_id = peer_for_dialog(dialog_id) msg.peer_id = peer_for_dialog(dialog_id)
sender = TLRPC.TL_peerUser() sender = TLRPC.TL_peerUser()
@@ -143,7 +156,12 @@ def build_summary_message(
except Exception as exc: except Exception as exc:
diagnostics.record_error("injector.reply_header", 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) 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 msg.flags2 = int(getattr(msg, "flags2", 0) or 0) | FLAGS2_RICH_MESSAGE
message_object = MessageObject(account, msg, True, True) message_object = MessageObject(account, msg, True, True)
@@ -156,6 +174,33 @@ def build_summary_message(
return None 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]: def get_messages_list(chat_activity: Any) -> Optional[Any]:
"""Returns the ChatActivity's live ArrayList<MessageObject>, or None.""" """Returns the ChatActivity's live ArrayList<MessageObject>, or None."""
try: try:
@@ -260,7 +305,12 @@ def notify_adapter(chat_activity: Any) -> None:
class SummaryInjection: 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): 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.chat_activity = chat_activity
@@ -270,25 +320,13 @@ class SummaryInjection:
self.anchor_msg_id = int(anchor_msg_id) self.anchor_msg_id = int(anchor_msg_id)
self.virtual_id = int(virtual_id) self.virtual_id = int(virtual_id)
self.message_object: Optional[Any] = None self.message_object: Optional[Any] = None
# Every id this injection has ever placed in the list. reanchor() # True once the card is in the list. Until then renders only replace
# changes the id mid-flight, so an update must be able to find and # message_object, which placement reads when it finally lands.
# remove the card under its previous id instead of adding a second one. self.placed = False
self._used_ids = {int(virtual_id)} self._placing = False
def insert( def _build(self, answer_markdown: str, thinking: str, streaming: bool, status: str, tagged: bool) -> Optional[Any]:
self, return build_summary_message(
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.account,
self.dialog_id, self.dialog_id,
self.topic_id, self.topic_id,
@@ -298,81 +336,223 @@ class SummaryInjection:
thinking, thinking,
streaming, streaming,
status, 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: if message_object is None:
return False return False
self.message_object = message_object self.message_object = message_object
self._placing = True
# The index must be computed on the UI thread, immediately before the _on_ui(lambda: self._place(PLACEMENT_RETRIES))
# 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)
return True return True
def scroll_into_view(self) -> bool: def _place(self, retries_left: int) -> None:
"""Scrolls the chat so the summary card is visible. """Runs on the UI thread. The index is computed immediately before the
mutation that uses it: computed earlier, paging or an incoming message
Targets the anchor, not the card: the anchor is a real message the app could shift the list and land the card after the wrong message.
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: try:
self.chat_activity.scrollToMessageId( messages_list = get_messages_list(self.chat_activity)
int(self.anchor_msg_id), 0, False, 0, True, 0 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:
attempt = PLACEMENT_RETRIES - retries_left
if retries_left > 0 and attempt % PLACEMENT_RELOAD_EVERY == 0:
# 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.
# Asked again periodically: scrollToMessageId drops the
# request outright while waitingForGetDifference, and
# a load can be superseded by another one.
diagnostics.note(
f"injector: anchor {self.anchor_msg_id} not loaded "
f"({_id_span(messages_list)}), requesting it (attempt {attempt})"
)
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
diagnostics.note(
f"injector: card placed after anchor {self.anchor_msg_id} at index {index} "
f"(retries left {retries_left})"
) )
adapter = get_chat_adapter(self.chat_activity)
if adapter is not None:
adapter.notifyDataSetChanged()
# On placement only -- scrolling on every streaming update would
# fight the user for control of the list. The settle checks right
# after only undo scrolls the app itself makes in that window.
if self.scroll_into_view():
self._schedule_settle(0)
except Exception as exc:
self._placing = False
diagnostics.record_error("injector.place", exc)
def _schedule_settle(self, step: int) -> None:
if step >= len(JUMP_SETTLE_DELAYS_MS):
return
previous = JUMP_SETTLE_DELAYS_MS[step - 1] if step else 0
_on_ui(lambda: self._settle(step), JUMP_SETTLE_DELAYS_MS[step] - previous)
def _settle(self, step: int) -> None:
"""Re-jumps if something scrolled the fresh card off screen.
Stops for good as soon as the user touches the list: from then on
where the chat is scrolled is theirs to decide.
"""
try:
list_view = _chat_list_view(self.chat_activity)
if list_view is not None and int(list_view.getScrollState()) == 1: # SCROLL_STATE_DRAGGING
diagnostics.note("injector: user is scrolling, jump settled")
return
span = self._visible_span()
if span is not None:
position, first, last = span
if not first <= position <= last:
diagnostics.note(
f"injector: card row {position} scrolled away (visible {first}..{last}), "
f"jumping back (check {step})"
)
self._jump_to_card()
elif self.placed and find_existing(get_messages_list(self.chat_activity), self.virtual_id) < 0:
# The list was reloaded under the card. Put it back.
messages_list = get_messages_list(self.chat_activity)
index = find_index_for_anchor(messages_list, self.anchor_msg_id)
if index >= 0:
diagnostics.note(f"injector: list reloaded, re-placing card (check {step})")
messages_list.add(index, self.message_object)
adapter = get_chat_adapter(self.chat_activity)
if adapter is not None:
adapter.notifyDataSetChanged()
self._jump_to_card()
except Exception as exc:
diagnostics.record_error("injector.settle", exc)
self._schedule_settle(step + 1)
def _visible_span(self) -> Optional[tuple]:
"""(card adapter position, first visible, last visible), or None."""
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"
)
if messages_list is None or adapter is None or layout_manager is None:
return None
index = find_existing(messages_list, self.virtual_id)
if index < 0:
return None
first = int(layout_manager.findFirstVisibleItemPosition())
last = int(layout_manager.findLastVisibleItemPosition())
if first < 0 or last < 0:
return None
return int(adapter.messagesStartRow) + index, min(first, last), max(first, last)
except Exception as exc:
diagnostics.record_error("injector.visible_span", exc)
return None
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)
diagnostics.note(f"injector: jump to row {position} offset {offset}")
return True return True
except Exception as exc: except Exception as exc:
diagnostics.record_error("injector.scroll_into_view", exc) diagnostics.record_error("injector.jump_to_card", exc)
return False return False
def update( def update(
@@ -381,6 +561,7 @@ class SummaryInjection:
thinking: str = "", thinking: str = "",
streaming: bool = True, streaming: bool = True,
status: str = "", status: str = "",
tagged: bool = False,
) -> None: ) -> None:
"""Re-renders the card in place, without rebuilding the whole list. """Re-renders the card in place, without rebuilding the whole list.
@@ -397,32 +578,28 @@ class SummaryInjection:
a fallback when the live cell cannot be found. a fallback when the live cell cannot be found.
""" """
if self.message_object is None: if self.message_object is None:
self.insert(answer_markdown, thinking, streaming, status) self.insert(answer_markdown, thinking, streaming, status, tagged)
return return
replacement = build_summary_message( replacement = self._build(answer_markdown, thinking, streaming, status, tagged)
self.account,
self.dialog_id,
self.topic_id,
self.anchor_msg_id,
self.virtual_id,
answer_markdown,
thinking,
streaming,
status,
)
if replacement is None: if replacement is None:
return return
self.message_object = replacement 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(): def run():
try: try:
messages_list = get_messages_list(self.chat_activity) messages_list = get_messages_list(self.chat_activity)
inserted = False inserted = False
if messages_list is not None: 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) index = find_existing(messages_list, self.virtual_id)
if index >= 0: if index >= 0:
messages_list.set(index, replacement) messages_list.set(index, replacement)
@@ -434,16 +611,9 @@ class SummaryInjection:
return return
messages_list.add(index, replacement) messages_list.add(index, replacement)
inserted = True 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 # A row was added, so the adapter's item count changed and only
# changed and only a full rebind is correct. # a full rebind is correct.
if inserted or not self._force_update_cell(replacement): if inserted or not self._force_update_cell(replacement):
adapter = get_chat_adapter(self.chat_activity) adapter = get_chat_adapter(self.chat_activity)
if adapter is not None: if adapter is not None:
@@ -467,9 +637,7 @@ class SummaryInjection:
delegate = getattr(self.chat_activity, "chatMessageCellDelegate", None) or get_private_field( delegate = getattr(self.chat_activity, "chatMessageCellDelegate", None) or get_private_field(
self.chat_activity, "chatMessageCellDelegate" self.chat_activity, "chatMessageCellDelegate"
) )
list_view = getattr(self.chat_activity, "chatListView", None) or get_private_field( list_view = _chat_list_view(self.chat_activity)
self.chat_activity, "chatListView"
)
if delegate is None or list_view is None: if delegate is None or list_view is None:
return False return False
@@ -491,80 +659,71 @@ class SummaryInjection:
diagnostics.record_error("injector._force_update_cell", exc) diagnostics.record_error("injector._force_update_cell", exc)
return False 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 def _chat_list_view(chat_activity: Any) -> Optional[Any]:
the anchor and the DB row id only become known later. Moving it keeps try:
the promise that a summary sits directly after its newest analyzed from hook_utils import get_private_field
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 return getattr(chat_activity, "chatListView", None) or get_private_field(chat_activity, "chatListView")
old_virtual_id = self.virtual_id except Exception:
self.anchor_msg_id = anchor_msg_id return None
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 def _id_span(messages_list: Any) -> str:
try: """'loaded ids A..B, N rows' for diagnostics."""
header = _tlrpc().TL_messageReplyHeader() try:
header.reply_to_msg_id = anchor_msg_id ids = [
if self.topic_id: int(getattr(getattr(messages_list.get(i), "messageOwner", None), "id", 0) or 0)
header.reply_to_top_id = int(self.topic_id) for i in range(messages_list.size())
owner.reply_to = header ]
except Exception as exc: real = [i for i in ids if i > 0]
diagnostics.record_error("injector.reanchor.reply_header", exc) if not real:
return f"no messages, {len(ids)} rows"
return f"loaded ids {min(real)}..{max(real)}, {len(ids)} rows"
except Exception:
return "loaded ids unknown"
if messages_list is not None and current >= 0:
messages_list.remove(current) def remove_cards(chat_activity: Any, virtual_ids) -> None:
# A card may already sit under the new id -- e.g. the """Drops injected cards from an open chat's list.
# restore hook re-injected this summary's saved row. Claim
# that id rather than ending up with two cards. ChatActivity removes deleted messages by looking them up in messagesDict,
duplicate = find_existing(messages_list, virtual_id) which injected cards are never added to, so a deleted card would otherwise
if duplicate >= 0: stay on screen.
messages_list.remove(duplicate) """
# The card was just removed, so it must go back somewhere: wanted = {int(v) for v in virtual_ids}
# the new anchor came from the loaded list and is normally if chat_activity is None or not wanted:
# present, but if it paged out in between, fall back to id return
# ordering rather than dropping the card entirely.
target = find_index_for_anchor(messages_list, anchor_msg_id) def run():
if target < 0: try:
target = find_index_for_anchor( messages_list = get_messages_list(chat_activity)
messages_list, anchor_msg_id, require_anchor=False if messages_list is None:
) return
messages_list.add(max(target, 0), message_object) removed = 0
adapter = get_chat_adapter(self.chat_activity) 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: if adapter is not None:
adapter.notifyDataSetChanged() adapter.notifyDataSetChanged()
# The card just moved to the real end of the analyzed range, except Exception as exc:
# which may be somewhere else entirely, so follow it there. diagnostics.record_error("injector.remove_cards", exc)
self.scroll_into_view()
except Exception as exc:
diagnostics.record_error("injector.reanchor", exc)
_on_ui(run) _on_ui(run)
def _on_ui(fn) -> None: def _on_ui(fn, delay_ms: int = 0) -> None:
try: try:
from android_utils import run_on_ui_thread 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: except Exception:
fn() fn()
@@ -621,6 +780,7 @@ def restore_saved_summaries(chat_activity: Any, account: int, dialog_id: int, to
summary.text, summary.text,
summary.thinking, summary.thinking,
False, False,
tagged=True,
) )
if message_object is not None: if message_object is not None:
pending.append((summary.anchor_msg_id, virtual_id, message_object)) pending.append((summary.anchor_msg_id, virtual_id, message_object))
+35
View File
@@ -296,3 +296,38 @@ def _thinking_details(TL_iv: Any, thinking: str) -> Any:
if not details.blocks: if not details.blocks:
details.blocks.add(_block(TL_iv, "pageBlockParagraph", build_rich_text(thinking, TL_iv))) details.blocks.add(_block(TL_iv, "pageBlockParagraph", build_rich_text(thinking, TL_iv)))
return details return details
_PLAIN_BLOCK_RE = re.compile(r"^\s{0,3}(?:#{1,6}\s+|>\s?)", re.MULTILINE)
_FENCE_LINE_RE = re.compile(r"^\s*```+.*$\n?", re.MULTILINE)
def markdown_to_plain(markdown: str) -> str:
"""Markdown reduced to the text a reader sees, for the message's plain body.
Links keep their label, emphasis markers and heading/quote prefixes are
dropped. List bullets stay: they still read naturally as plain text.
"""
text = _FENCE_LINE_RE.sub("", markdown or "")
text = _PLAIN_BLOCK_RE.sub("", text)
def inline(m: "re.Match") -> str:
for body in ("code_body", "link_text", "bold_body", "strike_body", "em_body"):
if m.group(body) is not None:
return markdown_to_plain(m.group(body)) if body != "code_body" else m.group(body)
return m.group(0)
return _INLINE_RE.sub(inline, text).strip()
def hashtag_block(tag: str, TL_iv: Optional[Any] = None) -> Any:
"""A paragraph holding one native hashtag node.
TL_iv.textHashtag is the rich-text counterpart of a hashtag entity
(verified in the APK: it carries only `text`), so the layout styles it
and handles taps on it the way it does any other hashtag.
"""
TL_iv = TL_iv or _tl_iv()
node = TL_iv.textHashtag()
node.text = _plain(TL_iv, tag)
return _block(TL_iv, "pageBlockParagraph", node)
+63 -1
View File
@@ -8,7 +8,7 @@ message when the chat is reopened.
import os import os
import sqlite3 import sqlite3
import time import time
from typing import List, Optional from typing import List, Optional, Tuple
DB_DIRNAME = "chat_summaries" DB_DIRNAME = "chat_summaries"
DB_FILENAME = "summaries.db" DB_FILENAME = "summaries.db"
@@ -107,6 +107,32 @@ class SummaryDb:
).fetchone() ).fetchone()
return int(row["id"]) return int(row["id"])
def reserve(self, dialog_id: int, topic_id: int, anchor_msg_id: int, msg_count: int = 0) -> Tuple[int, bool]:
"""Claims a row id for a summary that is about to stream.
Returns (row id, created). An existing summary for the same anchor is
left untouched, so re-summarizing a range does not wipe the saved text
before the new one has actually arrived; a stream that fails then keeps
the previous summary instead of leaving an empty row behind.
"""
now = int(time.time())
with self._connect() as conn:
cur = conn.execute(
"""
INSERT INTO summaries
(dialog_id, topic_id, anchor_msg_id, text, thinking, msg_count, created_at)
VALUES (?, ?, ?, '', '', ?, ?)
ON CONFLICT (dialog_id, topic_id, anchor_msg_id) DO NOTHING
""",
(int(dialog_id), int(topic_id), int(anchor_msg_id), int(msg_count), now),
)
created = cur.rowcount > 0
row = conn.execute(
"SELECT id FROM summaries WHERE dialog_id = ? AND topic_id = ? AND anchor_msg_id = ?",
(int(dialog_id), int(topic_id), int(anchor_msg_id)),
).fetchone()
return int(row["id"]), created
def get_for_scope(self, dialog_id: int, topic_id: int = 0) -> List[StoredSummary]: def get_for_scope(self, dialog_id: int, topic_id: int = 0) -> List[StoredSummary]:
"""All summaries for a chat/topic, oldest anchor first.""" """All summaries for a chat/topic, oldest anchor first."""
with self._connect() as conn: with self._connect() as conn:
@@ -120,6 +146,15 @@ class SummaryDb:
).fetchall() ).fetchall()
return [_row_to_summary(r) for r in rows] return [_row_to_summary(r) for r in rows]
def get_for_dialog(self, dialog_id: int) -> List[StoredSummary]:
"""All summaries for a chat across every topic."""
with self._connect() as conn:
rows = conn.execute(
"SELECT * FROM summaries WHERE dialog_id = ? ORDER BY anchor_msg_id ASC",
(int(dialog_id),),
).fetchall()
return [_row_to_summary(r) for r in rows]
def get(self, summary_id: int) -> Optional[StoredSummary]: def get(self, summary_id: int) -> Optional[StoredSummary]:
with self._connect() as conn: with self._connect() as conn:
row = conn.execute("SELECT * FROM summaries WHERE id = ?", (int(summary_id),)).fetchone() row = conn.execute("SELECT * FROM summaries WHERE id = ?", (int(summary_id),)).fetchone()
@@ -138,6 +173,33 @@ class SummaryDb:
) )
return cur.rowcount return cur.rowcount
def clear_all(self) -> int:
"""Deletes every stored summary and compacts the file. Returns rows removed."""
with self._connect() as conn:
cur = conn.execute("DELETE FROM summaries")
removed = cur.rowcount
# VACUUM cannot run inside a transaction, and the `with` block above
# commits on exit, so it gets a connection of its own.
conn = self._connect()
try:
conn.isolation_level = None
conn.execute("VACUUM")
finally:
conn.close()
return removed
def stats(self) -> Tuple[int, int]:
"""(row count, approximate on-disk size in bytes)."""
with self._connect() as conn:
count = int(conn.execute("SELECT COUNT(*) FROM summaries").fetchone()[0])
size = 0
for suffix in ("", "-wal", "-journal"):
try:
size += os.path.getsize(self.db_path + suffix)
except OSError:
pass
return count, size
def _row_to_summary(row: sqlite3.Row) -> StoredSummary: def _row_to_summary(row: sqlite3.Row) -> StoredSummary:
return StoredSummary( return StoredSummary(
Executable → Regular
+46
View File
@@ -2,6 +2,9 @@
"""Bundle a multi-file exteraGram plugin (plugin.json + src/) into a single .plugin file. """Bundle a multi-file exteraGram plugin (plugin.json + src/) into a single .plugin file.
Usage: python3 tools/bundle.py [--dev|--release] [--out DIR] [--check] Usage: python3 tools/bundle.py [--dev|--release] [--out DIR] [--check]
Also enables the repo's git hooks (scripts/pre-commit, scripts/post-commit) on
first run; see ensure_git_hooks().
""" """
from __future__ import annotations from __future__ import annotations
@@ -9,13 +12,18 @@ import argparse
import ast import ast
import base64 import base64
import json import json
import os
import re import re
import stat
import subprocess
import sys import sys
import zlib import zlib
from pathlib import Path from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent REPO_ROOT = Path(__file__).resolve().parent.parent
SRC_DIR = REPO_ROOT / "src" SRC_DIR = REPO_ROOT / "src"
HOOKS_DIR = "scripts"
HOOK_NAMES = ("pre-commit", "post-commit")
PLUGIN_JSON = REPO_ROOT / "plugin.json" PLUGIN_JSON = REPO_ROOT / "plugin.json"
RECOGNIZED_KEYS = { RECOGNIZED_KEYS = {
@@ -300,6 +308,43 @@ def assemble_bundle(data: dict, modules: dict, release: bool) -> str:
return text return text
def ensure_git_hooks() -> None:
"""Point git at scripts/ for hooks, once per clone.
Git never enables hooks from repository content, so a fresh clone (or a
repo created from the template) starts without them. Every workflow runs
this bundler first, directly or via scripts/deploy.sh and scripts/watch.sh,
which makes it the one place that can switch them on without an extra
setup step. Skipped in CI, outside a git work tree, and when
core.hooksPath is already set (including to something else on purpose).
Never fails the build.
"""
if os.environ.get("CI"):
return
hooks = [REPO_ROOT / HOOKS_DIR / name for name in HOOK_NAMES]
if not any(h.is_file() for h in hooks):
return
def git(*args):
return subprocess.run(["git", *args], cwd=REPO_ROOT, capture_output=True, text=True)
try:
top = git("rev-parse", "--show-toplevel")
if top.returncode != 0 or Path(top.stdout.strip()).resolve() != REPO_ROOT:
return
if git("config", "--get", "core.hooksPath").stdout.strip():
return
# Git silently skips hooks that are not executable.
for hook in hooks:
if hook.is_file():
mode = hook.stat().st_mode
hook.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
if git("config", "core.hooksPath", HOOKS_DIR).returncode == 0:
print(f"enabled git hooks from {HOOKS_DIR}/ (git config core.hooksPath {HOOKS_DIR})", file=sys.stderr)
except (OSError, subprocess.SubprocessError):
pass
def parse_args(argv): def parse_args(argv):
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Bundle a multi-file exteraGram plugin into a single .plugin file." description="Bundle a multi-file exteraGram plugin into a single .plugin file."
@@ -318,6 +363,7 @@ def parse_args(argv):
def main(argv=None) -> int: def main(argv=None) -> int:
args = parse_args(sys.argv[1:] if argv is None else argv) args = parse_args(sys.argv[1:] if argv is None else argv)
ensure_git_hooks()
release = bool(args.release) release = bool(args.release)
try: try:
data = load_metadata() data = load_metadata()
+514 -107
View File
@@ -162,6 +162,7 @@ class _TLObj:
self.blocks = _JavaList() self.blocks = _JavaList()
self.items = _JavaList() self.items = _JavaList()
self.texts = _JavaList() self.texts = _JavaList()
self.entities = _JavaList()
self.flags = 0 self.flags = 0
self.flags2 = 0 self.flags2 = 0
@@ -254,6 +255,7 @@ def _fake_send_request(request, callback, *, account=None):
_KNOWN_METHODS = { _KNOWN_METHODS = {
"org.telegram.ui.LaunchActivity": ["dispatchTouchEvent"], "org.telegram.ui.LaunchActivity": ["dispatchTouchEvent"],
"org.telegram.messenger.MessagesController": ["deleteMessages", "deleteMessages"],
"org.telegram.ui.ChatActivity": [ "org.telegram.ui.ChatActivity": [
"onPageDownClicked", "onPageDownClicked",
"updateMessagesVisiblePart", "updateMessagesVisiblePart",
@@ -343,9 +345,16 @@ class FakeAlertDialogBuilder:
BUTTON_POSITIVE = 1 BUTTON_POSITIVE = 1
BUTTON_NEGATIVE = 2 BUTTON_NEGATIVE = 2
built: List[Any] = []
def __init__(self, activity, progress_style=0, resources_provider=None): def __init__(self, activity, progress_style=0, resources_provider=None):
self.activity = activity self.activity = activity
self._view = None self._view = None
self._positive = None
self._negative = None
self.red_buttons = []
self.shown = False
FakeAlertDialogBuilder.built.append(self)
def set_title(self, *a, **k): def set_title(self, *a, **k):
return self return self
@@ -372,8 +381,12 @@ class FakeAlertDialogBuilder:
return self return self
def show(self): def show(self):
self.shown = True
return self return self
def make_button_red(self, button_type):
self.red_buttons.append(button_type)
def dismiss(self): def dismiss(self):
pass pass
@@ -572,6 +585,7 @@ def install_fakes() -> None:
"TL_peerChannel": staticmethod(lambda: _TLObj("TL_peerChannel")), "TL_peerChannel": staticmethod(lambda: _TLObj("TL_peerChannel")),
"TL_messageReplyHeader": staticmethod(lambda: _TLObj("TL_messageReplyHeader")), "TL_messageReplyHeader": staticmethod(lambda: _TLObj("TL_messageReplyHeader")),
"TL_emojiStatus": staticmethod(lambda: _TLObj("TL_emojiStatus")), "TL_emojiStatus": staticmethod(lambda: _TLObj("TL_emojiStatus")),
"TL_messageEntityHashtag": staticmethod(lambda: _TLObj("TL_messageEntityHashtag")),
}, },
) )
sys.modules["org.telegram.tgnet"] = tgnet_mod sys.modules["org.telegram.tgnet"] = tgnet_mod
@@ -588,7 +602,7 @@ def install_fakes() -> None:
"textFixed textUnderline textUrl pageBlockParagraph pageBlockHeading1 " "textFixed textUnderline textUrl pageBlockParagraph pageBlockHeading1 "
"pageBlockHeading2 pageBlockHeading3 pageBlockPreformatted pageBlockBlockquote " "pageBlockHeading2 pageBlockHeading3 pageBlockPreformatted pageBlockBlockquote "
"pageBlockDivider pageBlockThinking pageBlockList pageBlockOrderedList " "pageBlockDivider pageBlockThinking pageBlockList pageBlockOrderedList "
"pageBlockDetails TL_pageListItemText TL_pageListOrderedItemText" "pageBlockDetails TL_pageListItemText TL_pageListOrderedItemText textHashtag"
).split() ).split()
tgnet_tl_mod.TL_iv = type( tgnet_tl_mod.TL_iv = type(
"TL_iv", "TL_iv",
@@ -711,6 +725,242 @@ def expected_dunders(meta: dict) -> Dict[str, Any]:
return result return result
def run_pipeline_checks(plugin, injector, summary_db_mod, chat_activity_cls, message_cls) -> None:
"""Drives the whole Summarize pipeline: fetch -> stream -> card.
The rule under test: the chat is not touched until the endpoint has
actually started answering. Every earlier failure is a bulletin only.
"""
if plugin is None or message_cls is None:
check("pipeline checks need the plugin class", False, "plugin not loaded")
return
dialog_id = -4242
requests_mod = sys.modules["requests"]
real_post = requests_mod.post
cu_mod = sys.modules["client_utils"]
real_last_fragment = cu_mod.get_last_fragment
plugin.set_setting("custom_base_url", "https://example.test/v1")
def fresh_db():
plugin.summary_db = summary_db_mod.SummaryDb(str(Path(tempfile.mkdtemp()) / "summaries.db"))
return plugin.summary_db
def feed(messages=None, error=None):
plugin.fetcher.fetch_history = lambda d, c, o, t, cb: cb(messages, error)
def run(activity):
BULLETINS.clear()
plugin._run_summary(None, activity, dialog_id, 0, 3, 0, "Summarize")
analyzed = [
message_cls("Alice", "a", id=500, sender_id=1),
message_cls("Bob", "b", id=555, sender_id=2),
message_cls("Alice", "c", id=520, sender_id=1),
]
class _ErrorResponse:
status_code = 500
text = "upstream exploded"
def close(self):
pass
class _BreakingResponse:
status_code = 200
text = ""
def iter_lines(self):
yield b'data: {"choices":[{"delta":{"content":"Partial"}}]}'
raise IOError("connection reset")
def close(self):
pass
try:
# 1. History fetch fails: bulletin only, chat untouched.
db = fresh_db()
activity = chat_activity_cls([600, 555, 500])
feed(error="flood wait")
run(activity)
check(
"a failed history fetch shows a bulletin and leaves the chat untouched",
activity.ids() == [600, 555, 500]
and activity.chatLayoutManager.jumps == []
and activity.scrolled_to == []
and ("error", "Chat Summaries: flood wait") in BULLETINS,
f"ids={activity.ids()} bulletins={BULLETINS}",
)
feed(messages=[])
run(activity)
check(
"an empty history shows a bulletin and leaves the chat untouched",
activity.ids() == [600, 555, 500] and any("no messages" in b[1] for b in BULLETINS),
f"ids={activity.ids()} bulletins={BULLETINS}",
)
# 2. Endpoint fails before the first chunk (HTTP error, unreachable
# host): no card, no scroll, no DB row.
feed(messages=analyzed)
requests_mod.post = lambda *a, **k: _ErrorResponse()
run(activity)
check(
"an HTTP error before the first chunk injects nothing",
activity.ids() == [600, 555, 500]
and activity.chatLayoutManager.jumps == []
and any("HTTP 500" in b[1] for b in BULLETINS if b[0] == "error"),
f"ids={activity.ids()} bulletins={BULLETINS}",
)
def unreachable(*a, **k):
raise ConnectionError("no route to host")
requests_mod.post = unreachable
run(activity)
check(
"an unreachable endpoint injects nothing and reports the failure",
activity.ids() == [600, 555, 500]
and any("request failed" in b[1] for b in BULLETINS if b[0] == "error"),
f"ids={activity.ids()} bulletins={BULLETINS}",
)
check(
"a failure before the first chunk leaves no row in the store",
db.stats()[0] == 0,
f"rows={db.stats()[0]}",
)
# 3. Success: the card appears right after the newest analyzed message
# (555, not the chat's newest 600), the chat jumps to it once, and the
# finished summary is tagged and persisted.
requests_mod.post = real_post
plugin.restore_hook._scope_state["done"].add(("stale",))
run(activity)
ids = activity.ids()
cards = [i for i in ids if i < 0]
card_index = ids.index(cards[0]) if cards else -1
check(
"a successful stream places exactly one card directly after the newest analyzed message",
len(cards) == 1 and card_index == ids.index(555) - 1,
f"ids={ids}",
)
check(
"the chat hard-jumps to the card exactly once",
activity.chatLayoutManager.jumps == [(2 + card_index, 300, False)],
f"jumps={activity.chatLayoutManager.jumps}",
)
final_owner = activity.messages.get(card_index).messageOwner if cards else None
check(
"the finished card is tagged #aisummaries and carries the answer",
final_owner is not None
and final_owner.message == "Fake summary.\n\n#aisummaries",
f"message={getattr(final_owner, 'message', None)!r}",
)
rows = db.get_for_scope(dialog_id, 0)
check(
"the finished summary is saved under the analyzed anchor without the tag",
len(rows) == 1 and rows[0].anchor_msg_id == 555 and rows[0].text == "Fake summary."
and cards and cards[0] == injector.virtual_id_for(rows[0].id, 555),
f"rows={rows}",
)
check(
"saving a summary invalidates the restore cache",
("stale",) not in plugin.restore_hook._scope_state["done"],
)
# 4. Re-running a failing stream over a saved range keeps the saved text.
requests_mod.post = lambda *a, **k: _BreakingResponse()
run(activity)
rows = db.get_for_scope(dialog_id, 0)
check(
"a stream that breaks mid-way does not wipe the previously saved summary",
len(rows) == 1 and rows[0].text == "Fake summary.",
f"rows={rows}",
)
check(
"a mid-stream failure reuses the existing card and reports the error on it",
len([i for i in activity.ids() if i < 0]) == 1
and "Error:" in (activity.messages.get(card_index).messageOwner.message or "")
and any("stream failed" in b[1] for b in BULLETINS if b[0] == "error"),
f"ids={activity.ids()} bulletins={BULLETINS}",
)
# A fresh range that breaks mid-way leaves no empty row behind.
broken_activity = chat_activity_cls([700, 690])
feed(messages=[message_cls("Alice", "x", id=690, sender_id=1)])
run(broken_activity)
check(
"a fresh stream that breaks mid-way removes its reserved row",
[r.anchor_msg_id for r in db.get_for_dialog(dialog_id)] == [555],
f"rows={db.get_for_dialog(dialog_id)}",
)
requests_mod.post = real_post
# 5. Deleting the card through the app's own delete path forgets it.
card_id = cards[0]
cu_mod.get_last_fragment = lambda: activity
delete_hooks = [
hook for method, hook in HOOKS_INSTALLED
if getattr(method, "class_name", "") == "org.telegram.messenger.MessagesController"
]
BULLETINS.clear()
plugin.restore_hook._scope_state["done"].add(("stale",))
param = types.SimpleNamespace(
args=[_JavaArrayList([card_id, 600]), None, None, dialog_id, False, 0]
)
for hook in delete_hooks:
hook.before_hooked_method(param)
check(
"deleting a summary card removes its row, its card, and says so once",
db.get_for_dialog(dialog_id) == []
and card_id not in activity.ids()
and BULLETINS == [("info", "Summary deleted")]
and ("stale",) not in plugin.restore_hook._scope_state["done"],
f"rows={db.get_for_dialog(dialog_id)} ids={activity.ids()} bulletins={BULLETINS}",
)
BULLETINS.clear()
for hook in delete_hooks:
hook.before_hooked_method(types.SimpleNamespace(args=[_JavaArrayList([600]), None, None, dialog_id]))
check(
"deleting ordinary messages never touches the summary store",
BULLETINS == [],
f"bulletins={BULLETINS}",
)
# 6. Clear all: confirmed through a red-buttoned alert.
db.save(dialog_id, 0, 555, "one", "", 1)
db.save(-1, 0, 7, "two", "", 1)
count, size = db.stats()
check("stats report row count and a non-zero file size", count == 2 and size > 0, f"{count} {size}")
class _Fragment:
def getParentActivity(self):
return object()
cu_mod.get_last_fragment = lambda: _Fragment()
FakeAlertDialogBuilder.built.clear()
BULLETINS.clear()
plugin._on_clear_all_click(None)
dialog = FakeAlertDialogBuilder.built[-1] if FakeAlertDialogBuilder.built else None
check(
"Clear all asks for confirmation with a red button before deleting",
dialog is not None and dialog.shown
and dialog.red_buttons == [FakeAlertDialogBuilder.BUTTON_POSITIVE]
and db.stats()[0] == 2,
f"dialog={getattr(dialog, '__dict__', None)}",
)
if dialog is not None and dialog._positive is not None:
dialog._positive(dialog, FakeAlertDialogBuilder.BUTTON_POSITIVE)
check(
"confirming Clear all wipes every summary and reports it",
db.stats()[0] == 0 and ("success", "Deleted 2 summaries") in BULLETINS,
f"rows={db.stats()[0]} bulletins={BULLETINS}",
)
finally:
requests_mod.post = real_post
cu_mod.get_last_fragment = real_last_fragment
def main(argv=None) -> int: def main(argv=None) -> int:
argv = sys.argv[1:] if argv is None else argv argv = sys.argv[1:] if argv is None else argv
if len(argv) != 1: if len(argv) != 1:
@@ -1007,44 +1257,26 @@ def main(argv=None) -> int:
f"page_results={page_results}", f"page_results={page_results}",
) )
# Provisional anchor, chosen before the fetch returns: with an offset
# the analyzed range ends partway up the history, so the guess has to
# skip that many messages instead of grabbing the newest.
class _AnchorList(list):
def size(self):
return len(self)
def get(self, index):
return self[index]
class _AnchorActivity:
def __init__(self, ids):
self.messages = _AnchorList()
for mid in ids:
owner_obj = _TLObj("TL_message")
owner_obj.id = mid
holder = types.SimpleNamespace(messageOwner=owner_obj)
self.messages.append(holder)
anchor_activity = _AnchorActivity([900, 800, 700, 600])
check( check(
"with no offset the provisional anchor is the newest message", "delete_hook hooks every MessagesController.deleteMessages overload",
plugin._newest_visible_message_id(anchor_activity) == 900, len(plugin.delete_hook._unhooks) == 2,
f"got {plugin._newest_visible_message_id(anchor_activity)}", f"installed hooks: {len(plugin.delete_hook._unhooks)} (expected 2)",
)
check(
"an offset moves the provisional anchor up the history",
plugin._newest_visible_message_id(anchor_activity, skip=2) == 700,
f"got {plugin._newest_visible_message_id(anchor_activity, skip=2)}",
) )
# Injected cards carry negative ids and must never be chosen as the # Storage settings: an approximate size readout and a destructive,
# anchor, or a second summary would anchor to the first one's card. # confirmed "clear all" action.
mixed_anchor = _AnchorActivity([-700007, 900, 800]) storage_rows = {getattr(r, "text", None): r for r in settings_rows}
clear_row = storage_rows.get("Clear all summaries")
size_row = storage_rows.get("Saved summaries")
check( check(
"an existing injected card is never picked as the anchor", "settings show the saved summary count and approximate size",
plugin._newest_visible_message_id(mixed_anchor) == 900, size_row is not None and "saved" in (size_row.subtext or "") and "B" in size_row.subtext,
f"got {plugin._newest_visible_message_id(mixed_anchor)}", f"subtext={getattr(size_row, 'subtext', None)!r}",
)
check(
"settings offer a red Clear all summaries action",
clear_row is not None and clear_row.red is True and callable(clear_row.on_click),
f"row={getattr(clear_row, '__dict__', None)}",
) )
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -1176,6 +1408,14 @@ def main(argv=None) -> int:
[b.tl_name for b in answered.blocks] == ["pageBlockHeading1"], [b.tl_name for b in answered.blocks] == ["pageBlockHeading1"],
f"blocks={[b.tl_name for b in answered.blocks]}", f"blocks={[b.tl_name for b in answered.blocks]}",
) )
plain = rich_markdown.markdown_to_plain(
"# Title\n\n**Bold** and *it* with `code` by [Alice](tg://user?id=1)\n> quote\n```\nx = 1\n```"
)
check(
"markdown_to_plain keeps the readable text and drops the markup",
plain == "Title\n\nBold and it with code by Alice\nquote\nx = 1",
f"plain={plain!r}",
)
empty_rm = rich_markdown.build_rich_message("", streaming=True) empty_rm = rich_markdown.build_rich_message("", streaming=True)
check( check(
"an empty RichMessage still carries one block so the cell renders", "an empty RichMessage still carries one block so the cell renders",
@@ -1481,6 +1721,25 @@ def main(argv=None) -> int:
f"topic0={len(db.get_for_scope(-100, 0))} topic7={len(db.get_for_scope(-100, 7))}", f"topic0={len(db.get_for_scope(-100, 0))} topic7={len(db.get_for_scope(-100, 7))}",
) )
reserved, created = db.reserve(-100, 0, 555, 9)
check(
"reserving an anchor that already has a summary keeps its text",
reserved == first and created is False and db.get(first).text == "text two",
f"reserved={reserved} created={created} text={db.get(first).text!r}",
)
fresh, fresh_created = db.reserve(-100, 0, 777, 9)
check(
"reserving a new anchor creates an empty row",
fresh_created is True and db.get(fresh).text == "",
f"created={fresh_created}",
)
check(
"get_for_dialog spans every topic of the chat",
sorted((r.topic_id, r.anchor_msg_id) for r in db.get_for_dialog(-100))
== [(0, 555), (0, 777), (7, 555)],
f"rows={db.get_for_dialog(-100)}",
)
check("summary_db.delete removes a row", db.delete(first) and db.get(first) is None) check("summary_db.delete removes a row", db.delete(first) and db.get(first) is None)
injector = bundled("services.rich_injector") injector = bundled("services.rich_injector")
@@ -1547,6 +1806,10 @@ def main(argv=None) -> int:
# Insertion order: ChatActivity.messages is newest-first, so the # Insertion order: ChatActivity.messages is newest-first, so the
# summary must land at the anchor's index to sit after it on screen. # summary must land at the anchor's index to sit after it on screen.
class _FakeAdapter: class _FakeAdapter:
# ChatActivityAdapter rows before the first message (header,
# loading row): the jump must offset the list index by this.
messagesStartRow = 2
def __init__(self): def __init__(self):
self.notified = 0 self.notified = 0
@@ -1581,6 +1844,17 @@ def main(argv=None) -> int:
class _FakeListView: class _FakeListView:
def __init__(self): def __init__(self):
self.cells = [] self.cells = []
self.stopped = 0
self.scroll_state = 0
def getScrollState(self):
return self.scroll_state
def getMeasuredHeight(self):
return 900
def stopScroll(self):
self.stopped += 1
def getChildCount(self): def getChildCount(self):
return len(self.cells) return len(self.cells)
@@ -1588,6 +1862,32 @@ def main(argv=None) -> int:
def getChildAt(self, index): def getChildAt(self, index):
return self.cells[index] return self.cells[index]
class _FakeLayoutManager:
"""Records jumps and reports the visible row range they produce.
`hijack` simulates ChatActivity's own postponed scroll landing
right after a jump and carrying the view elsewhere.
"""
def __init__(self):
self.jumps = []
self.visible = (-1, -1)
self.hijack = 0
def scrollToPositionWithOffset(self, position, offset, bottom):
self.jumps.append((position, offset, bottom))
if self.hijack:
self.hijack -= 1
self.visible = (40, 46)
else:
self.visible = (max(0, position - 3), position + 3)
def findFirstVisibleItemPosition(self):
return self.visible[0]
def findLastVisibleItemPosition(self):
return self.visible[1]
class _FakeChatActivity: class _FakeChatActivity:
def __init__(self, ids): def __init__(self, ids):
self.messages = _JavaList() self.messages = _JavaList()
@@ -1598,13 +1898,24 @@ def main(argv=None) -> int:
self.chatAdapter = _FakeAdapter() self.chatAdapter = _FakeAdapter()
self.chatMessageCellDelegate = _FakeDelegate() self.chatMessageCellDelegate = _FakeDelegate()
self.chatListView = _FakeListView() self.chatListView = _FakeListView()
self.chatLayoutManager = _FakeLayoutManager()
self.scrolled_to = [] self.scrolled_to = []
# Ids scrollToMessageId will "load" into the list, mimicking
# the history request it issues for an unloaded id.
self.loadable = {}
def getDialogId(self): def getDialogId(self):
return -4242 return -4242
def scrollToMessageId(self, msg_id, from_id, select, load_index, force, unread): def scrollToMessageId(self, msg_id, from_id, select, load_index, force, unread):
self.scrolled_to.append(msg_id) self.scrolled_to.append(msg_id)
for mid in self.loadable.pop(msg_id, ()):
owner_obj = _TLObj("TL_message")
owner_obj.id = mid
self.messages.add(_FakeMessageObject(0, owner_obj))
def ids(self):
return [m.messageOwner.id for m in self.messages]
activity = _FakeChatActivity([600, 555, 500]) activity = _FakeChatActivity([600, 555, 500])
check( check(
@@ -1684,7 +1995,7 @@ def main(argv=None) -> int:
virtual_id = injector.virtual_id_for(9, 555) virtual_id = injector.virtual_id_for(9, 555)
inj = injector.SummaryInjection(activity, 0, -4242, 0, 555, virtual_id) inj = injector.SummaryInjection(activity, 0, -4242, 0, 555, virtual_id)
inserted = inj.insert(status="Fetching messages…", streaming=True) inserted = inj.insert(thinking="first chunk", streaming=True)
check( check(
"injection inserts one message at the anchor index and refreshes the adapter", "injection inserts one message at the anchor index and refreshes the adapter",
inserted inserted
@@ -1695,12 +2006,18 @@ def main(argv=None) -> int:
) )
# The analyzed range can end far up the history, so the card may be # The analyzed range can end far up the history, so the card may be
# injected off-screen; the chat has to move to it. The anchor is the # injected off-screen; the chat has to move to it. scrollToMessageId
# scroll target because it is a real message the app can resolve, and # only smooth-scrolls from stale cell positions right after an insert,
# the card sits directly above it. # so placement hard-jumps the layout manager to the card's own row.
check( check(
"inserting the card scrolls the chat to the analyzed range", "placing the card hard-jumps the layout manager to the card's row",
activity.scrolled_to == [555], activity.chatLayoutManager.jumps == [(2 + 1, 300, False)]
and activity.chatListView.stopped == 1,
f"jumps={activity.chatLayoutManager.jumps} stopped={activity.chatListView.stopped}",
)
check(
"a loaded anchor needs no history load before placement",
activity.scrolled_to == [],
f"scrolled_to={activity.scrolled_to}", f"scrolled_to={activity.scrolled_to}",
) )
@@ -1720,8 +2037,8 @@ def main(argv=None) -> int:
) )
check( check(
"a streaming update does not scroll again and fight the user", "a streaming update does not scroll again and fight the user",
activity.scrolled_to == [555], activity.scrolled_to == [] and len(activity.chatLayoutManager.jumps) == 1,
f"scrolled_to={activity.scrolled_to}", f"scrolled_to={activity.scrolled_to} jumps={activity.chatLayoutManager.jumps}",
) )
# Streaming repaints should hit the one visible cell, not rebind every # Streaming repaints should hit the one visible cell, not rebind every
@@ -1750,46 +2067,6 @@ def main(argv=None) -> int:
f"notified={activity.chatAdapter.notified} (was {notified_before})", f"notified={activity.chatAdapter.notified} (was {notified_before})",
) )
# The card is injected before the fetch, so its anchor starts
# provisional and must be corrected once the real range is known.
reanchor_activity = _FakeChatActivity([600, 555, 500])
provisional_id = injector.virtual_id_for(0, 600)
reinj = injector.SummaryInjection(reanchor_activity, 0, -4242, 0, 600, provisional_id)
reinj.insert(status="Fetching messages…", streaming=True)
check(
"provisional card lands at the newest message before the fetch",
reanchor_activity.messages.get(0).messageOwner.id == provisional_id,
f"ids={[m.messageOwner.id for m in reanchor_activity.messages]}",
)
final_id = injector.virtual_id_for(11, 555)
reinj.reanchor(555, final_id)
ids = [m.messageOwner.id for m in reanchor_activity.messages]
check(
"reanchor moves the card to the real anchor and adopts its virtual id",
reanchor_activity.messages.size() == 4
and ids.index(final_id) == 1
and reanchor_activity.messages.get(1).messageOwner.reply_to.reply_to_msg_id == 555,
f"ids={ids}",
)
check(
"reanchor follows the card to the real end of the analyzed range",
reanchor_activity.scrolled_to == [600, 555],
f"scrolled_to={reanchor_activity.scrolled_to}",
)
# Regression: a streaming update after a reanchor used to add a second
# card, because the lookup missed the old id and fell through to add().
reinj.update(status="Summarizing 98 messages…", streaming=True)
ids = [m.messageOwner.id for m in reanchor_activity.messages]
check(
"updating after a reanchor leaves exactly one summary card",
reanchor_activity.messages.size() == 4
and ids.count(final_id) == 1
and provisional_id not in ids,
f"ids={ids}",
)
# Re-summarizing a range whose saved card the restore hook already # Re-summarizing a range whose saved card the restore hook already
# re-injected must claim that card, not sit beside it. # re-injected must claim that card, not sit beside it.
collide_activity = _FakeChatActivity([600, 555, 500]) collide_activity = _FakeChatActivity([600, 555, 500])
@@ -1801,18 +2078,164 @@ def main(argv=None) -> int:
0, 0,
[summary_db_mod.StoredSummary(12, -4242, 0, 555, "previous run", "", 5, 0)], [summary_db_mod.StoredSummary(12, -4242, 0, 555, "previous run", "", 5, 0)],
) )
collide_provisional = injector.virtual_id_for(0, 600) collide = injector.SummaryInjection(collide_activity, 0, -4242, 0, 555, saved_id)
collide = injector.SummaryInjection(collide_activity, 0, -4242, 0, 600, collide_provisional) collide.insert(thinking="new run", streaming=True)
collide.insert(status="Fetching messages…", streaming=True) collide.update(answer_markdown="new answer", streaming=True)
collide.reanchor(555, saved_id) ids = collide_activity.ids()
collide.update(status="Summarizing…", streaming=True)
ids = [m.messageOwner.id for m in collide_activity.messages]
check( check(
"re-summarizing a restored range reuses its card instead of duplicating", "re-summarizing a restored range reuses its card instead of duplicating",
ids.count(saved_id) == 1 and collide_provisional not in ids, ids.count(saved_id) == 1 and len(ids) == 4,
f"ids={ids}", f"ids={ids}",
) )
# The anchor is the newest ANALYZED message; with an offset it can be
# outside the loaded window. The card must never fall back to the chat
# bottom: the chat is asked to load the anchor, and the card is placed
# directly after it once it arrives.
far = _FakeChatActivity([900, 800])
far.loadable[300] = [310, 300, 290]
far_card = injector.virtual_id_for(5, 300)
far_inj = injector.SummaryInjection(far, 0, -4242, 0, 300, far_card)
far_inj.insert(thinking="chunk", streaming=True)
far_ids = far.ids()
check(
"an unloaded anchor is loaded first, then the card lands right after it",
far.scrolled_to == [300]
and far_inj.placed
and far_ids.index(far_card) == far_ids.index(300) - 1,
f"scrolled_to={far.scrolled_to} ids={far_ids}",
)
check(
"the card is never parked at the bottom while its anchor loads",
far_ids[0] == 900,
f"ids={far_ids}",
)
# Loading a far anchor makes ChatActivity run its own postponed scroll,
# which can land after the jump and carry the view away from the card.
# The settle checks must bring it back.
hijacked = _FakeChatActivity([900, 800])
hijacked.loadable[300] = [310, 300, 290]
hijacked.chatLayoutManager.hijack = 1
hijacked_card = injector.virtual_id_for(7, 300)
injector.SummaryInjection(hijacked, 0, -4242, 0, 300, hijacked_card).insert(
thinking="chunk", streaming=True
)
card_row = 2 + hijacked.ids().index(hijacked_card)
first, last = hijacked.chatLayoutManager.visible
check(
"a jump overridden by the app's own scroll is redone until the card is on screen",
len(hijacked.chatLayoutManager.jumps) == 2 and first <= card_row <= last,
f"jumps={hijacked.chatLayoutManager.jumps} visible={(first, last)} row={card_row}",
)
# Once the user takes hold of the list, the plugin stops steering it.
dragged = _FakeChatActivity([600, 555, 500])
dragged.chatLayoutManager.hijack = 1
dragged.chatListView.scroll_state = 1
injector.SummaryInjection(dragged, 0, -4242, 0, 555, injector.virtual_id_for(8, 555)).insert(
thinking="chunk", streaming=True
)
check(
"no re-jump while the user is dragging the list",
len(dragged.chatLayoutManager.jumps) == 1,
f"jumps={dragged.chatLayoutManager.jumps}",
)
# A cache-then-server reload can replace the list under a fresh card.
reloaded = _FakeChatActivity([600, 555, 500])
reloaded_card = injector.virtual_id_for(9, 555)
reloaded_inj = injector.SummaryInjection(reloaded, 0, -4242, 0, 555, reloaded_card)
real_on_ui_for_reload = injector._on_ui
dropped = []
def _reloading_on_ui(fn, delay_ms=0):
if delay_ms and not dropped:
# First settle check: the chat reloads its window first.
reloaded.messages[:] = [m for m in reloaded.messages if m.messageOwner.id > 0]
dropped.append(True)
fn()
injector._on_ui = _reloading_on_ui
try:
reloaded_inj.insert(thinking="chunk", streaming=True)
finally:
injector._on_ui = real_on_ui_for_reload
reloaded_ids = reloaded.ids()
check(
"a card dropped by a list reload right after placement is put back",
dropped and reloaded_ids.count(reloaded_card) == 1
and reloaded_ids.index(reloaded_card) == reloaded_ids.index(555) - 1,
f"ids={reloaded_ids}",
)
# An anchor that never loads: no card anywhere, rather than a card
# attached to messages it did not analyze.
never = _FakeChatActivity([900, 800])
never_card = injector.virtual_id_for(6, 300)
never_inj = injector.SummaryInjection(never, 0, -4242, 0, 300, never_card)
never_inj.insert(thinking="chunk", streaming=True)
never_inj.update(answer_markdown="more", streaming=True)
check(
"an anchor that never loads places no card at a wrong position",
never_card not in never.ids() and not never_inj.placed,
f"ids={never.ids()}",
)
check(
"a far anchor that does not arrive is requested again, not just once",
len(never.scrolled_to) >= 2 and set(never.scrolled_to) == {300},
f"scrolled_to={never.scrolled_to}",
)
# ...and the card shows up once the user scrolls the anchor in.
for mid in (310, 300):
owner_obj = _TLObj("TL_message")
owner_obj.id = mid
never.messages.add(_FakeMessageObject(0, owner_obj))
never_inj.update(answer_markdown="final", streaming=False, tagged=True)
never_ids = never.ids()
check(
"a card whose anchor loads late is placed on the next render",
never_inj.placed and never_ids.index(never_card) == never_ids.index(300) - 1,
f"ids={never_ids}",
)
# Finished summaries carry the #aisummaries tag, in the card and in
# the plain-text body (with a hashtag entity), so copied text and any
# text matching see it. Streaming renders stay untagged.
tagged_mo = injector.build_summary_message(
0, -4242, 0, 555, injector.virtual_id_for(1, 555),
"**Done** see [Alice](tg://user?id=1)", "", False, tagged=True,
)
tagged_owner = tagged_mo.messageOwner
last_block = tagged_owner.rich_message.blocks[-1]
entity = tagged_owner.entities[0] if len(tagged_owner.entities) else None
tag_node = getattr(last_block, "text", None)
check(
"a finished summary ends with a native #aisummaries hashtag node",
last_block.tl_name == "pageBlockParagraph"
and getattr(tag_node, "tl_name", None) == "textHashtag"
and getattr(tag_node.text, "text", None) == "#aisummaries",
f"last={last_block.tl_name} node={getattr(tag_node, '__dict__', None)}",
)
check(
"the plain-text body is searchable text plus a hashtag entity",
tagged_owner.message == "Done see Alice\n\n#aisummaries"
and entity is not None
and entity.tl_name == "TL_messageEntityHashtag"
and entity.offset == len("Done see Alice\n\n")
and entity.length == len("#aisummaries"),
f"message={tagged_owner.message!r} entity={getattr(entity, '__dict__', None)}",
)
streaming_mo = injector.build_summary_message(
0, -4242, 0, 555, injector.virtual_id_for(1, 555), "partial", "", True
)
check(
"a streaming render is not tagged",
"#aisummaries" not in streaming_mo.messageOwner.message
and len(streaming_mo.messageOwner.entities) == 0,
f"message={streaming_mo.messageOwner.message!r}",
)
# Re-inserting the same virtual id must replace, never duplicate. # Re-inserting the same virtual id must replace, never duplicate.
inj2 = injector.SummaryInjection(activity, 0, -4242, 0, 555, virtual_id) inj2 = injector.SummaryInjection(activity, 0, -4242, 0, 555, virtual_id)
inj2.insert(answer_markdown="again", thinking="", streaming=False) inj2.insert(answer_markdown="again", thinking="", streaming=False)
@@ -2015,23 +2438,7 @@ def main(argv=None) -> int:
f"dispatched={sep_n} ids={sep_ids}", f"dispatched={sep_n} ids={sep_ids}",
) )
# A live status card is the only feedback that pressing Summarize did run_pipeline_checks(plugin if plugin_cls is not None else None, injector, summary_db_mod, _FakeChatActivity, _FakeMessage if plugin_cls is not None else None)
# anything, so it must appear even when the provisional anchor cannot be
# placed: a large offset reaches past the loaded window, and an
# unresolved anchor arrives as 0. Requiring a loaded anchor here left the
# user staring at an unchanged chat.
for label, anchor in (("unresolved anchor 0", 0), ("anchor past the window", 4242)):
live = _FakeChatActivity([700, 690, 680])
live_injection = injector.SummaryInjection(
live, 0, -4242, 0, anchor, injector.virtual_id_for(0, anchor or 1)
)
shown = live_injection.insert(status="Fetching messages…", streaming=True)
live_ids = [m.messageOwner.id for m in live.messages]
check(
f"the status card still appears with an {label}",
shown and len([i for i in live_ids if i < 0]) == 1,
f"inserted={shown} ids={live_ids}",
)
check( check(
"sys.modules gained no entry for bundled plugin module names", "sys.modules gained no entry for bundled plugin module names",