8 Commits
Author SHA1 Message Date
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
lyuksovannyy fc76a20420 version bump & auto-bump hooks
Publish plugin / publish (push) Successful in 4s
2026-09-19 17:33:28 +02:00
lyuksovannyy 785618233b Keep the status card visible and the unread prefill stable
Two bugs visible in a device log.

"injector: anchor 0 not loaded, skipping insert" was a regression from the
previous commit. _newest_visible_message_id returns 0 when the offset reaches
past the loaded window, and the strict anchor check then refused to place the
card, so pressing Summarize appeared to do nothing at all. Before that commit
anchor 0 silently matched a date separator, which is why it went unnoticed.

A live card is the user's only feedback that the press registered, so it must
always appear: try the exact anchor, then id ordering, then the bottom of the
chat, and let reanchor() move it once the analyzed range is known. Restoring
saved summaries stays strict, since nothing corrects their position later.
_newest_visible_message_id also falls back to the oldest loaded message rather
than returning 0.

The unread button prefilled a smaller range on each press -- 1000, then 15,
then 75, with ~1100 unread. Viewing a chat calls
MessagesController.markDialogAsRead, so dialog.unread_count decays while the
chat is open and no longer describes the backlog the user wants summarized.

Resolve the count from the unread divider instead, which is what the app's own
button uses (onPageDownClicked branches on createUnreadMessageAfterId) and
which does not decay while the chat stays open, and keep the largest value
seen per chat so a later press never prefills less than an earlier one.

Both are covered by tests that fail against the previous behaviour: the strict
anchor path reports inserted=True while leaving no card in the list, and the
unread resolver reproduces exactly 1100, 15, 75.
2026-09-19 17:08:11 +02:00
lyuksovannyy b05d1a274c Never match a date separator as a summary anchor
Date separators are local MessageObjects that live in the same
ChatActivity.messages list as real messages, and ChatActivity builds them
with messageOwner.id left at 0 (their stable ids come from a separate
dateObjectsStableIds space, so they never collide with injected cards).

find_index_for_anchor scanned for an exact id match without requiring a
positive id, so looking up anchor 0 returned the index of the first
separator and would have placed a card against a divider rather than a
message. loaded_message_ids had the matching flaw: it recorded the
separators' 0, which made a summary with anchor id 0 look restorable.

Reject non-positive anchors outright and keep separators out of the loaded
id set. Every real chat interleaves separators, so the harness now models
them: they must not be picked as the insertion point, must not satisfy an
anchor-loaded check, and summaries anchored on real messages must still
restore correctly with separators in between.
2026-09-19 16:54:25 +02:00
lyuksovannyy 0619c5fe68 Fix scroll lag and misplaced summary cards
Three defects in the restore/injection path.

Scrolling stuttered in any chat that had been summarized. The restore gate
keyed on id(chat_activity), but every Xposed callback hands Python a fresh
Chaquopy wrapper around the same Java object, so the key never matched and
each scroll frame opened SQLite and rescanned the message list.
updateMessagesVisiblePart runs per frame, so that ran continuously.
BaseFragment.classGuid is assigned once per fragment instance, so it
identifies an open chat across calls; measured 1 query per 300 frames
instead of 300. Saved rows are cached per scope, invalidated on save.

The card could attach to the wrong message when many messages were
included. insert() computed the index on the calling thread and applied it
later on the UI thread; a large range takes long enough for paging or an
incoming message to shift the list in between, so a stale index was used.
The index is now computed on the UI thread immediately before the
mutation that consumes it.

Every saved summary appeared stacked together in a row. find_index_for_anchor
fell back to nearest-by-id whenever the anchor was not loaded, and a chat
opens with a short window, so summaries anchored far up all collapsed to
adjacent slots at the bottom. Absence is now reported as -1 and those cards
are skipped until paging brings their anchor in; the id-order fallback stays
opt-in for reanchor, which must place a card it has already removed.
Verified against the 6 real saved summaries for one chat: previously all 6
were injected consecutively on open, now none are, and each appears in its
correct place once its range is scrolled in.

Also makes restore linear in list length rather than rescanning per summary.
2026-09-19 16:51:12 +02:00
lyuksovannyy 7f0fcfe72f Fall back to the runner token when the org secret is absent
Publish plugin / publish (push) Successful in 3s
pluginBase failed with "RELEASE_TOKEN is not set" while chatSummaries
published fine, from the same organisation secret. An organisation secret
is only exposed to a repository that has Actions enabled, so it can be
empty in one repo and present in another.

Try RELEASE_TOKEN first, then the runner-provided GITEA_TOKEN, which can
publish releases in its own repository, and report which one was used.
When none is set, name every variable that was tried and point at the
repository Actions setting rather than failing with a bare message.

The publish step also reports whether the organisation secret arrived,
printing only its length, so the fallback is visible in the job log.
2026-09-13 00:20:58 +02:00
lyuksovannyy 85302e78f0 Read the release token from RELEASE_TOKEN
Publish plugin / publish (push) Successful in 3s
The publish step set GITEA_TOKEN from the org-level API_TOKEN secret, but
the Actions runner injects its own GITEA_TOKEN into every step and that
value overrides a step-level env: entry of the same name. The org secret
was silently ignored and the job failed.

Pass the secret as RELEASE_TOKEN instead, which the runner does not
reserve, and read that name in release.py. --token-env makes the source
configurable, so the runner-provided GITEA_TOKEN can still be used
deliberately by passing --token-env GITEA_TOKEN.
2026-09-13 00:10:01 +02:00
17 changed files with 1961 additions and 354 deletions

No files matched your search

