6 Commits
Author SHA1 Message Date
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
11 changed files with 845 additions and 71 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 }}" \
+14 -4
View File
@@ -126,10 +126,20 @@ 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 -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.0.3",
"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",
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# Companion to scripts/pre-commit.
#
# `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
+262
View File
@@ -0,0 +1,262 @@
#!/usr/bin/env python3
"""Increment the patch component of plugin.json's version as part of the commit.
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())
+81 -20
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
try: messages_list = get_messages_list(chat_activity)
summaries = db.get_for_scope(dialog_id, topic_id) loaded = int(messages_list.size()) if messages_list is not None else 0
except Exception as exc: except Exception:
diagnostics.record_error("restore_hook.db.get_for_scope", exc) loaded = 0
return state_key = scope_key + (loaded,)
# Rows reserved at stream start have empty text until the stream finishes. if state_key in last_scope.get("done", ()):
summaries = [s for s in summaries if (s.text or "").strip()] 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:
summaries = db.get_for_scope(dialog_id, topic_id)
except Exception as exc:
diagnostics.record_error("restore_hook.db.get_for_scope", exc)
return
# Rows reserved at stream start have empty text until the stream ends.
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:
+17
View File
@@ -298,6 +298,13 @@ class ChatSummariesPlugin(BasePlugin):
`skip` mirrors the summarize offset, so with an offset the guess lands `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 where the analyzed range actually ends instead of at the bottom of the
chat. Injected cards carry negative ids and are ignored. chat. Injected cards carry negative ids and are ignored.
When the offset reaches past the loaded window there is no message to
skip to, so fall back to the oldest loaded message rather than 0: the
card only needs somewhere valid to appear while "Fetching messages…" is
shown, and reanchor() moves it to the exact spot once the range is
known. Returning 0 would make the insert fail and the user would click
Summarize and see nothing happen.
""" """
try: try:
from services.rich_injector import get_messages_list from services.rich_injector import get_messages_list
@@ -306,15 +313,18 @@ class ChatSummariesPlugin(BasePlugin):
if messages_list is None: if messages_list is None:
return 0 return 0
remaining = max(0, int(skip)) remaining = max(0, int(skip))
oldest = 0
for i in range(messages_list.size()): for i in range(messages_list.size()):
owner = getattr(messages_list.get(i), "messageOwner", None) owner = getattr(messages_list.get(i), "messageOwner", None)
msg_id = int(getattr(owner, "id", 0) or 0) if owner is not None else 0 msg_id = int(getattr(owner, "id", 0) or 0) if owner is not None else 0
if msg_id <= 0: if msg_id <= 0:
continue continue
oldest = msg_id
if remaining: if remaining:
remaining -= 1 remaining -= 1
continue continue
return msg_id return msg_id
return oldest
except Exception as exc: except Exception as exc:
diagnostics.record_error("_newest_visible_message_id", exc) diagnostics.record_error("_newest_visible_message_id", exc)
return 0 return 0
@@ -412,6 +422,13 @@ 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.
try:
self.restore_hook.invalidate()
except Exception:
pass
except Exception as exc: except Exception as exc:
diagnostics.record_error("_stream_into:db.save", exc) diagnostics.record_error("_stream_into:db.save", exc)
+116 -22
View File
@@ -181,25 +181,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 +224,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:
@@ -294,15 +303,43 @@ class SummaryInjection:
return False return False
self.message_object = message_object self.message_object = message_object
existing = find_existing(messages_list, self.virtual_id) # The index must be computed on the UI thread, immediately before the
index = existing if existing >= 0 else find_index_for_anchor(messages_list, self.anchor_msg_id) # mutation that uses it. Computing it here would race: loading a large
# range takes long enough for paging or an incoming message to shift the
# list, and the stale index then lands the card after the wrong message.
def run(): def run():
try: try:
messages_list_now = get_messages_list(self.chat_activity)
if messages_list_now is None:
return
existing = find_existing(messages_list_now, self.virtual_id)
if existing >= 0: if existing >= 0:
messages_list.set(index, message_object) messages_list_now.set(existing, message_object)
else: else:
messages_list.add(index, message_object) # A live card is a direct response to the user pressing
# Summarize, so it must become visible even when the
# provisional anchor is not loaded (a large offset reaches
# past the loaded window). Fall back to id ordering, which
# still keeps every newer message below the card, and let
# reanchor() move it to the exact spot once the analyzed
# range is known. Restoring saved summaries stays strict --
# there is no follow-up correction there.
index = find_index_for_anchor(messages_list_now, self.anchor_msg_id)
if index < 0:
index = find_index_for_anchor(
messages_list_now, self.anchor_msg_id, require_anchor=False
)
if index < 0:
# No anchor at all (nothing loaded yet, or the offset
# reached past everything). Show the card at the bottom,
# where the user is looking after pressing Summarize;
# reanchor() relocates it once the range is known.
diagnostics.note(
f"injector: anchor {self.anchor_msg_id} unresolved, "
"showing status card at the bottom"
)
index = 0
messages_list_now.add(index, message_object)
adapter = get_chat_adapter(self.chat_activity) adapter = get_chat_adapter(self.chat_activity)
if adapter is not None: if adapter is not None:
adapter.notifyDataSetChanged() adapter.notifyDataSetChanged()
@@ -391,6 +428,10 @@ class SummaryInjection:
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: for stale_id in self._used_ids:
@@ -497,8 +538,16 @@ class SummaryInjection:
duplicate = find_existing(messages_list, virtual_id) duplicate = find_existing(messages_list, virtual_id)
if duplicate >= 0: if duplicate >= 0:
messages_list.remove(duplicate) messages_list.remove(duplicate)
# The card was just removed, so it must go back somewhere:
# the new anchor came from the loaded list and is normally
# present, but if it paged out in between, fall back to id
# ordering rather than dropping the card entirely.
target = find_index_for_anchor(messages_list, anchor_msg_id) target = find_index_for_anchor(messages_list, anchor_msg_id)
messages_list.add(target, message_object) if target < 0:
target = find_index_for_anchor(
messages_list, anchor_msg_id, require_anchor=False
)
messages_list.add(max(target, 0), message_object)
adapter = get_chat_adapter(self.chat_activity) adapter = get_chat_adapter(self.chat_activity)
if adapter is not None: if adapter is not None:
adapter.notifyDataSetChanged() adapter.notifyDataSetChanged()
@@ -520,17 +569,48 @@ def _on_ui(fn) -> None:
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,
@@ -543,11 +623,12 @@ def restore_saved_summaries(chat_activity: Any, account: int, dialog_id: int, to
False, False,
) )
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,11 +636,24 @@ 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.
adapter = get_chat_adapter(chat_activity) live = get_messages_list(chat_activity)
if adapter is not None: if live is None:
adapter.notifyDataSetChanged() 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)
if adapter is not None:
adapter.notifyDataSetChanged()
except Exception as exc: except Exception as exc:
diagnostics.record_error("injector.restore", exc) diagnostics.record_error("injector.restore", exc)
+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:
+225 -8
View File
@@ -794,6 +794,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 = {
@@ -1582,28 +1626,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]
@@ -1816,6 +1867,172 @@ 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}",
)
# A live status card is the only feedback that pressing Summarize did
# anything, so it must appear even when the provisional anchor cannot be
# placed: a large offset reaches past the loaded window, and an
# unresolved anchor arrives as 0. Requiring a loaded anchor here left the
# user staring at an unchanged chat.
for label, anchor in (("unresolved anchor 0", 0), ("anchor past the window", 4242)):
live = _FakeChatActivity([700, 690, 680])
live_injection = injector.SummaryInjection(
live, 0, -4242, 0, anchor, injector.virtual_id_for(0, anchor or 1)
)
shown = live_injection.insert(status="Fetching messages…", streaming=True)
live_ids = [m.messageOwner.id for m in live.messages]
check(
f"the status card still appears with an {label}",
shown and len([i for i in live_ids if i < 0]) == 1,
f"inserted={shown} ids={live_ids}",
)
check( check(
"sys.modules gained no entry for bundled plugin module names", "sys.modules gained no entry for bundled plugin module names",
not ({"main", "config", "services", "providers", "hooks", "ui.summarize_dialog"} & new_modules), not ({"main", "config", "services", "providers", "hooks", "ui.summarize_dialog"} & new_modules),