+15 -5
View File
@@ -69,14 +69,24 @@ jobs:
fi fi
echo "tag=${tag}" >> "$GITHUB_OUTPUT" echo "tag=${tag}" >> "$GITHUB_OUTPUT"
echo "publishing as ${tag}" echo "publishing as ${tag}"
- name: Publish release and upload plugin - name: Publish release and upload plugin
env: env:
# Org-level secret holding a user token with write:repository scope. # Passed under a name the runner does not reserve. A step-level
# Not using a `||` fallback to GITEA_TOKEN: Gitea's expression engine # GITEA_TOKEN is overridden by the runner's own job token, so the
# supports only always(), so the operator would not evaluate. # org secret would be silently ignored under that name.
GITEA_TOKEN: ${{ secrets.API_TOKEN }} #
# RELEASE_TOKEN is the org-wide API_TOKEN secret. If it is empty --
# which happens when Actions is disabled on the repository, so org
# secrets are never exposed to it -- release.py falls back to the
# runner's GITEA_TOKEN, which can still publish to its own repo.
RELEASE_TOKEN: ${{ secrets.API_TOKEN }}
run: | run: |
set -eu
if [ -n "${RELEASE_TOKEN:-}" ]; then
echo "org secret API_TOKEN is present (${#RELEASE_TOKEN} chars)"
else
echo "org secret API_TOKEN is empty; falling back to the runner token"
fi
python3 tools/release.py \ python3 tools/release.py \
--api "${{ github.server_url }}/api/v1/repos/${{ github.repository }}" \ --api "${{ github.server_url }}/api/v1/repos/${{ github.repository }}" \
--tag "${{ steps.tag.outputs.tag }}" \ --tag "${{ steps.tag.outputs.tag }}" \
+89 -11
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
@@ -126,16 +192,28 @@ token with the `write:repository` scope (which covers releases). Set it once und
repo-level secret of the same name takes precedence if one repo ever needs different repo-level secret of the same name takes precedence if one repo ever needs different
credentials. credentials.
If you would rather not manage a token, Gitea also injects a per-job `secrets.GITEA_TOKEN` The workflow passes it to `release.py` as `RELEASE_TOKEN`, **not** `GITEA_TOKEN`. The Actions
that can create releases in its own repo — swap the `env:` line in the workflow and drop the runner injects its own `GITEA_TOKEN` into every step, and that value overrides a step-level
org secret. It is scoped to the single job and expires with it, so it is the safer default; `env:` entry of the same name, so the org secret would be silently ignored.
`API_TOKEN` is what you want when publishing needs to reach beyond this repo.
If the org secret does not reach a repository the publish still works: `release.py` tries
`RELEASE_TOKEN` first and then falls back to the runner's `GITEA_TOKEN`, which can publish
releases in its own repository, and prints which one it used. An organisation secret is only
exposed to a repository that has Actions enabled, so check **Repo → Settings → Actions** if a
repo unexpectedly falls back.
If you would rather not manage a token at all, that injected `GITEA_TOKEN` can create
releases in its own repo: pass `--token-env GITEA_TOKEN` and drop the org secret. It is
scoped to the single job and expires with it, so it is the safer default; `API_TOKEN` is what
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>
+1 -1
View File
@@ -3,7 +3,7 @@
"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.0", "version": "1.1.0",
"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",
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# 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
# the commit from a temporary index. The pre-commit bump therefore lands in the
# commit, but the real index keeps the pre-bump blob and plugin.json is left
# looking modified. The real index cannot be updated during the commit because
# git holds index.lock, so refresh it here instead.
#
# Must never fail a commit that already succeeded.
git add -- plugin.json 2>/dev/null || true
exit 0
+265
View File
@@ -0,0 +1,265 @@
#!/usr/bin/env python3
"""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
picked up by the commit currently being created:
* pre-commit -> staged changes land in THIS commit (correct)
* prepare-commit-msg -> staged changes land in the NEXT commit
* commit-msg -> staged changes are ignored entirely
The commit message is read from the invoking `git` process' argv (-m/-F/-C and
long forms). `.git/COMMIT_EDITMSG` is deliberately NOT consulted: at pre-commit
time it still holds the PREVIOUS commit's message, so a stop word there would
silently suppress every later bump.
Bump is skipped when:
* the commit message carries a stop word (see STOP_PATTERNS),
* NO_BUMP / GIT_NO_BUMP is set in the environment,
* plugin.json's version already differs from HEAD (manual bump),
* a merge or rebase is in progress,
* plugin.json is absent or its version is not x.y.z.
For editor-driven commits (`git commit` with no -m) the message does not exist
yet when this hook runs, so use `NO_BUMP=1 git commit` to skip the bump there.
"""
import json
import os
import re
import subprocess
import sys
from pathlib import Path
PLUGIN_FILE = "plugin.json"
STOP_PATTERNS = (
r"\[\s*no[-_ ]?bump\s*\]",
r"\[\s*skip[-_ ]?bump\s*\]",
r"\[\s*no[-_ ]?version[-_ ]?bump\s*\]",
r"\[\s*skip[-_ ]?version\s*\]",
)
STOP_RE = re.compile("|".join(STOP_PATTERNS), re.IGNORECASE)
VERSION_RE = re.compile(r"\A(\d+)\.(\d+)\.(\d+)(.*)\Z", re.DOTALL)
# options whose value IS the message text
TEXT_OPTS = ("-m", "--message")
TEXT_OPTS_EQ = ("--message=",)
# options whose value NAMES a file containing the message
FILE_OPTS = ("-F", "--file")
FILE_OPTS_EQ = ("--file=",)
# options whose value names a commit to take the message from
COMMIT_OPTS = ("-C", "--reuse-message", "-c", "--reedit-message")
COMMIT_OPTS_EQ = ("--reuse-message=", "--reedit-message=")
def repo_root() -> Path:
out = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
)
if out.returncode != 0:
raise RuntimeError(out.stderr.strip() or "not inside a git work tree")
return Path(out.stdout.strip())
def git_dir() -> Path:
out = subprocess.run(
["git", "rev-parse", "--absolute-git-dir"],
capture_output=True,
text=True,
)
if out.returncode != 0:
raise RuntimeError(out.stderr.strip() or "cannot resolve git dir")
return Path(out.stdout.strip())
def bump_patch(version: str) -> str:
m = VERSION_RE.match(version.strip())
if not m:
raise ValueError(f"version {version!r} is not x.y.z")
major, minor, patch, suffix = m.groups()
return f"{major}.{minor}.{int(patch) + 1}{suffix}"
def head_version(root: Path) -> str | None:
"""Version recorded for plugin.json in HEAD, or None when unavailable."""
if subprocess.run(
["git", "rev-parse", "--verify", "--quiet", "HEAD"],
cwd=root,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
).returncode != 0:
return None
show = subprocess.run(
["git", "show", f"HEAD:{PLUGIN_FILE}"],
cwd=root,
capture_output=True,
text=True,
)
if show.returncode != 0:
return None
try:
return str(json.loads(show.stdout)["version"])
except (json.JSONDecodeError, KeyError, TypeError):
return None
def commit_message_text(root: Path) -> str:
"""Commit message text of the git process that spawned this hook.
Covers `-m/--message` (inline), `-F/--file` (read the file) and
`-C/-c/--reuse-message/--reedit-message` (read that commit's message).
Returns "" for editor-driven commits, where no message exists yet.
"""
try:
argv = Path(f"/proc/{os.getppid()}/cmdline").read_bytes().decode("utf-8", "replace").split("\0")
except OSError:
return ""
texts: list[str] = []
pending: str | None = None # "text" | "file" | "commit"
def take(kind: str, value: str) -> None:
if kind == "text":
texts.append(value)
elif kind == "file":
try:
texts.append(Path(value).read_text(encoding="utf-8", errors="replace"))
except OSError:
pass
else:
out = subprocess.run(
["git", "log", "-1", "--format=%B", value],
cwd=root,
capture_output=True,
text=True,
)
if out.returncode == 0:
texts.append(out.stdout)
for arg in argv:
if not arg:
continue
if pending:
take(pending, arg)
pending = None
continue
if arg in TEXT_OPTS:
pending = "text"
elif arg in FILE_OPTS:
pending = "file"
elif arg in COMMIT_OPTS:
pending = "commit"
elif arg.startswith(TEXT_OPTS_EQ):
take("text", arg.split("=", 1)[1])
elif arg.startswith(FILE_OPTS_EQ):
take("file", arg.split("=", 1)[1])
elif arg.startswith(COMMIT_OPTS_EQ):
take("commit", arg.split("=", 1)[1])
elif len(arg) > 1 and arg[0] == "-" and arg[1] != "-":
# clustered short flags, e.g. -am "text" or -am"text"
for kind, letter in (("text", "m"), ("file", "F"), ("commit", "C")):
idx = arg.find(letter, 1)
if idx == -1:
continue
if idx + 1 < len(arg):
take(kind, arg[idx + 1:])
else:
pending = kind
break
return "\n".join(texts)
def strip_comments(text: str) -> str:
return " ".join(
line for line in text.splitlines() if not line.lstrip().startswith("#")
)
def stop_word_reason(root: Path) -> str | None:
if os.environ.get("NO_BUMP") or os.environ.get("GIT_NO_BUMP"):
return "NO_BUMP is set"
match = STOP_RE.search(strip_comments(commit_message_text(root)))
if match:
return f"commit message contains {match.group(0)!r}"
return None
def merge_or_rebase_in_progress(gitdir: Path) -> bool:
return any(
(gitdir / name).exists()
for name in ("MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "rebase-merge", "rebase-apply")
)
def main() -> int:
try:
root = repo_root()
gitdir = git_dir()
except RuntimeError as exc:
print(f"pre-commit: {exc}", file=sys.stderr)
return 0
if merge_or_rebase_in_progress(gitdir):
return 0
reason = stop_word_reason(root)
if reason:
print(f"pre-commit: {reason}, keeping version unchanged")
return 0
path = root / PLUGIN_FILE
if not path.exists():
return 0
raw = path.read_text(encoding="utf-8")
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
print(f"pre-commit: {PLUGIN_FILE} is not valid JSON: {exc}", file=sys.stderr)
return 1
current = str(data.get("version", ""))
previous = head_version(root)
if previous is not None and current != previous:
print(
f"pre-commit: version already changed ({previous} -> {current}), keeping it"
)
return 0
try:
new_version = bump_patch(current)
except ValueError as exc:
print(f"pre-commit: {exc}, keeping version unchanged", file=sys.stderr)
return 0
data["version"] = new_version
trailing = "\n" if raw.endswith("\n") else ""
path.write_text(json.dumps(data, indent=2) + trailing, encoding="utf-8")
# Stage into the index git is actually building for this commit. For
# `git commit -- <pathspec>` that is a temporary index named by
# GIT_INDEX_FILE, so honouring the inherited environment is what makes the
# bump part of THIS commit.
add = subprocess.run(["git", "add", "--", PLUGIN_FILE], cwd=root)
if add.returncode != 0:
print(f"pre-commit: failed to stage {PLUGIN_FILE}", file=sys.stderr)
return add.returncode
# The real index may still hold the pre-bump blob after a pathspec commit;
# scripts/post-commit refreshes it once git releases index.lock.
print(f"pre-commit: version {current} -> {new_version}")
return 0
if __name__ == "__main__":
sys.exit(main())
+14
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,14 @@ 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.
PLACEMENT_RETRIES = 25
PLACEMENT_RETRY_DELAY_MS = 120
+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 = []
+75 -14
View File
@@ -47,6 +47,20 @@ class RestoreHook:
def __init__(self, plugin): def __init__(self, plugin):
self.plugin = plugin self.plugin = plugin
self._unhooks: List[Any] = [] self._unhooks: List[Any] = []
self._scope_state = {"done": set(), "rows": {}}
def invalidate(self) -> None:
"""Drops the restore cache so the next frame re-reads saved summaries.
Called after storing a summary: the gate deliberately skips work for a
scope it has already handled, so a newly saved row would otherwise not
be restored until the chat was reopened.
"""
try:
self._scope_state["done"].clear()
self._scope_state["rows"].clear()
except Exception:
self._scope_state = {"done": set(), "rows": {}}
def install(self) -> None: def install(self) -> None:
try: try:
@@ -75,10 +89,13 @@ class RestoreHook:
return return
plugin = self.plugin plugin = self.plugin
# updateMessagesVisiblePart fires on every scroll frame, so restoring # Shared with invalidate(): `updateMessagesVisiblePart` fires on every
# is gated on the card actually being absent: restore_into() bails as # scroll frame, so restoring is gated on a scope identity that survives
# soon as the summary is already in the list. # across calls (see _scope_identity) plus the loaded window size, and
last_scope = {"key": None} # the DB rows are cached per scope.
# Reuse the dict created in __init__ rather than replacing it, so the
# reference invalidate() operates on stays the one the hook reads.
last_scope = self._scope_state
class _RestoreAfterLoad(MethodHook): class _RestoreAfterLoad(MethodHook):
def after_hooked_method(self, param): def after_hooked_method(self, param):
@@ -107,12 +124,29 @@ class RestoreHook:
self._unhooks = [] self._unhooks = []
def _scope_identity(chat_activity: Any, dialog_id: int, topic_id: int) -> tuple:
"""Identity that is stable across hook invocations for one open chat.
`id(chat_activity)` cannot be used: each Xposed callback hands Python a
fresh Chaquopy wrapper around the same Java object, so its Python id
differs every call and a cache keyed on it never hits. BaseFragment's
classGuid is assigned once per fragment instance, so it identifies the open
ChatActivity for as long as it lives and changes when a new one is opened.
"""
guid = 0
try:
guid = int(chat_activity.getClassGuid())
except Exception:
guid = 0
return (guid, int(dialog_id), int(topic_id))
def restore_into(plugin: Any, chat_activity: Any, last_scope: Any = None) -> None: def restore_into(plugin: Any, chat_activity: Any, last_scope: Any = None) -> None:
"""Injects stored summaries for the chat this ChatActivity is showing. """Injects stored summaries for the chat this ChatActivity is showing.
Called from a high-frequency lifecycle hook, so it must be cheap: it does Called from `updateMessagesVisiblePart`, which runs on every scroll frame,
one DB read per (dialog, topic) it has not seen yet, and so the common case must do no work at all: once a scope has been restored
restore_saved_summaries() skips anything already present in the list. it is remembered and every later frame returns after one tuple compare.
""" """
from services.rich_injector import restore_saved_summaries from services.rich_injector import restore_saved_summaries
@@ -134,21 +168,48 @@ def restore_into(plugin: Any, chat_activity: Any, last_scope: Any = None) -> Non
except Exception: except Exception:
topic_id = 0 topic_id = 0
# Avoid a DB round-trip on every scroll frame; a new scope means a # Gate before touching the DB. `updateMessagesVisiblePart` runs on every
# different chat/topic was opened, which is the only time a restore is due. # scroll frame, and re-querying SQLite plus rescanning the message list each
scope_key = (id(chat_activity), dialog_id, topic_id) # time is what made scrolling stutter in summarized chats.
if last_scope is not None and last_scope.get("key") == scope_key: #
return # The gate includes the loaded message count: a card can only be placed
# while its anchor is loaded, and a chat opens with a short window, so
# summaries anchored further up must get another chance once paging widens
# it. Size changes only when messages are added or removed, so scrolling
# within an unchanged window still does no work.
scope_key = _scope_identity(chat_activity, dialog_id, topic_id)
if last_scope is not None: if last_scope is not None:
last_scope["key"] = scope_key try:
from services.rich_injector import get_messages_list
messages_list = get_messages_list(chat_activity)
loaded = int(messages_list.size()) if messages_list is not None else 0
except Exception:
loaded = 0
state_key = scope_key + (loaded,)
if state_key in last_scope.get("done", ()):
return
done = last_scope.setdefault("done", set())
# Bounded: one entry per (fragment, scope, window size) actually seen.
if len(done) > 256:
done.clear()
done.add(state_key)
# Cache the rows per scope: paging re-runs this for the same chat, and the
# saved set only changes when a new summary is stored.
cache = last_scope.setdefault("rows", {}) if last_scope is not None else {}
summaries = cache.get(scope_key)
if summaries is None:
try: try:
summaries = db.get_for_scope(dialog_id, topic_id) summaries = db.get_for_scope(dialog_id, topic_id)
except Exception as exc: except Exception as exc:
diagnostics.record_error("restore_hook.db.get_for_scope", exc) diagnostics.record_error("restore_hook.db.get_for_scope", exc)
return return
# Rows reserved at stream start have empty text until the stream finishes. # Rows reserved at stream start have empty text until the stream ends.
summaries = [s for s in summaries if (s.text or "").strip()] summaries = [s for s in summaries if (s.text or "").strip()]
if len(cache) > 32:
cache.clear()
cache[scope_key] = summaries
if not summaries: if not summaries:
return return
+70 -8
View File
@@ -102,6 +102,8 @@ class UnreadHook:
self.plugin = plugin self.plugin = plugin
self._unhooks: List[Any] = [] self._unhooks: List[Any] = []
self._hooked_view_ids: set = set() self._hooked_view_ids: set = set()
# dialog_id -> largest unread count seen while this chat stayed open.
self._peak_unread: dict = {}
def install(self) -> None: def install(self) -> None:
try: try:
@@ -307,14 +309,13 @@ class UnreadHook:
diagnostics.record_error("unread_hook._trigger", RuntimeError("could not resolve dialog_id")) diagnostics.record_error("unread_hook._trigger", RuntimeError("could not resolve dialog_id"))
return return
raw_unread = 0 # unread_count decays while the chat is open: viewing messages calls
try: # MessagesController.markDialogAsRead, so by a second press it can
controller = get_messages_controller() # report 15 where the first press saw ~1100. Prefer the unread
dialog = controller.dialogs_dict.get(dialog_id) if controller else None # divider, which is what the app's own button uses
if dialog is not None: # (onPageDownClicked branches on createUnreadMessageAfterId) and
raw_unread = int(getattr(dialog, "unread_count", 0) or 0) # which does not decay while the chat stays open.
except Exception: raw_unread = self._resolve_unread_count(fragment, dialog_id)
raw_unread = 0
if raw_unread > 0: if raw_unread > 0:
count = min(MAX_MSG_COUNT, max(1, raw_unread)) count = min(MAX_MSG_COUNT, max(1, raw_unread))
@@ -330,6 +331,67 @@ class UnreadHook:
except Exception as exc: except Exception as exc:
diagnostics.record_error("unread_hook._trigger", exc) diagnostics.record_error("unread_hook._trigger", exc)
def _resolve_unread_count(self, fragment: Any, dialog_id: int) -> int:
"""Unread message count that stays stable while the chat is open.
Three sources, best first:
1. The unread divider (`createUnreadMessageAfterId`): the id the app's
own pagedown button scrolls to. Counting loaded messages newer than
it gives the real backlog and does not shrink as messages are read.
2. `dialog.unread_count`: accurate on first open, but
MessagesController.markDialogAsRead decrements it while the chat is
viewed, so a second press saw 15 instead of ~1100.
3. The largest value seen earlier for this chat, so a later press never
prefills less than an earlier one.
"""
from client_utils import get_messages_controller
candidates = []
divider_id = 0
try:
divider_id = int(getattr(fragment, "createUnreadMessageAfterId", 0) or 0)
except Exception:
divider_id = 0
if divider_id > 0:
try:
from services.rich_injector import get_messages_list
messages_list = get_messages_list(fragment)
if messages_list is not None:
newer = 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
# Injected cards are negative and date separators are 0.
if msg_id > 0 and msg_id >= divider_id:
newer += 1
if newer > 0:
candidates.append(newer)
except Exception as exc:
diagnostics.record_error("unread_hook.divider_count", exc)
try:
controller = get_messages_controller()
dialog = controller.dialogs_dict.get(dialog_id) if controller else None
if dialog is not None:
candidates.append(int(getattr(dialog, "unread_count", 0) or 0))
except Exception as exc:
diagnostics.record_error("unread_hook.dialog_unread", exc)
previous = int(self._peak_unread.get(dialog_id, 0) or 0)
resolved = max(candidates + [previous]) if candidates or previous else 0
if resolved > 0:
if len(self._peak_unread) > 64:
self._peak_unread.clear()
self._peak_unread[dialog_id] = resolved
diagnostics.note(
f"unread_hook: unread divider={divider_id} candidates={candidates} "
f"previous={previous} resolved={resolved}"
)
return resolved
@staticmethod @staticmethod
def _resolve_dialog_id(fragment: Any) -> Optional[int]: def _resolve_dialog_id(fragment: Any) -> Optional[int]:
try: try:
+202 -80
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,107 +266,93 @@ 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 BulletinHelper.show_info(f"Chat Summaries: reading {count} messages…", fragment)
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_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. 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.
"""
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))
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
if remaining:
remaining -= 1
continue
return msg_id
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.
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
anchor_msg_id = max(int(getattr(m, "id", 0) or 0) for m in messages) anchor_msg_id = max(int(getattr(m, "id", 0) or 0) for m in messages)
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": "",
"thinking": "",
"last_push": 0.0,
"mapping": None,
"injection": None,
"summary_id": 0,
"created_row": False,
}
def open_card():
"""Creates and places the card. Runs once, on the first chunk."""
if state["injection"] is not 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: if db is not None:
try: try:
summary_id = db.save(dialog_id, topic_id, anchor_msg_id, "", "", len(messages)) state["summary_id"], state["created_row"] = db.reserve(
dialog_id, topic_id, anchor_msg_id, len(messages)
)
except Exception as exc: except Exception as exc:
diagnostics.record_error("_stream_into:db.reserve", exc) diagnostics.record_error("_stream_into:db.reserve", exc)
injection = SummaryInjection(
injection.reanchor(anchor_msg_id, virtual_id_for(summary_id, anchor_msg_id)) fragment,
injection.update(status=f"Summarizing {len(messages)} messages…", streaming=True) self._get_account(),
dialog_id,
state = {"answer": "", "thinking": "", "last_push": 0.0, "mapping": None} 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`.
@@ -368,18 +375,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:
@@ -390,18 +396,32 @@ class ChatSummariesPlugin(BasePlugin):
state["thinking"] += text state["thinking"] += text
else: else:
state["answer"] += text state["answer"] += text
if state["injection"] is None:
open_card()
else:
push() 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
@@ -412,6 +432,10 @@ class ChatSummariesPlugin(BasePlugin):
rendered("thinking", final=True), rendered("thinking", final=True),
len(messages), len(messages),
) )
# The restore gate skips scopes it has already handled, so
# the newly saved row has to invalidate that cache or it
# would not be restored until the chat was reopened.
self.restore_hook.invalidate()
except Exception as exc: except Exception as exc:
diagnostics.record_error("_stream_into:db.save", exc) diagnostics.record_error("_stream_into:db.save", exc)
@@ -425,6 +449,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.
@@ -502,3 +610,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"
+286 -133
View File
@@ -16,7 +16,8 @@ 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 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 +109,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 +132,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 +150,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 +168,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:
@@ -181,25 +220,34 @@ def get_chat_adapter(chat_activity: Any) -> Optional[Any]:
return None return None
def find_index_for_anchor(messages_list: Any, anchor_msg_id: int) -> int: def find_index_for_anchor(messages_list: Any, anchor_msg_id: int, require_anchor: bool = True) -> int:
"""Index at which to insert so the summary sits directly after the anchor. """Index at which to insert so the summary sits directly after the anchor.
ChatActivity.messages is newest-first, so "directly after the anchor" ChatActivity.messages is newest-first, so "directly after the anchor"
chronologically means "immediately before it" in list order. chronologically means "immediately before it" in list order.
The anchor is the newest message that was actually analyzed, which is not Returns -1 when the anchor message is not loaded in this view and
the newest message in the chat whenever a non-zero offset was used. If the `require_anchor` is set. Positioning by id order alone is not safe: with a
anchor itself is not loaded (paged out, or never fetched into this view), non-zero offset, or a range that has since paged out, the nearest-by-id slot
fall back to ordering by id rather than to index 0: index 0 is the bottom can sit among messages the summary never analyzed, which is how cards ended
of the chat and would place the card after messages the summary never saw. up attached to the wrong message. Callers skip the insert instead and try
again once the anchor is paged in.
""" """
anchor_msg_id = int(anchor_msg_id) anchor_msg_id = int(anchor_msg_id)
# Anchors are real, positive message ids. A non-positive anchor must never
# match: date separators are local MessageObjects whose messageOwner.id is
# 0, so scanning for 0 would "find" the first separator and place the card
# against a divider instead of a message.
if anchor_msg_id <= 0:
return -1
try: try:
size = messages_list.size() size = messages_list.size()
for i in range(size): for i in range(size):
owner = getattr(messages_list.get(i), "messageOwner", None) owner = getattr(messages_list.get(i), "messageOwner", None)
if owner is not None and int(getattr(owner, "id", 0) or 0) == anchor_msg_id: if owner is not None and int(getattr(owner, "id", 0) or 0) == anchor_msg_id:
return i return i
if require_anchor:
return -1
# Anchor absent: insert before the first real message whose id is at # Anchor absent: insert before the first real message whose id is at
# most the anchor's, i.e. the newest message not newer than the # most the anchor's, i.e. the newest message not newer than the
@@ -215,7 +263,7 @@ def find_index_for_anchor(messages_list: Any, anchor_msg_id: int) -> int:
return size return size
except Exception as exc: except Exception as exc:
diagnostics.record_error("injector.find_index_for_anchor", exc) diagnostics.record_error("injector.find_index_for_anchor", exc)
return 0 return -1
def find_existing(messages_list: Any, virtual_id: int) -> int: def find_existing(messages_list: Any, virtual_id: int) -> int:
@@ -251,7 +299,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
@@ -261,25 +314,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,
@@ -289,53 +330,143 @@ 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
_on_ui(lambda: self._place(PLACEMENT_RETRIES))
return True
existing = find_existing(messages_list, self.virtual_id) def _place(self, retries_left: int) -> None:
index = existing if existing >= 0 else find_index_for_anchor(messages_list, self.anchor_msg_id) """Runs on the UI thread. The index is computed immediately before the
mutation that uses it: computed earlier, paging or an incoming message
def run(): could shift the list and land the card after the wrong message.
"""
try: try:
if existing >= 0: messages_list = get_messages_list(self.chat_activity)
if messages_list is None:
self._placing = False
return
message_object = self.message_object
index = find_existing(messages_list, self.virtual_id)
if index >= 0:
# A card for this summary is already on screen -- the restore
# hook re-injected the saved row being re-summarized. Claim it.
messages_list.set(index, message_object) messages_list.set(index, message_object)
else: else:
index = find_index_for_anchor(messages_list, self.anchor_msg_id)
if index < 0:
if retries_left == PLACEMENT_RETRIES:
# The analyzed range ends outside the loaded window
# (an offset, or history scrolled away). Have the chat
# load and show it, then place once it is in the list.
self._load_anchor()
if retries_left > 0:
_on_ui(lambda: self._place(retries_left - 1), PLACEMENT_RETRY_DELAY_MS)
else:
self._placing = False
diagnostics.note(
f"injector: anchor {self.anchor_msg_id} never loaded, card not placed"
)
return
messages_list.add(index, message_object) messages_list.add(index, message_object)
# A render may have landed while this was queued; show the newest.
if self.message_object is not message_object:
messages_list.set(index, self.message_object)
self.placed = True
self._placing = False
adapter = get_chat_adapter(self.chat_activity) adapter = get_chat_adapter(self.chat_activity)
if adapter is not None: if adapter is not None:
adapter.notifyDataSetChanged() adapter.notifyDataSetChanged()
# The analyzed range can end far up the history, so the card # Once, on placement only -- scrolling on every streaming update
# 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. # would fight the user for control of the list.
if existing < 0:
self.scroll_into_view() self.scroll_into_view()
except Exception as exc: except Exception as exc:
diagnostics.record_error("injector.insert", exc) self._placing = False
diagnostics.record_error("injector.place", exc)
_on_ui(run) def _load_anchor(self) -> None:
return True """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: def scroll_into_view(self) -> bool:
"""Scrolls the chat so the summary card is visible. """Jumps the chat so the card sits a third of the way down the screen,
with its anchor directly above it.
Targets the anchor, not the card: the anchor is a real message the app A hard jump, not scrollToMessageId: for a message already in the list
can resolve and load, and the card sits directly above it, so both end that only does smoothScrollBy() from the current cell positions, which
up on screen. ChatActivity.scrollToMessageId(id, fromMessageId, are stale right after an insert (the layout pass has not run yet), so
select, loadIndex, forceScroll, unreadCount) -- arity verified in the it undershoots or does nothing. Setting the layout manager's pending
APK. 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 not self.anchor_msg_id: if self._jump_to_card():
return True
self._load_anchor()
return False return False
def _jump_to_card(self) -> bool:
try: try:
self.chat_activity.scrollToMessageId( from hook_utils import get_private_field
int(self.anchor_msg_id), 0, False, 0, True, 0
messages_list = get_messages_list(self.chat_activity)
adapter = get_chat_adapter(self.chat_activity)
layout_manager = getattr(self.chat_activity, "chatLayoutManager", None) or get_private_field(
self.chat_activity, "chatLayoutManager"
) )
list_view = _chat_list_view(self.chat_activity)
if messages_list is None or adapter is None or layout_manager is None:
return False
index = find_existing(messages_list, self.virtual_id)
if index < 0:
return False
# Public on ChatActivityAdapter; already current, because
# notifyDataSetChanged() runs updateRowsInternal() before returning.
position = int(adapter.messagesStartRow) + index
offset = 0
if list_view is not None:
try:
list_view.stopScroll()
except Exception:
pass
offset = int(list_view.getMeasuredHeight()) // 3
# bottom=False: offset counts from the top edge, as in
# ChatActivity.scrollToMessageId's own scrollToPosition call.
layout_manager.scrollToPositionWithOffset(position, offset, False)
return True 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(
@@ -344,6 +475,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.
@@ -360,49 +492,42 @@ 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)
else: else:
index = find_index_for_anchor(messages_list, self.anchor_msg_id) index = find_index_for_anchor(messages_list, self.anchor_msg_id)
if index < 0:
# Anchor paged out mid-stream. Keep the streamed
# text; it is re-inserted once the anchor is back.
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:
@@ -426,9 +551,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
@@ -450,87 +573,102 @@ 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.
return getattr(chat_activity, "chatListView", None) or get_private_field(chat_activity, "chatListView")
except Exception:
return None
def remove_cards(chat_activity: Any, virtual_ids) -> None:
"""Drops injected cards from an open chat's list.
ChatActivity removes deleted messages by looking them up in messagesDict,
which injected cards are never added to, so a deleted card would otherwise
stay on screen.
""" """
anchor_msg_id = int(anchor_msg_id) wanted = {int(v) for v in virtual_ids}
virtual_id = int(virtual_id) if chat_activity is None or not wanted:
if anchor_msg_id == self.anchor_msg_id and virtual_id == self.virtual_id:
return
message_object = self.message_object
old_virtual_id = self.virtual_id
self.anchor_msg_id = anchor_msg_id
self.virtual_id = virtual_id
self._used_ids.add(virtual_id)
if message_object is None:
return return
def run(): def run():
try: try:
owner = message_object.messageOwner messages_list = get_messages_list(chat_activity)
messages_list = get_messages_list(self.chat_activity) if messages_list is None:
# Locate the card by its OLD id before rewriting it, otherwise return
# the lookup misses and the card is never moved. removed = 0
current = find_existing(messages_list, old_virtual_id) if messages_list is not None else -1 for i in range(messages_list.size() - 1, -1, -1):
owner = getattr(messages_list.get(i), "messageOwner", None)
owner.id = virtual_id if owner is not None and int(getattr(owner, "id", 0) or 0) in wanted:
try: messages_list.remove(i)
header = _tlrpc().TL_messageReplyHeader() removed += 1
header.reply_to_msg_id = anchor_msg_id if removed:
if self.topic_id: adapter = get_chat_adapter(chat_activity)
header.reply_to_top_id = int(self.topic_id)
owner.reply_to = header
except Exception as exc:
diagnostics.record_error("injector.reanchor.reply_header", exc)
if messages_list is not None and current >= 0:
messages_list.remove(current)
# A card may already sit under the new id -- e.g. the
# restore hook re-injected this summary's saved row. Claim
# that id rather than ending up with two cards.
duplicate = find_existing(messages_list, virtual_id)
if duplicate >= 0:
messages_list.remove(duplicate)
target = find_index_for_anchor(messages_list, anchor_msg_id)
messages_list.add(target, message_object)
adapter = get_chat_adapter(self.chat_activity)
if adapter is not None: if adapter is not None:
adapter.notifyDataSetChanged() adapter.notifyDataSetChanged()
# The card just moved to the real end of the analyzed range,
# which may be somewhere else entirely, so follow it there.
self.scroll_into_view()
except Exception as exc: except Exception as exc:
diagnostics.record_error("injector.reanchor", exc) diagnostics.record_error("injector.remove_cards", exc)
_on_ui(run) _on_ui(run)
def _on_ui(fn) -> None: def _on_ui(fn, delay_ms: int = 0) -> None:
try: try:
from android_utils import run_on_ui_thread from android_utils import run_on_ui_thread
if delay_ms:
run_on_ui_thread(fn, delay_ms)
else:
run_on_ui_thread(fn) run_on_ui_thread(fn)
except Exception: except Exception:
fn() fn()
def loaded_message_ids(messages_list: Any) -> set:
"""Ids currently loaded in the view, in one pass.
Restoring used to scan the whole list once per saved summary; a single set
keeps it linear in the list length no matter how many summaries exist.
"""
ids = set()
try:
for i in range(messages_list.size()):
owner = getattr(messages_list.get(i), "messageOwner", None)
if owner is None:
continue
msg_id = int(getattr(owner, "id", 0) or 0)
# Skip date separators (id 0). Keeping them would make a summary
# whose anchor id is 0 look restorable.
if msg_id:
ids.add(msg_id)
except Exception as exc:
diagnostics.record_error("injector.loaded_message_ids", exc)
return ids
def restore_saved_summaries(chat_activity: Any, account: int, dialog_id: int, topic_id: int, summaries) -> int: def restore_saved_summaries(chat_activity: Any, account: int, dialog_id: int, topic_id: int, summaries) -> int:
"""Re-injects persisted summaries into a freshly opened chat. Returns count.""" """Re-injects persisted summaries into a freshly opened chat.
Only summaries whose anchor is currently loaded are injected: a card whose
anchor is outside the loaded window has no correct position, and guessing
one by id order is what stacked every saved card together at the bottom of
the chat. The rest are picked up on a later call, once paging has brought
their anchor in. Returns how many were dispatched for insertion.
"""
messages_list = get_messages_list(chat_activity) messages_list = get_messages_list(chat_activity)
if messages_list is None or not summaries: if messages_list is None or not summaries:
return 0 return 0
register_mock_sender(account) loaded = loaded_message_ids(messages_list)
pending = [] pending = []
for summary in summaries: for summary in summaries:
virtual_id = virtual_id_for(summary.id, summary.anchor_msg_id) virtual_id = virtual_id_for(summary.id, summary.anchor_msg_id)
if find_existing(messages_list, virtual_id) >= 0: if virtual_id in loaded:
continue
if int(summary.anchor_msg_id) not in loaded:
continue continue
message_object = build_summary_message( message_object = build_summary_message(
account, account,
@@ -541,13 +679,15 @@ 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, message_object)) pending.append((summary.anchor_msg_id, virtual_id, message_object))
if not pending: if not pending:
return 0 return 0
register_mock_sender(account)
# Newest anchor first: each insert shifts everything below it, so placing # Newest anchor first: each insert shifts everything below it, so placing
# the newest card first keeps the remaining lookups pointing at unmoved # the newest card first keeps the remaining lookups pointing at unmoved
# rows. The index is recomputed per card against the live list anyway. # rows. The index is recomputed per card against the live list anyway.
@@ -555,8 +695,21 @@ def restore_saved_summaries(chat_activity: Any, account: int, dialog_id: int, to
def run(): def run():
try: try:
for anchor_msg_id, message_object in pending: # Re-resolve on the UI thread: the list this was queued against may
messages_list.add(find_index_for_anchor(messages_list, anchor_msg_id), message_object) # have paged or been replaced in between.
live = get_messages_list(chat_activity)
if live is None:
return
added = 0
for anchor_msg_id, virtual_id, message_object in pending:
if find_existing(live, virtual_id) >= 0:
continue
index = find_index_for_anchor(live, anchor_msg_id)
if index < 0:
continue
live.add(index, message_object)
added += 1
if added:
adapter = get_chat_adapter(chat_activity) adapter = get_chat_adapter(chat_activity)
if adapter is not None: if adapter is not None:
adapter.notifyDataSetChanged() adapter.notifyDataSetChanged()
+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()
+32 -3
View File
@@ -10,7 +10,9 @@ Stdlib only, so it runs on any runner image without a pip install step.
Usage: Usage:
tools/release.py --api <repo-api-url> --tag v1.0.0 --artifact dist/x.plugin tools/release.py --api <repo-api-url> --tag v1.0.0 --artifact dist/x.plugin
The token is read from GITEA_TOKEN (or GITHUB_TOKEN as a fallback). The token is read from RELEASE_TOKEN by default; override with --token-env.
Deliberately not GITEA_TOKEN: the Actions runner injects its own value for
that name into every step, which overrides a step-level `env:` entry.
""" """
from __future__ import annotations from __future__ import annotations
@@ -147,11 +149,38 @@ def main(argv: Optional[list] = None) -> int:
parser.add_argument("--artifact", required=True, help="file to attach") parser.add_argument("--artifact", required=True, help="file to attach")
parser.add_argument("--commitish", default="", help="target commit for a new tag") parser.add_argument("--commitish", default="", help="target commit for a new tag")
parser.add_argument("--asset-name", default="", help="attachment name; defaults to the file name") parser.add_argument("--asset-name", default="", help="attachment name; defaults to the file name")
parser.add_argument(
"--token-env",
default="RELEASE_TOKEN",
help="preferred environment variable holding the API token",
)
args = parser.parse_args(argv) args = parser.parse_args(argv)
token = os.environ.get("GITEA_TOKEN") or os.environ.get("GITHUB_TOKEN") or "" # Preferred name first, then the runner-provided job token. GITEA_TOKEN is
# not used as the primary name because the Actions runner injects its own
# value for it into every step, which overrides a step-level `env:` entry
# of the same name -- but it is a valid last resort, since it can publish
# releases in its own repository.
candidates = [args.token_env, "RELEASE_TOKEN", "GITEA_TOKEN", "GITHUB_TOKEN"]
token, token_source = "", ""
for name in candidates:
value = os.environ.get(name, "").strip()
if value:
token, token_source = value, name
break
if not token: if not token:
fail("GITEA_TOKEN is not set") present = sorted(n for n in candidates if n in os.environ)
fail(
f"no API token found; looked at {', '.join(dict.fromkeys(candidates))}. "
+ (
f"These exist but are empty: {', '.join(present)}. "
"An organisation secret is only exposed to a repository when Actions "
"is enabled on that repository, so check Repo > Settings > Actions."
if present
else "None of them are set in the environment."
)
)
print(f"using token from {token_source}")
artifact = Path(args.artifact) artifact = Path(args.artifact)
if not artifact.is_file() or artifact.stat().st_size == 0: if not artifact.is_file() or artifact.stat().st_size == 0:
+636 -98
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:
@@ -794,6 +1044,50 @@ def main(argv=None) -> int:
f"installed hooks: {len(plugin.unread_hook._unhooks)} (expected 3)", f"installed hooks: {len(plugin.unread_hook._unhooks)} (expected 3)",
) )
# unread_count decays while a chat is open (markDialogAsRead runs as
# messages are viewed), so pressing the unread button a second time used
# to prefill a far smaller range than the first press: 1100 then 15 then
# 75. The resolved count must not shrink while the chat stays open.
class _UnreadDialog:
def __init__(self, count):
self.unread_count = count
class _UnreadFragment:
def __init__(self, divider, ids):
self.createUnreadMessageAfterId = divider
self.messages = _JavaList()
for mid in ids:
owner_obj = _TLObj("TL_message")
owner_obj.id = mid
self.messages.add(_FakeMessageObject(0, owner_obj))
unread_dialog = _UnreadDialog(1100)
cu_mod = sys.modules["client_utils"]
real_controller = cu_mod.get_messages_controller
class _UnreadController:
class dialogs_dict:
@staticmethod
def get(_dialog_id):
return unread_dialog
cu_mod.get_messages_controller = lambda: _UnreadController
try:
unread_fragment = _UnreadFragment(171000, list(range(172700, 172670, -1)))
resolved = []
for decayed in (1100, 15, 75):
unread_dialog.unread_count = decayed
resolved.append(
plugin.unread_hook._resolve_unread_count(unread_fragment, -1978105175)
)
finally:
cu_mod.get_messages_controller = real_controller
check(
"the unread prefill does not shrink as messages are marked read",
resolved == [1100, 1100, 1100],
f"resolved={resolved}",
)
settings_rows = plugin.create_settings() settings_rows = plugin.create_settings()
settings_keys = {row.key for row in settings_rows if hasattr(row, "key")} settings_keys = {row.key for row in settings_rows if hasattr(row, "key")}
expected_keys = { expected_keys = {
@@ -963,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)}",
) )
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -1132,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",
@@ -1437,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")
@@ -1503,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
@@ -1537,6 +1844,13 @@ def main(argv=None) -> int:
class _FakeListView: class _FakeListView:
def __init__(self): def __init__(self):
self.cells = [] self.cells = []
self.stopped = 0
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)
@@ -1544,6 +1858,13 @@ def main(argv=None) -> int:
def getChildAt(self, index): def getChildAt(self, index):
return self.cells[index] return self.cells[index]
class _FakeLayoutManager:
def __init__(self):
self.jumps = []
def scrollToPositionWithOffset(self, position, offset, bottom):
self.jumps.append((position, offset, bottom))
class _FakeChatActivity: class _FakeChatActivity:
def __init__(self, ids): def __init__(self, ids):
self.messages = _JavaList() self.messages = _JavaList()
@@ -1554,13 +1875,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(
@@ -1582,28 +1914,35 @@ def main(argv=None) -> int:
# Anchor not loaded in this view: falling back to index 0 would put the # Anchor not loaded in this view: falling back to index 0 would put the
# card below messages the summary never saw, which is the bug this # card below messages the summary never saw, which is the bug this
# ordering fallback exists to prevent. # ordering fallback exists to prevent.
# These exercise the id-ordering fallback itself, which is now opt-in:
# callers that must place a card somewhere (reanchor) ask for it, while
# the default refuses to guess a position for an unloaded anchor.
check( check(
"an unloaded anchor is ordered by id instead of dropping to the bottom", "requested id ordering places an unloaded anchor by id",
injector.find_index_for_anchor(offset_activity.messages, 750) == 2, injector.find_index_for_anchor(offset_activity.messages, 750, require_anchor=False) == 2,
f"index={injector.find_index_for_anchor(offset_activity.messages, 750)}", f"index={injector.find_index_for_anchor(offset_activity.messages, 750, require_anchor=False)}",
) )
check( check(
"an anchor older than everything loaded lands at the top of the list", "an anchor older than everything loaded lands at the top of the list",
injector.find_index_for_anchor(offset_activity.messages, 100) injector.find_index_for_anchor(offset_activity.messages, 100, require_anchor=False)
== offset_activity.messages.size(), == offset_activity.messages.size(),
f"index={injector.find_index_for_anchor(offset_activity.messages, 100)}", f"index={injector.find_index_for_anchor(offset_activity.messages, 100, require_anchor=False)}",
) )
check( check(
"an anchor newer than everything loaded lands at the bottom", "an anchor newer than everything loaded lands at the bottom",
injector.find_index_for_anchor(offset_activity.messages, 1000) == 0, injector.find_index_for_anchor(offset_activity.messages, 1000, require_anchor=False) == 0,
f"index={injector.find_index_for_anchor(offset_activity.messages, 1000)}", f"index={injector.find_index_for_anchor(offset_activity.messages, 1000, require_anchor=False)}",
) )
# The real invariant is positional, not a fixed index: every message # The real invariant is positional, not a fixed index: every message
# newer than the anchor stays below the card, every older one above, # newer than the anchor stays below the card, every older one above,
# regardless of injected cards already sitting in the list. # regardless of injected cards already sitting in the list.
def neighbours(activity_obj, anchor): def neighbours(activity_obj, anchor):
index = injector.find_index_for_anchor(activity_obj.messages, anchor) # require_anchor=False so the invariant is checked for unloaded
# anchors too: when a position is chosen at all, it must be correct.
index = injector.find_index_for_anchor(
activity_obj.messages, anchor, require_anchor=False
)
ids = [m.messageOwner.id for m in activity_obj.messages] ids = [m.messageOwner.id for m in activity_obj.messages]
real = [i for i in ids if i > 0] real = [i for i in ids if i > 0]
below = [i for i in ids[:index] if i > 0] below = [i for i in ids[:index] if i > 0]
@@ -1633,7 +1972,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
@@ -1644,12 +1983,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}",
) )
@@ -1669,8 +2014,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
@@ -1699,46 +2044,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])
@@ -1750,18 +2055,101 @@ 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}",
)
# 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 and never.scrolled_to == [300],
f"ids={never.ids()} 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)
@@ -1816,6 +2204,156 @@ def main(argv=None) -> int:
f"ids={ids}", f"ids={ids}",
) )
# Bug: every saved summary appeared stacked in a row at the bottom of
# the chat. Cards whose anchor is outside the loaded window have no
# correct position, and placing them by id order alone collapsed them
# together. They must be skipped until paging brings their anchor in.
narrow = _FakeChatActivity([600, 590, 580])
stranded = injector.restore_saved_summaries(
narrow,
0,
-4242,
0,
[
summary_db_mod.StoredSummary(21, -4242, 0, 120, "ancient", "", 5, 0),
summary_db_mod.StoredSummary(22, -4242, 0, 130, "old", "", 5, 0),
summary_db_mod.StoredSummary(23, -4242, 0, 590, "loaded", "", 5, 0),
],
)
narrow_ids = [m.messageOwner.id for m in narrow.messages]
injected = [i for i in narrow_ids if i < 0]
check(
"summaries whose anchor is not loaded are not injected at all",
stranded == 1 and injected == [injector.virtual_id_for(23, 590)],
f"dispatched={stranded} injected={injected} ids={narrow_ids}",
)
check(
"the one restorable summary still lands directly above its anchor",
narrow_ids.index(injector.virtual_id_for(23, 590)) == narrow_ids.index(590) - 1,
f"ids={narrow_ids}",
)
# Once paging widens the window, the previously skipped summary has a
# real anchor and must be restored on the next attempt.
for older in (130, 120):
owner_obj = _TLObj("TL_message")
owner_obj.id = older
narrow.messages.add(_FakeMessageObject(0, owner_obj))
after_paging = injector.restore_saved_summaries(
narrow,
0,
-4242,
0,
[
summary_db_mod.StoredSummary(21, -4242, 0, 120, "ancient", "", 5, 0),
summary_db_mod.StoredSummary(22, -4242, 0, 130, "old", "", 5, 0),
summary_db_mod.StoredSummary(23, -4242, 0, 590, "loaded", "", 5, 0),
],
)
paged_ids = [m.messageOwner.id for m in narrow.messages]
check(
"summaries are restored once paging loads their anchor",
after_paging == 2
and paged_ids.index(injector.virtual_id_for(21, 120)) == paged_ids.index(120) - 1
and paged_ids.index(injector.virtual_id_for(22, 130)) == paged_ids.index(130) - 1,
f"dispatched={after_paging} ids={paged_ids}",
)
check(
"re-restoring does not duplicate the summary already on screen",
paged_ids.count(injector.virtual_id_for(23, 590)) == 1,
f"ids={paged_ids}",
)
# Bug: the card appeared after the wrong message when many messages
# were included. The insert index was computed on the calling thread and
# applied later on the UI thread; anything arriving in between shifted
# the list and invalidated it. A fake dispatcher that mutates the list
# between computing and applying reproduces that window.
racing = _FakeChatActivity([700, 690, 680])
real_on_ui = injector._on_ui
def _racing_on_ui(fn):
owner_obj = _TLObj("TL_message")
owner_obj.id = 701
racing.messages.add(0, _FakeMessageObject(0, owner_obj))
fn()
injector._on_ui = _racing_on_ui
try:
racing_injection = injector.SummaryInjection(
racing, 0, -4242, 0, 690, injector.virtual_id_for(31, 690)
)
racing_injection.insert("summary body", "", streaming=False)
finally:
injector._on_ui = real_on_ui
racing_ids = [m.messageOwner.id for m in racing.messages]
card = injector.virtual_id_for(31, 690)
check(
"the card lands above its anchor even if the list shifts first",
racing_ids.index(card) == racing_ids.index(690) - 1,
f"ids={racing_ids}",
)
# find_index_for_anchor must report an absent anchor rather than
# guessing index 0 (chat bottom) or a nearest-by-id slot.
absent = _FakeChatActivity([900, 800])
check(
"an unloaded anchor is reported as absent",
injector.find_index_for_anchor(absent.messages, 123) == -1,
f"index={injector.find_index_for_anchor(absent.messages, 123)}",
)
check(
"id-ordered placement is still available when explicitly requested",
injector.find_index_for_anchor(absent.messages, 123, require_anchor=False) == 2,
f"index={injector.find_index_for_anchor(absent.messages, 123, require_anchor=False)}",
)
# Every real chat interleaves date separators, which are local
# MessageObjects carrying messageOwner.id == 0 (ChatActivity builds them
# with `iput v12, ...Message->id` where v12 is 0, and gives them ids from
# a separate dateObjectsStableIds space). They must never be treated as
# the insertion point or counted as a loaded anchor.
dated = _FakeChatActivity([700, 690])
separator_owner = _TLObj("TL_messageService")
separator_owner.id = 0
dated.messages.add(1, _FakeMessageObject(0, separator_owner))
dated_index = injector.find_index_for_anchor(dated.messages, 690)
dated_ids = [m.messageOwner.id for m in dated.messages]
check(
"a date separator is not mistaken for the anchor",
dated_ids[dated_index] == 690,
f"index={dated_index} ids={dated_ids}",
)
# A separator's id 0 must not make "anchor 0" look loaded, and must not
# be offered as a position; anchor ids are always positive.
check(
"a date separator does not satisfy an anchor-loaded check",
injector.find_index_for_anchor(dated.messages, 0) == -1,
f"index={injector.find_index_for_anchor(dated.messages, 0)}",
)
# A summary anchored on a real message must still restore correctly when
# separators sit between the loaded messages.
sep_restore = _FakeChatActivity([800, 790])
sep_owner = _TLObj("TL_messageService")
sep_owner.id = 0
sep_restore.messages.add(1, _FakeMessageObject(0, sep_owner))
sep_n = injector.restore_saved_summaries(
sep_restore,
0,
-4242,
0,
[summary_db_mod.StoredSummary(41, -4242, 0, 790, "text", "", 5, 0)],
)
sep_ids = [m.messageOwner.id for m in sep_restore.messages]
sep_card = injector.virtual_id_for(41, 790)
check(
"summaries restore correctly around date separators",
sep_n == 1 and sep_ids.index(sep_card) == sep_ids.index(790) - 1,
f"dispatched={sep_n} ids={sep_ids}",
)
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)
check( check(
"sys.modules gained no entry for bundled plugin module names", "sys.modules gained no entry for bundled plugin module names",
not ({"main", "config", "services", "providers", "hooks", "ui.summarize_dialog"} & new_modules), not ({"main", "config", "services", "providers", "hooks", "ui.summarize_dialog"} & new_modules),