generated from exteraGramPlugins/pluginBase
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af23d2e682 | ||
|
|
16bf8de84c | ||
|
|
b2105ca753 | ||
|
|
fc76a20420 | ||
|
|
785618233b | ||
|
|
b05d1a274c | ||
|
|
0619c5fe68 | ||
|
|
7f0fcfe72f | ||
|
|
85302e78f0 |
No files matched your search
@@ -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 }}" \
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -78,6 +78,41 @@ plugins that need to reach beyond the public SDK surface.
|
|||||||
- `intents.IntentsManager` — Android intent integration, **exteraGram 12.6.4+ only**.
|
- `intents.IntentsManager` — Android intent integration, **exteraGram 12.6.4+ only**.
|
||||||
<https://plugins.exteragram.app/docs/intents>
|
<https://plugins.exteragram.app/docs/intents>
|
||||||
|
|
||||||
|
## Host internals relied on by the summary card
|
||||||
|
|
||||||
|
Verified against exteraGram's `ChatActivity`/`MessagesController` sources and the 12.5.x dex.
|
||||||
|
|
||||||
|
- **Scrolling.** `ChatActivity.scrollToMessageId(id, 0, false, 0, true, 0)`: for an id that
|
||||||
|
is not loaded it requests history around it and jumps there when it arrives; for a loaded
|
||||||
|
one it only `smoothScrollBy`s from the current cell positions, which are stale right after
|
||||||
|
an insert. To land on a row, set the layout manager's pending position instead:
|
||||||
|
`chatLayoutManager.scrollToPositionWithOffset(chatAdapter.messagesStartRow + index,
|
||||||
|
offset, false)` (`bottom=false` counts `offset` from the top edge). `messagesStartRow` is
|
||||||
|
public and current after `notifyDataSetChanged()`, which runs `updateRowsInternal()`.
|
||||||
|
- **Deleting.** Every delete goes through `MessagesController.deleteMessages` (4 overloads,
|
||||||
|
all starting `(messages, randoms, encryptedChat, dialogId, …)`). Only ids `> 0` are sent to
|
||||||
|
the server; the rest are deleted from the local cache and posted in
|
||||||
|
`NotificationCenter.messagesDeleted`. `ChatActivity` removes deleted rows by looking them up
|
||||||
|
in `messagesDict`, which injected cards are never added to, so the plugin removes them.
|
||||||
|
- **Hashtags in rich text.** `TL_iv.textHashtag` (single field `text`) is rendered by
|
||||||
|
`RichMessageLayout.formatText` exactly like a `TL_messageEntityHashtag` in a normal
|
||||||
|
message (`URLSpanNoUnderline`). Long-pressing it offers Open / Copy hashtag. A `textUrl`
|
||||||
|
whose URL starts with `#` is not a hashtag: it goes through the external-URL path and does
|
||||||
|
nothing.
|
||||||
|
- **Search.** In-chat and hashtag search are server-side (`messages.search`); local results
|
||||||
|
exist only for Saved Messages tags. A locally injected message cannot appear in them.
|
||||||
|
|
||||||
|
## SDK UI details used here
|
||||||
|
|
||||||
|
- `ui.settings.Text(text, subtext=, icon=, accent=, red=, on_click=)`, where `on_click`
|
||||||
|
receives the clicked `View`. `set_setting(key, value, reload_settings=True)` re-renders
|
||||||
|
the settings page.
|
||||||
|
- `AlertDialogBuilder(activity)`; button listeners receive `(builder, which)`;
|
||||||
|
`make_button_red(AlertDialogBuilder.BUTTON_POSITIVE)` after `show()`. A dismissed builder
|
||||||
|
cannot be shown again.
|
||||||
|
- `BulletinHelper.show_*` post to the UI thread themselves.
|
||||||
|
- `android_utils.run_on_ui_thread(fn, delay)`, where `delay` is in milliseconds.
|
||||||
|
|
||||||
## On-device dev server protocol
|
## On-device dev server protocol
|
||||||
|
|
||||||
<https://plugins.exteragram.app/docs/dev-server>
|
<https://plugins.exteragram.app/docs/dev-server>
|
||||||
|
|||||||
+5
-2
@@ -3,9 +3,12 @@
|
|||||||
"name": "Chat Summaries",
|
"name": "Chat Summaries",
|
||||||
"description": "Summarize the last N messages (with offset) in any chat, group, channel, or topic using AI.",
|
"description": "Summarize the last N messages (with offset) in any chat, group, channel, or topic using AI.",
|
||||||
"author": "@lyuksovannyy",
|
"author": "@lyuksovannyy",
|
||||||
"version": "1.0.0",
|
"version": "1.1.1",
|
||||||
"icon": "exteraPlugins/1",
|
"icon": "exteraPlugins/1",
|
||||||
"app_version": ">=12.5.1",
|
"app_version": ">=12.5.1",
|
||||||
"sdk_version": ">=1.4.4.3",
|
"sdk_version": ">=1.4.4.3",
|
||||||
"requirements": ["requests", "debugpy"]
|
"requirements": [
|
||||||
|
"requests",
|
||||||
|
"debugpy"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
Executable
+13
@@ -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
|
||||||
Executable
+265
@@ -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())
|
||||||
@@ -28,6 +28,9 @@ SETTING_DEFAULT_COUNT = "default_count"
|
|||||||
SETTING_DEFAULT_OFFSET = "default_offset"
|
SETTING_DEFAULT_OFFSET = "default_offset"
|
||||||
SETTING_GLOBAL_PROMPT = "global_prompt"
|
SETTING_GLOBAL_PROMPT = "global_prompt"
|
||||||
SETTING_INCLUDE_ATTACHMENTS = "include_attachments"
|
SETTING_INCLUDE_ATTACHMENTS = "include_attachments"
|
||||||
|
# Not a user setting: writing it with reload_settings=True is how the
|
||||||
|
# settings page is re-rendered after the store changes.
|
||||||
|
SETTING_STORAGE_REVISION = "storage_revision"
|
||||||
|
|
||||||
DEFAULT_PROMPT = (
|
DEFAULT_PROMPT = (
|
||||||
"Summarize the following chat conversation. Focus on key points, "
|
"Summarize the following chat conversation. Focus on key points, "
|
||||||
@@ -46,3 +49,22 @@ LONG_PRESS_SECONDS = 0.4
|
|||||||
# rebuilds the RichMessage blocks and rebinds the cell, so pushing on every SSE
|
# rebuilds the RichMessage blocks and rebinds the cell, so pushing on every SSE
|
||||||
# chunk would burn the UI thread for no visible gain.
|
# chunk would burn the UI thread for no visible gain.
|
||||||
STREAM_UPDATE_INTERVAL = 0.25
|
STREAM_UPDATE_INTERVAL = 0.25
|
||||||
|
|
||||||
|
# Appended to every finished summary. Lets a reader tell a local AI summary
|
||||||
|
# from a real message at a glance, and gives copied or forwarded text a
|
||||||
|
# searchable marker.
|
||||||
|
SUMMARY_HASHTAG = "#aisummaries"
|
||||||
|
|
||||||
|
# A summary card can only be placed directly after its newest analyzed
|
||||||
|
# message, which with an offset may not be loaded yet. Placement asks the chat
|
||||||
|
# to load it, then retries this many times, this many ms apart (~10 s), asking
|
||||||
|
# again every PLACEMENT_RELOAD_EVERY retries in case a request was dropped.
|
||||||
|
PLACEMENT_RETRIES = 80
|
||||||
|
PLACEMENT_RETRY_DELAY_MS = 125
|
||||||
|
PLACEMENT_RELOAD_EVERY = 16
|
||||||
|
|
||||||
|
# After jumping to a fresh card, check at these delays (ms) that it is still on
|
||||||
|
# screen and jump again if not. Loading a far anchor makes ChatActivity run its
|
||||||
|
# own postponed scroll, and a cache-then-server reload can re-scroll it, either
|
||||||
|
# of which can land after the jump and carry the view away from the card.
|
||||||
|
JUMP_SETTLE_DELAYS_MS = (250, 700, 1500, 3000)
|
||||||
@@ -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 = []
|
||||||
+81
-20
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+213
-83
@@ -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,101 @@ class ChatSummariesPlugin(BasePlugin):
|
|||||||
offset: int,
|
offset: int,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Shows the summary card immediately, then drives it through every
|
"""Fetches history, then streams the summary into a card.
|
||||||
stage: fetching history, contacting the endpoint, streaming reasoning,
|
|
||||||
streaming the answer.
|
|
||||||
|
|
||||||
The card is injected before the fetch so the click has instant visible
|
Nothing is injected into the chat until the endpoint has actually
|
||||||
feedback. Its anchor is provisional until the fetch returns (the newest
|
started answering. Every failure before that point -- history fetch,
|
||||||
analyzed message id is not known yet) and is corrected via reanchor().
|
unreachable host, bad key, HTTP error, empty history -- is reported as a
|
||||||
|
bulletin and leaves the chat exactly as the user left it: no error
|
||||||
|
bubble to dismiss, no jump away from where they were reading.
|
||||||
"""
|
"""
|
||||||
from services.rich_injector import SummaryInjection, virtual_id_for
|
diagnostics.note(
|
||||||
|
f"summary: dialog={dialog_id} topic={topic_id} count={count} offset={offset}"
|
||||||
account = self._get_account()
|
|
||||||
# With a non-zero offset the analyzed range ends partway up the
|
|
||||||
# history, so skip that many messages when guessing where the card
|
|
||||||
# goes. reanchor() corrects it exactly once the fetch returns.
|
|
||||||
provisional_anchor = self._newest_visible_message_id(fragment, skip=offset)
|
|
||||||
|
|
||||||
injection = SummaryInjection(
|
|
||||||
fragment,
|
|
||||||
account,
|
|
||||||
dialog_id,
|
|
||||||
topic_id,
|
|
||||||
provisional_anchor,
|
|
||||||
virtual_id_for(0, provisional_anchor or 1),
|
|
||||||
)
|
)
|
||||||
if not injection.insert(status="Fetching messages…", streaming=True):
|
BulletinHelper.show_info(f"Chat Summaries: reading {count} messages…", fragment)
|
||||||
BulletinHelper.show_error("Chat Summaries: cannot inject into this chat", fragment)
|
|
||||||
return
|
|
||||||
|
|
||||||
def fail(message: str) -> None:
|
|
||||||
injection.update(answer_markdown=f"**Error:** {message}", streaming=False)
|
|
||||||
BulletinHelper.show_error(f"Chat Summaries: {message}", fragment)
|
|
||||||
|
|
||||||
def on_fetched(messages, error):
|
def on_fetched(messages, error):
|
||||||
if error:
|
if error:
|
||||||
fail(str(error))
|
self._report_error(str(error), fragment)
|
||||||
return
|
return
|
||||||
if not messages:
|
if not messages:
|
||||||
fail("no messages found")
|
self._report_error("no messages found", fragment)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
self._stream_into(injection, fragment, dialog_id, topic_id, prompt, messages)
|
self._stream_into(fragment, dialog_id, topic_id, prompt, messages)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
diagnostics.record_error("on_fetched:_stream_into", exc)
|
diagnostics.record_error("on_fetched:_stream_into", exc)
|
||||||
fail(str(exc))
|
self._report_error(str(exc), fragment)
|
||||||
|
|
||||||
self.fetcher.fetch_history(dialog_id, count, offset, topic_id, on_fetched)
|
self.fetcher.fetch_history(dialog_id, count, offset, topic_id, on_fetched)
|
||||||
|
|
||||||
def _newest_visible_message_id(self, chat_activity: Any, skip: int = 0) -> int:
|
def _report_error(self, message: str, fragment: Any) -> None:
|
||||||
"""Provisional anchor: the newest real message the analysis will cover.
|
diagnostics.note(f"summary failed: {message}")
|
||||||
|
BulletinHelper.show_error(f"Chat Summaries: {message}", fragment)
|
||||||
`skip` mirrors the summarize offset, so with an offset the guess lands
|
|
||||||
where the analyzed range actually ends instead of at the bottom of the
|
|
||||||
chat. Injected cards carry negative ids and are ignored.
|
|
||||||
"""
|
|
||||||
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.
|
||||||
|
|
||||||
anchor_msg_id = max(int(getattr(m, "id", 0) or 0) for m in messages)
|
The card goes directly after the newest analyzed message and the chat
|
||||||
|
jumps to it once, when it is placed.
|
||||||
|
"""
|
||||||
|
from services.rich_injector import SummaryInjection, virtual_id_for
|
||||||
|
|
||||||
|
ids = [int(getattr(m, "id", 0) or 0) for m in messages]
|
||||||
|
anchor_msg_id = max(ids)
|
||||||
|
diagnostics.note(
|
||||||
|
f"summary: fetched {len(messages)} messages, ids {min(ids)}..{anchor_msg_id}"
|
||||||
|
)
|
||||||
db = self._get_summary_db()
|
db = self._get_summary_db()
|
||||||
# Reserve the row now so the virtual id is stable for the whole stream.
|
state = {
|
||||||
summary_id = 0
|
"answer": "",
|
||||||
if db is not None:
|
"thinking": "",
|
||||||
try:
|
"last_push": 0.0,
|
||||||
summary_id = db.save(dialog_id, topic_id, anchor_msg_id, "", "", len(messages))
|
"mapping": None,
|
||||||
except Exception as exc:
|
"injection": None,
|
||||||
diagnostics.record_error("_stream_into:db.reserve", exc)
|
"summary_id": 0,
|
||||||
|
"created_row": False,
|
||||||
|
}
|
||||||
|
|
||||||
injection.reanchor(anchor_msg_id, virtual_id_for(summary_id, anchor_msg_id))
|
def open_card():
|
||||||
injection.update(status=f"Summarizing {len(messages)} messages…", streaming=True)
|
"""Creates and places the card. Runs once, on the first chunk."""
|
||||||
|
if state["injection"] is not None:
|
||||||
state = {"answer": "", "thinking": "", "last_push": 0.0, "mapping": None}
|
return state["injection"]
|
||||||
|
# Reserve the row now so the virtual id is stable for the whole
|
||||||
|
# stream, and matches the card the restore hook may already show
|
||||||
|
# for this range.
|
||||||
|
if db is not None:
|
||||||
|
try:
|
||||||
|
state["summary_id"], state["created_row"] = db.reserve(
|
||||||
|
dialog_id, topic_id, anchor_msg_id, len(messages)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("_stream_into:db.reserve", exc)
|
||||||
|
injection = SummaryInjection(
|
||||||
|
fragment,
|
||||||
|
self._get_account(),
|
||||||
|
dialog_id,
|
||||||
|
topic_id,
|
||||||
|
anchor_msg_id,
|
||||||
|
virtual_id_for(state["summary_id"], anchor_msg_id),
|
||||||
|
)
|
||||||
|
state["injection"] = injection
|
||||||
|
if not injection.insert(
|
||||||
|
answer_markdown=rendered("answer"), thinking=rendered("thinking"), streaming=True
|
||||||
|
):
|
||||||
|
# The summary is still saved when it finishes, and the restore
|
||||||
|
# hook shows it once the chat can take it.
|
||||||
|
BulletinHelper.show_error("Chat Summaries: cannot show the summary in this chat", fragment)
|
||||||
|
state["last_push"] = _now()
|
||||||
|
return injection
|
||||||
|
|
||||||
def rendered(key: str, final: bool = False) -> str:
|
def rendered(key: str, final: bool = False) -> str:
|
||||||
"""Placeholder-substituted view of `answer` or `thinking`.
|
"""Placeholder-substituted view of `answer` or `thinking`.
|
||||||
@@ -368,18 +383,17 @@ class ChatSummariesPlugin(BasePlugin):
|
|||||||
return text
|
return text
|
||||||
return deanonymize(text, mapping, dialog_id)
|
return deanonymize(text, mapping, dialog_id)
|
||||||
|
|
||||||
def push(force: bool = False) -> None:
|
def push(force: bool = False, tagged: bool = False) -> None:
|
||||||
"""Rate-limits re-renders; each one rebuilds blocks and rebinds cells."""
|
"""Rate-limits re-renders; each one rebuilds blocks and rebinds cells."""
|
||||||
import time
|
now = _now()
|
||||||
|
|
||||||
now = time.monotonic()
|
|
||||||
if not force and now - state["last_push"] < STREAM_UPDATE_INTERVAL:
|
if not force and now - state["last_push"] < STREAM_UPDATE_INTERVAL:
|
||||||
return
|
return
|
||||||
state["last_push"] = now
|
state["last_push"] = now
|
||||||
injection.update(
|
state["injection"].update(
|
||||||
answer_markdown=rendered("answer", final=force),
|
answer_markdown=rendered("answer", final=force),
|
||||||
thinking=rendered("thinking", final=force),
|
thinking=rendered("thinking", final=force),
|
||||||
streaming=not force,
|
streaming=not force,
|
||||||
|
tagged=tagged,
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_mapping(mapping) -> None:
|
def on_mapping(mapping) -> None:
|
||||||
@@ -390,18 +404,32 @@ class ChatSummariesPlugin(BasePlugin):
|
|||||||
state["thinking"] += text
|
state["thinking"] += text
|
||||||
else:
|
else:
|
||||||
state["answer"] += text
|
state["answer"] += text
|
||||||
push()
|
if state["injection"] is None:
|
||||||
|
open_card()
|
||||||
|
else:
|
||||||
|
push()
|
||||||
|
|
||||||
def on_done(answer: Optional[str], thinking: Optional[str], error: Optional[str]) -> None:
|
def on_done(answer: Optional[str], thinking: Optional[str], error: Optional[str]) -> None:
|
||||||
if error:
|
if error:
|
||||||
|
self._report_error(error, fragment)
|
||||||
|
if state["injection"] is None:
|
||||||
|
# Failed before the first chunk: the chat was never touched.
|
||||||
|
return
|
||||||
state["answer"] = (state["answer"] or "") + f"\n\n**Error:** {error}"
|
state["answer"] = (state["answer"] or "") + f"\n\n**Error:** {error}"
|
||||||
push(force=True)
|
push(force=True)
|
||||||
BulletinHelper.show_error(f"Chat Summaries: {error}", fragment)
|
# A row created for this stream would never get text; don't
|
||||||
|
# leave it behind. A pre-existing summary keeps its text.
|
||||||
|
if db is not None and state["created_row"] and state["summary_id"]:
|
||||||
|
try:
|
||||||
|
db.delete(state["summary_id"])
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("_stream_into:db.delete", exc)
|
||||||
return
|
return
|
||||||
state["answer"] = answer or state["answer"]
|
state["answer"] = answer or state["answer"]
|
||||||
state["thinking"] = thinking or state["thinking"]
|
state["thinking"] = thinking or state["thinking"]
|
||||||
push(force=True)
|
open_card()
|
||||||
if db is not None and summary_id:
|
push(force=True, tagged=True)
|
||||||
|
if db is not None and state["summary_id"]:
|
||||||
try:
|
try:
|
||||||
# Persist real names and links, not placeholders: the
|
# Persist real names and links, not placeholders: the
|
||||||
# mapping is per-request and would be gone by the time the
|
# mapping is per-request and would be gone by the time the
|
||||||
@@ -412,6 +440,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 +457,90 @@ class ChatSummariesPlugin(BasePlugin):
|
|||||||
topic_id=topic_id,
|
topic_id=topic_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Deleting a card / clearing the store
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def on_cards_deleted(self, dialog_id: int, deleted_ids: list) -> None:
|
||||||
|
"""Called by DeleteHook with the negative ids being deleted in a chat."""
|
||||||
|
from client_utils import get_last_fragment
|
||||||
|
from services.rich_injector import remove_cards, virtual_id_for
|
||||||
|
|
||||||
|
db = self._get_summary_db()
|
||||||
|
if db is None or not dialog_id:
|
||||||
|
return
|
||||||
|
wanted = {int(i) for i in deleted_ids}
|
||||||
|
removed = []
|
||||||
|
try:
|
||||||
|
for summary in db.get_for_dialog(dialog_id):
|
||||||
|
virtual_id = virtual_id_for(summary.id, summary.anchor_msg_id)
|
||||||
|
if virtual_id in wanted and db.delete(summary.id):
|
||||||
|
removed.append(virtual_id)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("on_cards_deleted", exc)
|
||||||
|
return
|
||||||
|
if not removed:
|
||||||
|
return
|
||||||
|
# The restore cache still holds the deleted rows; without this the
|
||||||
|
# card would be re-injected on the next frame.
|
||||||
|
self.restore_hook.invalidate()
|
||||||
|
fragment = get_last_fragment()
|
||||||
|
remove_cards(fragment, removed)
|
||||||
|
BulletinHelper.show_info(
|
||||||
|
"Summary deleted" if len(removed) == 1 else f"{len(removed)} summaries deleted",
|
||||||
|
fragment,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _storage_summary(self) -> str:
|
||||||
|
db = self._get_summary_db()
|
||||||
|
if db is None:
|
||||||
|
return "Storage unavailable"
|
||||||
|
try:
|
||||||
|
count, size = db.stats()
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("_storage_summary", exc)
|
||||||
|
return "Storage unavailable"
|
||||||
|
noun = "summary" if count == 1 else "summaries"
|
||||||
|
return f"{count} {noun} saved · about {_format_size(size)}"
|
||||||
|
|
||||||
|
def _on_clear_all_click(self, view: Any = None) -> None:
|
||||||
|
try:
|
||||||
|
from client_utils import get_last_fragment
|
||||||
|
from ui.alert import AlertDialogBuilder
|
||||||
|
|
||||||
|
fragment = get_last_fragment()
|
||||||
|
activity = fragment.getParentActivity() if fragment is not None else None
|
||||||
|
if activity is None:
|
||||||
|
return
|
||||||
|
builder = AlertDialogBuilder(activity)
|
||||||
|
builder.set_title("Clear all summaries?")
|
||||||
|
builder.set_message(
|
||||||
|
"Every saved summary in every chat will be deleted. This cannot be undone."
|
||||||
|
)
|
||||||
|
builder.set_positive_button("Clear", lambda b, w: self._clear_all_summaries())
|
||||||
|
builder.set_negative_button("Cancel", lambda b, w: b.dismiss())
|
||||||
|
builder.show()
|
||||||
|
builder.make_button_red(AlertDialogBuilder.BUTTON_POSITIVE)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("_on_clear_all_click", exc)
|
||||||
|
|
||||||
|
def _clear_all_summaries(self) -> None:
|
||||||
|
db = self._get_summary_db()
|
||||||
|
if db is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
removed = db.clear_all()
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("_clear_all_summaries", exc)
|
||||||
|
BulletinHelper.show_error("Chat Summaries: could not clear summaries")
|
||||||
|
return
|
||||||
|
self.restore_hook.invalidate()
|
||||||
|
# Re-render the settings page so the size row shows the new total.
|
||||||
|
self.set_setting(SETTING_STORAGE_REVISION, str(_now()), reload_settings=True)
|
||||||
|
BulletinHelper.show_success(
|
||||||
|
"Deleted 1 summary" if removed == 1 else f"Deleted {removed} summaries"
|
||||||
|
)
|
||||||
|
|
||||||
def _fetch_reply_context(self, messages: list) -> dict:
|
def _fetch_reply_context(self, messages: list) -> dict:
|
||||||
"""Reply parents that fall outside the analyzed range.
|
"""Reply parents that fall outside the analyzed range.
|
||||||
|
|
||||||
@@ -502,3 +618,17 @@ class ChatSummariesPlugin(BasePlugin):
|
|||||||
def _save_prompt_for(self, dialog_id: int, topic_id: int, prompt: str) -> None:
|
def _save_prompt_for(self, dialog_id: int, topic_id: int, prompt: str) -> None:
|
||||||
if prompt:
|
if prompt:
|
||||||
self.set_setting(self._prompt_key(dialog_id, topic_id), prompt)
|
self.set_setting(self._prompt_key(dialog_id, topic_id), prompt)
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> float:
|
||||||
|
import time
|
||||||
|
|
||||||
|
return time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
def _format_size(size: int) -> str:
|
||||||
|
if size < 1024:
|
||||||
|
return f"{size} B"
|
||||||
|
if size < 1024 * 1024:
|
||||||
|
return f"{size / 1024:.0f} KB"
|
||||||
|
return f"{size / (1024 * 1024):.1f} MB"
|
||||||
+408
-154
@@ -16,7 +16,14 @@ Verified against exteraGram 12.5.x (dex inspection):
|
|||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
import diagnostics
|
import diagnostics
|
||||||
from services.rich_markdown import build_rich_message
|
from config import (
|
||||||
|
JUMP_SETTLE_DELAYS_MS,
|
||||||
|
PLACEMENT_RELOAD_EVERY,
|
||||||
|
PLACEMENT_RETRIES,
|
||||||
|
PLACEMENT_RETRY_DELAY_MS,
|
||||||
|
SUMMARY_HASHTAG,
|
||||||
|
)
|
||||||
|
from services.rich_markdown import build_rich_message, hashtag_block, markdown_to_plain
|
||||||
|
|
||||||
# Above Telegram's issued id range, so this user exists only locally.
|
# Above Telegram's issued id range, so this user exists only locally.
|
||||||
MOCK_SENDER_ID = 8000000000000001
|
MOCK_SENDER_ID = 8000000000000001
|
||||||
@@ -108,8 +115,15 @@ def build_summary_message(
|
|||||||
thinking: str = "",
|
thinking: str = "",
|
||||||
streaming: bool = False,
|
streaming: bool = False,
|
||||||
status: str = "",
|
status: str = "",
|
||||||
|
tagged: bool = False,
|
||||||
) -> Optional[Any]:
|
) -> Optional[Any]:
|
||||||
"""Builds the local MessageObject for a summary, or None on failure."""
|
"""Builds the local MessageObject for a summary, or None on failure.
|
||||||
|
|
||||||
|
`tagged` marks a finished summary: SUMMARY_HASHTAG is appended to the
|
||||||
|
card as a native hashtag node (TL_iv.textHashtag), and to the message's
|
||||||
|
plain-text body with a TL_messageEntityHashtag. The tag is added here rather than stored, so every
|
||||||
|
summary restored from the DB carries it too.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -124,7 +138,6 @@ def build_summary_message(
|
|||||||
msg.unread = False
|
msg.unread = False
|
||||||
msg.post = False
|
msg.post = False
|
||||||
msg.flags = 0
|
msg.flags = 0
|
||||||
msg.message = ""
|
|
||||||
msg.peer_id = peer_for_dialog(dialog_id)
|
msg.peer_id = peer_for_dialog(dialog_id)
|
||||||
|
|
||||||
sender = TLRPC.TL_peerUser()
|
sender = TLRPC.TL_peerUser()
|
||||||
@@ -143,7 +156,12 @@ def build_summary_message(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
diagnostics.record_error("injector.reply_header", exc)
|
diagnostics.record_error("injector.reply_header", exc)
|
||||||
|
|
||||||
|
tagged = tagged and bool((answer_markdown or "").strip())
|
||||||
|
_set_plain_body(msg, answer_markdown, tagged, TLRPC)
|
||||||
|
|
||||||
msg.rich_message = build_rich_message(answer_markdown, thinking, streaming, status)
|
msg.rich_message = build_rich_message(answer_markdown, thinking, streaming, status)
|
||||||
|
if tagged:
|
||||||
|
msg.rich_message.blocks.add(hashtag_block(SUMMARY_HASHTAG))
|
||||||
msg.flags2 = int(getattr(msg, "flags2", 0) or 0) | FLAGS2_RICH_MESSAGE
|
msg.flags2 = int(getattr(msg, "flags2", 0) or 0) | FLAGS2_RICH_MESSAGE
|
||||||
|
|
||||||
message_object = MessageObject(account, msg, True, True)
|
message_object = MessageObject(account, msg, True, True)
|
||||||
@@ -156,6 +174,33 @@ def build_summary_message(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _utf16_len(text: str) -> int:
|
||||||
|
"""Length in UTF-16 code units, which is what Telegram entity offsets count."""
|
||||||
|
return len(text.encode("utf-16-le")) // 2
|
||||||
|
|
||||||
|
|
||||||
|
def _set_plain_body(msg: Any, answer_markdown: str, tagged: bool, TLRPC: Any) -> None:
|
||||||
|
"""Fills `message` with the summary as plain text.
|
||||||
|
|
||||||
|
The card renders from rich_message, but copy, translate and any text
|
||||||
|
matching in the app read `message`. Left empty, a summary is invisible to
|
||||||
|
all of them.
|
||||||
|
"""
|
||||||
|
text = markdown_to_plain(answer_markdown)
|
||||||
|
if tagged:
|
||||||
|
text = (text + "\n\n" + SUMMARY_HASHTAG) if text else SUMMARY_HASHTAG
|
||||||
|
msg.message = text
|
||||||
|
if not tagged:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
entity = TLRPC.TL_messageEntityHashtag()
|
||||||
|
entity.offset = _utf16_len(text) - _utf16_len(SUMMARY_HASHTAG)
|
||||||
|
entity.length = _utf16_len(SUMMARY_HASHTAG)
|
||||||
|
msg.entities.add(entity)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("injector.hashtag_entity", exc)
|
||||||
|
|
||||||
|
|
||||||
def get_messages_list(chat_activity: Any) -> Optional[Any]:
|
def get_messages_list(chat_activity: Any) -> Optional[Any]:
|
||||||
"""Returns the ChatActivity's live ArrayList<MessageObject>, or None."""
|
"""Returns the ChatActivity's live ArrayList<MessageObject>, or None."""
|
||||||
try:
|
try:
|
||||||
@@ -181,25 +226,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 +269,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 +305,12 @@ def notify_adapter(chat_activity: Any) -> None:
|
|||||||
|
|
||||||
|
|
||||||
class SummaryInjection:
|
class SummaryInjection:
|
||||||
"""A single injected summary message, updatable in place while streaming."""
|
"""A single injected summary message, updatable in place while streaming.
|
||||||
|
|
||||||
|
Created only once the summary has started arriving, with its final anchor
|
||||||
|
and virtual id already known: nothing about it is provisional, so it is
|
||||||
|
placed exactly once, directly after the newest analyzed message.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, chat_activity: Any, account: int, dialog_id: int, topic_id: int, anchor_msg_id: int, virtual_id: int):
|
def __init__(self, chat_activity: Any, account: int, dialog_id: int, topic_id: int, anchor_msg_id: int, virtual_id: int):
|
||||||
self.chat_activity = chat_activity
|
self.chat_activity = chat_activity
|
||||||
@@ -261,25 +320,13 @@ class SummaryInjection:
|
|||||||
self.anchor_msg_id = int(anchor_msg_id)
|
self.anchor_msg_id = int(anchor_msg_id)
|
||||||
self.virtual_id = int(virtual_id)
|
self.virtual_id = int(virtual_id)
|
||||||
self.message_object: Optional[Any] = None
|
self.message_object: Optional[Any] = None
|
||||||
# Every id this injection has ever placed in the list. reanchor()
|
# True once the card is in the list. Until then renders only replace
|
||||||
# changes the id mid-flight, so an update must be able to find and
|
# message_object, which placement reads when it finally lands.
|
||||||
# remove the card under its previous id instead of adding a second one.
|
self.placed = False
|
||||||
self._used_ids = {int(virtual_id)}
|
self._placing = False
|
||||||
|
|
||||||
def insert(
|
def _build(self, answer_markdown: str, thinking: str, streaming: bool, status: str, tagged: bool) -> Optional[Any]:
|
||||||
self,
|
return build_summary_message(
|
||||||
answer_markdown: str = "",
|
|
||||||
thinking: str = "",
|
|
||||||
streaming: bool = True,
|
|
||||||
status: str = "",
|
|
||||||
) -> bool:
|
|
||||||
messages_list = get_messages_list(self.chat_activity)
|
|
||||||
if messages_list is None:
|
|
||||||
diagnostics.note("injector: ChatActivity messages list not found")
|
|
||||||
return False
|
|
||||||
|
|
||||||
register_mock_sender(self.account)
|
|
||||||
message_object = build_summary_message(
|
|
||||||
self.account,
|
self.account,
|
||||||
self.dialog_id,
|
self.dialog_id,
|
||||||
self.topic_id,
|
self.topic_id,
|
||||||
@@ -289,53 +336,223 @@ class SummaryInjection:
|
|||||||
thinking,
|
thinking,
|
||||||
streaming,
|
streaming,
|
||||||
status,
|
status,
|
||||||
|
tagged,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def insert(
|
||||||
|
self,
|
||||||
|
answer_markdown: str = "",
|
||||||
|
thinking: str = "",
|
||||||
|
streaming: bool = True,
|
||||||
|
status: str = "",
|
||||||
|
tagged: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
"""Places the card after its anchor and jumps the chat to it.
|
||||||
|
|
||||||
|
Returns False only when the chat cannot take an injected card at all.
|
||||||
|
An anchor that is not loaded yet is not a failure: the chat is asked to
|
||||||
|
load it and placement retries until it arrives.
|
||||||
|
"""
|
||||||
|
if get_messages_list(self.chat_activity) is None:
|
||||||
|
diagnostics.note("injector: ChatActivity messages list not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
register_mock_sender(self.account)
|
||||||
|
message_object = self._build(answer_markdown, thinking, streaming, status, tagged)
|
||||||
if message_object is None:
|
if message_object is None:
|
||||||
return False
|
return False
|
||||||
self.message_object = message_object
|
self.message_object = message_object
|
||||||
|
self._placing = True
|
||||||
existing = find_existing(messages_list, self.virtual_id)
|
_on_ui(lambda: self._place(PLACEMENT_RETRIES))
|
||||||
index = existing if existing >= 0 else find_index_for_anchor(messages_list, self.anchor_msg_id)
|
|
||||||
|
|
||||||
def run():
|
|
||||||
try:
|
|
||||||
if existing >= 0:
|
|
||||||
messages_list.set(index, message_object)
|
|
||||||
else:
|
|
||||||
messages_list.add(index, message_object)
|
|
||||||
adapter = get_chat_adapter(self.chat_activity)
|
|
||||||
if adapter is not None:
|
|
||||||
adapter.notifyDataSetChanged()
|
|
||||||
# The analyzed range can end far up the history, so the card
|
|
||||||
# may be injected off-screen. Bring it into view once, on the
|
|
||||||
# first insert only -- scrolling on every streaming update
|
|
||||||
# would fight the user for control of the list.
|
|
||||||
if existing < 0:
|
|
||||||
self.scroll_into_view()
|
|
||||||
except Exception as exc:
|
|
||||||
diagnostics.record_error("injector.insert", exc)
|
|
||||||
|
|
||||||
_on_ui(run)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def scroll_into_view(self) -> bool:
|
def _place(self, retries_left: int) -> None:
|
||||||
"""Scrolls the chat so the summary card is visible.
|
"""Runs on the UI thread. The index is computed immediately before the
|
||||||
|
mutation that uses it: computed earlier, paging or an incoming message
|
||||||
Targets the anchor, not the card: the anchor is a real message the app
|
could shift the list and land the card after the wrong message.
|
||||||
can resolve and load, and the card sits directly above it, so both end
|
|
||||||
up on screen. ChatActivity.scrollToMessageId(id, fromMessageId,
|
|
||||||
select, loadIndex, forceScroll, unreadCount) -- arity verified in the
|
|
||||||
APK.
|
|
||||||
"""
|
"""
|
||||||
if not self.anchor_msg_id:
|
|
||||||
return False
|
|
||||||
try:
|
try:
|
||||||
self.chat_activity.scrollToMessageId(
|
messages_list = get_messages_list(self.chat_activity)
|
||||||
int(self.anchor_msg_id), 0, False, 0, True, 0
|
if messages_list is None:
|
||||||
|
self._placing = False
|
||||||
|
return
|
||||||
|
message_object = self.message_object
|
||||||
|
index = find_existing(messages_list, self.virtual_id)
|
||||||
|
if index >= 0:
|
||||||
|
# A card for this summary is already on screen -- the restore
|
||||||
|
# hook re-injected the saved row being re-summarized. Claim it.
|
||||||
|
messages_list.set(index, message_object)
|
||||||
|
else:
|
||||||
|
index = find_index_for_anchor(messages_list, self.anchor_msg_id)
|
||||||
|
if index < 0:
|
||||||
|
attempt = PLACEMENT_RETRIES - retries_left
|
||||||
|
if retries_left > 0 and attempt % PLACEMENT_RELOAD_EVERY == 0:
|
||||||
|
# The analyzed range ends outside the loaded window
|
||||||
|
# (an offset, or history scrolled away). Have the chat
|
||||||
|
# load and show it, then place once it is in the list.
|
||||||
|
# Asked again periodically: scrollToMessageId drops the
|
||||||
|
# request outright while waitingForGetDifference, and
|
||||||
|
# a load can be superseded by another one.
|
||||||
|
diagnostics.note(
|
||||||
|
f"injector: anchor {self.anchor_msg_id} not loaded "
|
||||||
|
f"({_id_span(messages_list)}), requesting it (attempt {attempt})"
|
||||||
|
)
|
||||||
|
self._load_anchor()
|
||||||
|
if retries_left > 0:
|
||||||
|
_on_ui(lambda: self._place(retries_left - 1), PLACEMENT_RETRY_DELAY_MS)
|
||||||
|
else:
|
||||||
|
self._placing = False
|
||||||
|
diagnostics.note(
|
||||||
|
f"injector: anchor {self.anchor_msg_id} never loaded, card not placed"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
messages_list.add(index, message_object)
|
||||||
|
# A render may have landed while this was queued; show the newest.
|
||||||
|
if self.message_object is not message_object:
|
||||||
|
messages_list.set(index, self.message_object)
|
||||||
|
self.placed = True
|
||||||
|
self._placing = False
|
||||||
|
diagnostics.note(
|
||||||
|
f"injector: card placed after anchor {self.anchor_msg_id} at index {index} "
|
||||||
|
f"(retries left {retries_left})"
|
||||||
)
|
)
|
||||||
|
adapter = get_chat_adapter(self.chat_activity)
|
||||||
|
if adapter is not None:
|
||||||
|
adapter.notifyDataSetChanged()
|
||||||
|
# On placement only -- scrolling on every streaming update would
|
||||||
|
# fight the user for control of the list. The settle checks right
|
||||||
|
# after only undo scrolls the app itself makes in that window.
|
||||||
|
if self.scroll_into_view():
|
||||||
|
self._schedule_settle(0)
|
||||||
|
except Exception as exc:
|
||||||
|
self._placing = False
|
||||||
|
diagnostics.record_error("injector.place", exc)
|
||||||
|
|
||||||
|
def _schedule_settle(self, step: int) -> None:
|
||||||
|
if step >= len(JUMP_SETTLE_DELAYS_MS):
|
||||||
|
return
|
||||||
|
previous = JUMP_SETTLE_DELAYS_MS[step - 1] if step else 0
|
||||||
|
_on_ui(lambda: self._settle(step), JUMP_SETTLE_DELAYS_MS[step] - previous)
|
||||||
|
|
||||||
|
def _settle(self, step: int) -> None:
|
||||||
|
"""Re-jumps if something scrolled the fresh card off screen.
|
||||||
|
|
||||||
|
Stops for good as soon as the user touches the list: from then on
|
||||||
|
where the chat is scrolled is theirs to decide.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
list_view = _chat_list_view(self.chat_activity)
|
||||||
|
if list_view is not None and int(list_view.getScrollState()) == 1: # SCROLL_STATE_DRAGGING
|
||||||
|
diagnostics.note("injector: user is scrolling, jump settled")
|
||||||
|
return
|
||||||
|
span = self._visible_span()
|
||||||
|
if span is not None:
|
||||||
|
position, first, last = span
|
||||||
|
if not first <= position <= last:
|
||||||
|
diagnostics.note(
|
||||||
|
f"injector: card row {position} scrolled away (visible {first}..{last}), "
|
||||||
|
f"jumping back (check {step})"
|
||||||
|
)
|
||||||
|
self._jump_to_card()
|
||||||
|
elif self.placed and find_existing(get_messages_list(self.chat_activity), self.virtual_id) < 0:
|
||||||
|
# The list was reloaded under the card. Put it back.
|
||||||
|
messages_list = get_messages_list(self.chat_activity)
|
||||||
|
index = find_index_for_anchor(messages_list, self.anchor_msg_id)
|
||||||
|
if index >= 0:
|
||||||
|
diagnostics.note(f"injector: list reloaded, re-placing card (check {step})")
|
||||||
|
messages_list.add(index, self.message_object)
|
||||||
|
adapter = get_chat_adapter(self.chat_activity)
|
||||||
|
if adapter is not None:
|
||||||
|
adapter.notifyDataSetChanged()
|
||||||
|
self._jump_to_card()
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("injector.settle", exc)
|
||||||
|
self._schedule_settle(step + 1)
|
||||||
|
|
||||||
|
def _visible_span(self) -> Optional[tuple]:
|
||||||
|
"""(card adapter position, first visible, last visible), or None."""
|
||||||
|
try:
|
||||||
|
from hook_utils import get_private_field
|
||||||
|
|
||||||
|
messages_list = get_messages_list(self.chat_activity)
|
||||||
|
adapter = get_chat_adapter(self.chat_activity)
|
||||||
|
layout_manager = getattr(self.chat_activity, "chatLayoutManager", None) or get_private_field(
|
||||||
|
self.chat_activity, "chatLayoutManager"
|
||||||
|
)
|
||||||
|
if messages_list is None or adapter is None or layout_manager is None:
|
||||||
|
return None
|
||||||
|
index = find_existing(messages_list, self.virtual_id)
|
||||||
|
if index < 0:
|
||||||
|
return None
|
||||||
|
first = int(layout_manager.findFirstVisibleItemPosition())
|
||||||
|
last = int(layout_manager.findLastVisibleItemPosition())
|
||||||
|
if first < 0 or last < 0:
|
||||||
|
return None
|
||||||
|
return int(adapter.messagesStartRow) + index, min(first, last), max(first, last)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("injector.visible_span", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _load_anchor(self) -> None:
|
||||||
|
"""Asks the chat to load the anchor and scroll to it.
|
||||||
|
|
||||||
|
ChatActivity.scrollToMessageId(id, fromMessageId, select, loadIndex,
|
||||||
|
forceScroll, unreadCount) -- arity verified in the APK. For an
|
||||||
|
unloaded id it requests history around it and jumps there on arrival.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.chat_activity.scrollToMessageId(int(self.anchor_msg_id), 0, False, 0, True, 0)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("injector.load_anchor", exc)
|
||||||
|
|
||||||
|
def scroll_into_view(self) -> bool:
|
||||||
|
"""Jumps the chat so the card sits a third of the way down the screen,
|
||||||
|
with its anchor directly above it.
|
||||||
|
|
||||||
|
A hard jump, not scrollToMessageId: for a message already in the list
|
||||||
|
that only does smoothScrollBy() from the current cell positions, which
|
||||||
|
are stale right after an insert (the layout pass has not run yet), so
|
||||||
|
it undershoots or does nothing. Setting the layout manager's pending
|
||||||
|
position is applied on the next layout, after the insert, and is what
|
||||||
|
ChatActivity itself does when it must land on a specific row.
|
||||||
|
"""
|
||||||
|
if self._jump_to_card():
|
||||||
|
return True
|
||||||
|
self._load_anchor()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _jump_to_card(self) -> bool:
|
||||||
|
try:
|
||||||
|
from hook_utils import get_private_field
|
||||||
|
|
||||||
|
messages_list = get_messages_list(self.chat_activity)
|
||||||
|
adapter = get_chat_adapter(self.chat_activity)
|
||||||
|
layout_manager = getattr(self.chat_activity, "chatLayoutManager", None) or get_private_field(
|
||||||
|
self.chat_activity, "chatLayoutManager"
|
||||||
|
)
|
||||||
|
list_view = _chat_list_view(self.chat_activity)
|
||||||
|
if messages_list is None or adapter is None or layout_manager is None:
|
||||||
|
return False
|
||||||
|
index = find_existing(messages_list, self.virtual_id)
|
||||||
|
if index < 0:
|
||||||
|
return False
|
||||||
|
# Public on ChatActivityAdapter; already current, because
|
||||||
|
# notifyDataSetChanged() runs updateRowsInternal() before returning.
|
||||||
|
position = int(adapter.messagesStartRow) + index
|
||||||
|
offset = 0
|
||||||
|
if list_view is not None:
|
||||||
|
try:
|
||||||
|
list_view.stopScroll()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
offset = int(list_view.getMeasuredHeight()) // 3
|
||||||
|
# bottom=False: offset counts from the top edge, as in
|
||||||
|
# ChatActivity.scrollToMessageId's own scrollToPosition call.
|
||||||
|
layout_manager.scrollToPositionWithOffset(position, offset, False)
|
||||||
|
diagnostics.note(f"injector: jump to row {position} offset {offset}")
|
||||||
return True
|
return True
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
diagnostics.record_error("injector.scroll_into_view", exc)
|
diagnostics.record_error("injector.jump_to_card", exc)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def update(
|
def update(
|
||||||
@@ -344,6 +561,7 @@ class SummaryInjection:
|
|||||||
thinking: str = "",
|
thinking: str = "",
|
||||||
streaming: bool = True,
|
streaming: bool = True,
|
||||||
status: str = "",
|
status: str = "",
|
||||||
|
tagged: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Re-renders the card in place, without rebuilding the whole list.
|
"""Re-renders the card in place, without rebuilding the whole list.
|
||||||
|
|
||||||
@@ -360,49 +578,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 +637,7 @@ class SummaryInjection:
|
|||||||
delegate = getattr(self.chat_activity, "chatMessageCellDelegate", None) or get_private_field(
|
delegate = getattr(self.chat_activity, "chatMessageCellDelegate", None) or get_private_field(
|
||||||
self.chat_activity, "chatMessageCellDelegate"
|
self.chat_activity, "chatMessageCellDelegate"
|
||||||
)
|
)
|
||||||
list_view = getattr(self.chat_activity, "chatListView", None) or get_private_field(
|
list_view = _chat_list_view(self.chat_activity)
|
||||||
self.chat_activity, "chatListView"
|
|
||||||
)
|
|
||||||
if delegate is None or list_view is None:
|
if delegate is None or list_view is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -450,87 +659,117 @@ class SummaryInjection:
|
|||||||
diagnostics.record_error("injector._force_update_cell", exc)
|
diagnostics.record_error("injector._force_update_cell", exc)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def reanchor(self, anchor_msg_id: int, virtual_id: int) -> None:
|
|
||||||
"""Re-targets an already-visible card once the analyzed range is known.
|
|
||||||
|
|
||||||
The card is injected on click, before any messages are fetched, so both
|
def _chat_list_view(chat_activity: Any) -> Optional[Any]:
|
||||||
the anchor and the DB row id only become known later. Moving it keeps
|
try:
|
||||||
the promise that a summary sits directly after its newest analyzed
|
from hook_utils import get_private_field
|
||||||
message.
|
|
||||||
"""
|
|
||||||
anchor_msg_id = int(anchor_msg_id)
|
|
||||||
virtual_id = int(virtual_id)
|
|
||||||
if anchor_msg_id == self.anchor_msg_id and virtual_id == self.virtual_id:
|
|
||||||
return
|
|
||||||
|
|
||||||
message_object = self.message_object
|
return getattr(chat_activity, "chatListView", None) or get_private_field(chat_activity, "chatListView")
|
||||||
old_virtual_id = self.virtual_id
|
except Exception:
|
||||||
self.anchor_msg_id = anchor_msg_id
|
return None
|
||||||
self.virtual_id = virtual_id
|
|
||||||
self._used_ids.add(virtual_id)
|
|
||||||
if message_object is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
def run():
|
|
||||||
try:
|
|
||||||
owner = message_object.messageOwner
|
|
||||||
messages_list = get_messages_list(self.chat_activity)
|
|
||||||
# Locate the card by its OLD id before rewriting it, otherwise
|
|
||||||
# the lookup misses and the card is never moved.
|
|
||||||
current = find_existing(messages_list, old_virtual_id) if messages_list is not None else -1
|
|
||||||
|
|
||||||
owner.id = virtual_id
|
def _id_span(messages_list: Any) -> str:
|
||||||
try:
|
"""'loaded ids A..B, N rows' for diagnostics."""
|
||||||
header = _tlrpc().TL_messageReplyHeader()
|
try:
|
||||||
header.reply_to_msg_id = anchor_msg_id
|
ids = [
|
||||||
if self.topic_id:
|
int(getattr(getattr(messages_list.get(i), "messageOwner", None), "id", 0) or 0)
|
||||||
header.reply_to_top_id = int(self.topic_id)
|
for i in range(messages_list.size())
|
||||||
owner.reply_to = header
|
]
|
||||||
except Exception as exc:
|
real = [i for i in ids if i > 0]
|
||||||
diagnostics.record_error("injector.reanchor.reply_header", exc)
|
if not real:
|
||||||
|
return f"no messages, {len(ids)} rows"
|
||||||
|
return f"loaded ids {min(real)}..{max(real)}, {len(ids)} rows"
|
||||||
|
except Exception:
|
||||||
|
return "loaded ids unknown"
|
||||||
|
|
||||||
if messages_list is not None and current >= 0:
|
|
||||||
messages_list.remove(current)
|
def remove_cards(chat_activity: Any, virtual_ids) -> None:
|
||||||
# A card may already sit under the new id -- e.g. the
|
"""Drops injected cards from an open chat's list.
|
||||||
# restore hook re-injected this summary's saved row. Claim
|
|
||||||
# that id rather than ending up with two cards.
|
ChatActivity removes deleted messages by looking them up in messagesDict,
|
||||||
duplicate = find_existing(messages_list, virtual_id)
|
which injected cards are never added to, so a deleted card would otherwise
|
||||||
if duplicate >= 0:
|
stay on screen.
|
||||||
messages_list.remove(duplicate)
|
"""
|
||||||
target = find_index_for_anchor(messages_list, anchor_msg_id)
|
wanted = {int(v) for v in virtual_ids}
|
||||||
messages_list.add(target, message_object)
|
if chat_activity is None or not wanted:
|
||||||
adapter = get_chat_adapter(self.chat_activity)
|
return
|
||||||
|
|
||||||
|
def run():
|
||||||
|
try:
|
||||||
|
messages_list = get_messages_list(chat_activity)
|
||||||
|
if messages_list is None:
|
||||||
|
return
|
||||||
|
removed = 0
|
||||||
|
for i in range(messages_list.size() - 1, -1, -1):
|
||||||
|
owner = getattr(messages_list.get(i), "messageOwner", None)
|
||||||
|
if owner is not None and int(getattr(owner, "id", 0) or 0) in wanted:
|
||||||
|
messages_list.remove(i)
|
||||||
|
removed += 1
|
||||||
|
if removed:
|
||||||
|
adapter = get_chat_adapter(chat_activity)
|
||||||
if adapter is not None:
|
if adapter is not None:
|
||||||
adapter.notifyDataSetChanged()
|
adapter.notifyDataSetChanged()
|
||||||
# The card just moved to the real end of the analyzed range,
|
except Exception as exc:
|
||||||
# which may be somewhere else entirely, so follow it there.
|
diagnostics.record_error("injector.remove_cards", exc)
|
||||||
self.scroll_into_view()
|
|
||||||
except Exception as exc:
|
|
||||||
diagnostics.record_error("injector.reanchor", exc)
|
|
||||||
|
|
||||||
_on_ui(run)
|
_on_ui(run)
|
||||||
|
|
||||||
|
|
||||||
def _on_ui(fn) -> None:
|
def _on_ui(fn, delay_ms: int = 0) -> None:
|
||||||
try:
|
try:
|
||||||
from android_utils import run_on_ui_thread
|
from android_utils import run_on_ui_thread
|
||||||
|
|
||||||
run_on_ui_thread(fn)
|
if delay_ms:
|
||||||
|
run_on_ui_thread(fn, delay_ms)
|
||||||
|
else:
|
||||||
|
run_on_ui_thread(fn)
|
||||||
except Exception:
|
except Exception:
|
||||||
fn()
|
fn()
|
||||||
|
|
||||||
|
|
||||||
|
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 +780,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,11 +796,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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
+722
-98
@@ -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,17 @@ def main(argv=None) -> int:
|
|||||||
class _FakeListView:
|
class _FakeListView:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.cells = []
|
self.cells = []
|
||||||
|
self.stopped = 0
|
||||||
|
self.scroll_state = 0
|
||||||
|
|
||||||
|
def getScrollState(self):
|
||||||
|
return self.scroll_state
|
||||||
|
|
||||||
|
def getMeasuredHeight(self):
|
||||||
|
return 900
|
||||||
|
|
||||||
|
def stopScroll(self):
|
||||||
|
self.stopped += 1
|
||||||
|
|
||||||
def getChildCount(self):
|
def getChildCount(self):
|
||||||
return len(self.cells)
|
return len(self.cells)
|
||||||
@@ -1544,6 +1862,32 @@ def main(argv=None) -> int:
|
|||||||
def getChildAt(self, index):
|
def getChildAt(self, index):
|
||||||
return self.cells[index]
|
return self.cells[index]
|
||||||
|
|
||||||
|
class _FakeLayoutManager:
|
||||||
|
"""Records jumps and reports the visible row range they produce.
|
||||||
|
|
||||||
|
`hijack` simulates ChatActivity's own postponed scroll landing
|
||||||
|
right after a jump and carrying the view elsewhere.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.jumps = []
|
||||||
|
self.visible = (-1, -1)
|
||||||
|
self.hijack = 0
|
||||||
|
|
||||||
|
def scrollToPositionWithOffset(self, position, offset, bottom):
|
||||||
|
self.jumps.append((position, offset, bottom))
|
||||||
|
if self.hijack:
|
||||||
|
self.hijack -= 1
|
||||||
|
self.visible = (40, 46)
|
||||||
|
else:
|
||||||
|
self.visible = (max(0, position - 3), position + 3)
|
||||||
|
|
||||||
|
def findFirstVisibleItemPosition(self):
|
||||||
|
return self.visible[0]
|
||||||
|
|
||||||
|
def findLastVisibleItemPosition(self):
|
||||||
|
return self.visible[1]
|
||||||
|
|
||||||
class _FakeChatActivity:
|
class _FakeChatActivity:
|
||||||
def __init__(self, ids):
|
def __init__(self, ids):
|
||||||
self.messages = _JavaList()
|
self.messages = _JavaList()
|
||||||
@@ -1554,13 +1898,24 @@ def main(argv=None) -> int:
|
|||||||
self.chatAdapter = _FakeAdapter()
|
self.chatAdapter = _FakeAdapter()
|
||||||
self.chatMessageCellDelegate = _FakeDelegate()
|
self.chatMessageCellDelegate = _FakeDelegate()
|
||||||
self.chatListView = _FakeListView()
|
self.chatListView = _FakeListView()
|
||||||
|
self.chatLayoutManager = _FakeLayoutManager()
|
||||||
self.scrolled_to = []
|
self.scrolled_to = []
|
||||||
|
# Ids scrollToMessageId will "load" into the list, mimicking
|
||||||
|
# the history request it issues for an unloaded id.
|
||||||
|
self.loadable = {}
|
||||||
|
|
||||||
def getDialogId(self):
|
def getDialogId(self):
|
||||||
return -4242
|
return -4242
|
||||||
|
|
||||||
def scrollToMessageId(self, msg_id, from_id, select, load_index, force, unread):
|
def scrollToMessageId(self, msg_id, from_id, select, load_index, force, unread):
|
||||||
self.scrolled_to.append(msg_id)
|
self.scrolled_to.append(msg_id)
|
||||||
|
for mid in self.loadable.pop(msg_id, ()):
|
||||||
|
owner_obj = _TLObj("TL_message")
|
||||||
|
owner_obj.id = mid
|
||||||
|
self.messages.add(_FakeMessageObject(0, owner_obj))
|
||||||
|
|
||||||
|
def ids(self):
|
||||||
|
return [m.messageOwner.id for m in self.messages]
|
||||||
|
|
||||||
activity = _FakeChatActivity([600, 555, 500])
|
activity = _FakeChatActivity([600, 555, 500])
|
||||||
check(
|
check(
|
||||||
@@ -1582,28 +1937,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 +1995,7 @@ def main(argv=None) -> int:
|
|||||||
|
|
||||||
virtual_id = injector.virtual_id_for(9, 555)
|
virtual_id = injector.virtual_id_for(9, 555)
|
||||||
inj = injector.SummaryInjection(activity, 0, -4242, 0, 555, virtual_id)
|
inj = injector.SummaryInjection(activity, 0, -4242, 0, 555, virtual_id)
|
||||||
inserted = inj.insert(status="Fetching messages…", streaming=True)
|
inserted = inj.insert(thinking="first chunk", streaming=True)
|
||||||
check(
|
check(
|
||||||
"injection inserts one message at the anchor index and refreshes the adapter",
|
"injection inserts one message at the anchor index and refreshes the adapter",
|
||||||
inserted
|
inserted
|
||||||
@@ -1644,12 +2006,18 @@ def main(argv=None) -> int:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# The analyzed range can end far up the history, so the card may be
|
# The analyzed range can end far up the history, so the card may be
|
||||||
# injected off-screen; the chat has to move to it. The anchor is the
|
# injected off-screen; the chat has to move to it. scrollToMessageId
|
||||||
# scroll target because it is a real message the app can resolve, and
|
# only smooth-scrolls from stale cell positions right after an insert,
|
||||||
# the card sits directly above it.
|
# so placement hard-jumps the layout manager to the card's own row.
|
||||||
check(
|
check(
|
||||||
"inserting the card scrolls the chat to the analyzed range",
|
"placing the card hard-jumps the layout manager to the card's row",
|
||||||
activity.scrolled_to == [555],
|
activity.chatLayoutManager.jumps == [(2 + 1, 300, False)]
|
||||||
|
and activity.chatListView.stopped == 1,
|
||||||
|
f"jumps={activity.chatLayoutManager.jumps} stopped={activity.chatListView.stopped}",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"a loaded anchor needs no history load before placement",
|
||||||
|
activity.scrolled_to == [],
|
||||||
f"scrolled_to={activity.scrolled_to}",
|
f"scrolled_to={activity.scrolled_to}",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1669,8 +2037,8 @@ def main(argv=None) -> int:
|
|||||||
)
|
)
|
||||||
check(
|
check(
|
||||||
"a streaming update does not scroll again and fight the user",
|
"a streaming update does not scroll again and fight the user",
|
||||||
activity.scrolled_to == [555],
|
activity.scrolled_to == [] and len(activity.chatLayoutManager.jumps) == 1,
|
||||||
f"scrolled_to={activity.scrolled_to}",
|
f"scrolled_to={activity.scrolled_to} jumps={activity.chatLayoutManager.jumps}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Streaming repaints should hit the one visible cell, not rebind every
|
# Streaming repaints should hit the one visible cell, not rebind every
|
||||||
@@ -1699,46 +2067,6 @@ def main(argv=None) -> int:
|
|||||||
f"notified={activity.chatAdapter.notified} (was {notified_before})",
|
f"notified={activity.chatAdapter.notified} (was {notified_before})",
|
||||||
)
|
)
|
||||||
|
|
||||||
# The card is injected before the fetch, so its anchor starts
|
|
||||||
# provisional and must be corrected once the real range is known.
|
|
||||||
reanchor_activity = _FakeChatActivity([600, 555, 500])
|
|
||||||
provisional_id = injector.virtual_id_for(0, 600)
|
|
||||||
reinj = injector.SummaryInjection(reanchor_activity, 0, -4242, 0, 600, provisional_id)
|
|
||||||
reinj.insert(status="Fetching messages…", streaming=True)
|
|
||||||
check(
|
|
||||||
"provisional card lands at the newest message before the fetch",
|
|
||||||
reanchor_activity.messages.get(0).messageOwner.id == provisional_id,
|
|
||||||
f"ids={[m.messageOwner.id for m in reanchor_activity.messages]}",
|
|
||||||
)
|
|
||||||
|
|
||||||
final_id = injector.virtual_id_for(11, 555)
|
|
||||||
reinj.reanchor(555, final_id)
|
|
||||||
ids = [m.messageOwner.id for m in reanchor_activity.messages]
|
|
||||||
check(
|
|
||||||
"reanchor moves the card to the real anchor and adopts its virtual id",
|
|
||||||
reanchor_activity.messages.size() == 4
|
|
||||||
and ids.index(final_id) == 1
|
|
||||||
and reanchor_activity.messages.get(1).messageOwner.reply_to.reply_to_msg_id == 555,
|
|
||||||
f"ids={ids}",
|
|
||||||
)
|
|
||||||
check(
|
|
||||||
"reanchor follows the card to the real end of the analyzed range",
|
|
||||||
reanchor_activity.scrolled_to == [600, 555],
|
|
||||||
f"scrolled_to={reanchor_activity.scrolled_to}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Regression: a streaming update after a reanchor used to add a second
|
|
||||||
# card, because the lookup missed the old id and fell through to add().
|
|
||||||
reinj.update(status="Summarizing 98 messages…", streaming=True)
|
|
||||||
ids = [m.messageOwner.id for m in reanchor_activity.messages]
|
|
||||||
check(
|
|
||||||
"updating after a reanchor leaves exactly one summary card",
|
|
||||||
reanchor_activity.messages.size() == 4
|
|
||||||
and ids.count(final_id) == 1
|
|
||||||
and provisional_id not in ids,
|
|
||||||
f"ids={ids}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Re-summarizing a range whose saved card the restore hook already
|
# Re-summarizing a range whose saved card the restore hook already
|
||||||
# re-injected must claim that card, not sit beside it.
|
# re-injected must claim that card, not sit beside it.
|
||||||
collide_activity = _FakeChatActivity([600, 555, 500])
|
collide_activity = _FakeChatActivity([600, 555, 500])
|
||||||
@@ -1750,18 +2078,164 @@ def main(argv=None) -> int:
|
|||||||
0,
|
0,
|
||||||
[summary_db_mod.StoredSummary(12, -4242, 0, 555, "previous run", "", 5, 0)],
|
[summary_db_mod.StoredSummary(12, -4242, 0, 555, "previous run", "", 5, 0)],
|
||||||
)
|
)
|
||||||
collide_provisional = injector.virtual_id_for(0, 600)
|
collide = injector.SummaryInjection(collide_activity, 0, -4242, 0, 555, saved_id)
|
||||||
collide = injector.SummaryInjection(collide_activity, 0, -4242, 0, 600, collide_provisional)
|
collide.insert(thinking="new run", streaming=True)
|
||||||
collide.insert(status="Fetching messages…", streaming=True)
|
collide.update(answer_markdown="new answer", streaming=True)
|
||||||
collide.reanchor(555, saved_id)
|
ids = collide_activity.ids()
|
||||||
collide.update(status="Summarizing…", streaming=True)
|
|
||||||
ids = [m.messageOwner.id for m in collide_activity.messages]
|
|
||||||
check(
|
check(
|
||||||
"re-summarizing a restored range reuses its card instead of duplicating",
|
"re-summarizing a restored range reuses its card instead of duplicating",
|
||||||
ids.count(saved_id) == 1 and collide_provisional not in ids,
|
ids.count(saved_id) == 1 and len(ids) == 4,
|
||||||
f"ids={ids}",
|
f"ids={ids}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# The anchor is the newest ANALYZED message; with an offset it can be
|
||||||
|
# outside the loaded window. The card must never fall back to the chat
|
||||||
|
# bottom: the chat is asked to load the anchor, and the card is placed
|
||||||
|
# directly after it once it arrives.
|
||||||
|
far = _FakeChatActivity([900, 800])
|
||||||
|
far.loadable[300] = [310, 300, 290]
|
||||||
|
far_card = injector.virtual_id_for(5, 300)
|
||||||
|
far_inj = injector.SummaryInjection(far, 0, -4242, 0, 300, far_card)
|
||||||
|
far_inj.insert(thinking="chunk", streaming=True)
|
||||||
|
far_ids = far.ids()
|
||||||
|
check(
|
||||||
|
"an unloaded anchor is loaded first, then the card lands right after it",
|
||||||
|
far.scrolled_to == [300]
|
||||||
|
and far_inj.placed
|
||||||
|
and far_ids.index(far_card) == far_ids.index(300) - 1,
|
||||||
|
f"scrolled_to={far.scrolled_to} ids={far_ids}",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"the card is never parked at the bottom while its anchor loads",
|
||||||
|
far_ids[0] == 900,
|
||||||
|
f"ids={far_ids}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Loading a far anchor makes ChatActivity run its own postponed scroll,
|
||||||
|
# which can land after the jump and carry the view away from the card.
|
||||||
|
# The settle checks must bring it back.
|
||||||
|
hijacked = _FakeChatActivity([900, 800])
|
||||||
|
hijacked.loadable[300] = [310, 300, 290]
|
||||||
|
hijacked.chatLayoutManager.hijack = 1
|
||||||
|
hijacked_card = injector.virtual_id_for(7, 300)
|
||||||
|
injector.SummaryInjection(hijacked, 0, -4242, 0, 300, hijacked_card).insert(
|
||||||
|
thinking="chunk", streaming=True
|
||||||
|
)
|
||||||
|
card_row = 2 + hijacked.ids().index(hijacked_card)
|
||||||
|
first, last = hijacked.chatLayoutManager.visible
|
||||||
|
check(
|
||||||
|
"a jump overridden by the app's own scroll is redone until the card is on screen",
|
||||||
|
len(hijacked.chatLayoutManager.jumps) == 2 and first <= card_row <= last,
|
||||||
|
f"jumps={hijacked.chatLayoutManager.jumps} visible={(first, last)} row={card_row}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Once the user takes hold of the list, the plugin stops steering it.
|
||||||
|
dragged = _FakeChatActivity([600, 555, 500])
|
||||||
|
dragged.chatLayoutManager.hijack = 1
|
||||||
|
dragged.chatListView.scroll_state = 1
|
||||||
|
injector.SummaryInjection(dragged, 0, -4242, 0, 555, injector.virtual_id_for(8, 555)).insert(
|
||||||
|
thinking="chunk", streaming=True
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"no re-jump while the user is dragging the list",
|
||||||
|
len(dragged.chatLayoutManager.jumps) == 1,
|
||||||
|
f"jumps={dragged.chatLayoutManager.jumps}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# A cache-then-server reload can replace the list under a fresh card.
|
||||||
|
reloaded = _FakeChatActivity([600, 555, 500])
|
||||||
|
reloaded_card = injector.virtual_id_for(9, 555)
|
||||||
|
reloaded_inj = injector.SummaryInjection(reloaded, 0, -4242, 0, 555, reloaded_card)
|
||||||
|
real_on_ui_for_reload = injector._on_ui
|
||||||
|
dropped = []
|
||||||
|
|
||||||
|
def _reloading_on_ui(fn, delay_ms=0):
|
||||||
|
if delay_ms and not dropped:
|
||||||
|
# First settle check: the chat reloads its window first.
|
||||||
|
reloaded.messages[:] = [m for m in reloaded.messages if m.messageOwner.id > 0]
|
||||||
|
dropped.append(True)
|
||||||
|
fn()
|
||||||
|
|
||||||
|
injector._on_ui = _reloading_on_ui
|
||||||
|
try:
|
||||||
|
reloaded_inj.insert(thinking="chunk", streaming=True)
|
||||||
|
finally:
|
||||||
|
injector._on_ui = real_on_ui_for_reload
|
||||||
|
reloaded_ids = reloaded.ids()
|
||||||
|
check(
|
||||||
|
"a card dropped by a list reload right after placement is put back",
|
||||||
|
dropped and reloaded_ids.count(reloaded_card) == 1
|
||||||
|
and reloaded_ids.index(reloaded_card) == reloaded_ids.index(555) - 1,
|
||||||
|
f"ids={reloaded_ids}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# An anchor that never loads: no card anywhere, rather than a card
|
||||||
|
# attached to messages it did not analyze.
|
||||||
|
never = _FakeChatActivity([900, 800])
|
||||||
|
never_card = injector.virtual_id_for(6, 300)
|
||||||
|
never_inj = injector.SummaryInjection(never, 0, -4242, 0, 300, never_card)
|
||||||
|
never_inj.insert(thinking="chunk", streaming=True)
|
||||||
|
never_inj.update(answer_markdown="more", streaming=True)
|
||||||
|
check(
|
||||||
|
"an anchor that never loads places no card at a wrong position",
|
||||||
|
never_card not in never.ids() and not never_inj.placed,
|
||||||
|
f"ids={never.ids()}",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"a far anchor that does not arrive is requested again, not just once",
|
||||||
|
len(never.scrolled_to) >= 2 and set(never.scrolled_to) == {300},
|
||||||
|
f"scrolled_to={never.scrolled_to}",
|
||||||
|
)
|
||||||
|
# ...and the card shows up once the user scrolls the anchor in.
|
||||||
|
for mid in (310, 300):
|
||||||
|
owner_obj = _TLObj("TL_message")
|
||||||
|
owner_obj.id = mid
|
||||||
|
never.messages.add(_FakeMessageObject(0, owner_obj))
|
||||||
|
never_inj.update(answer_markdown="final", streaming=False, tagged=True)
|
||||||
|
never_ids = never.ids()
|
||||||
|
check(
|
||||||
|
"a card whose anchor loads late is placed on the next render",
|
||||||
|
never_inj.placed and never_ids.index(never_card) == never_ids.index(300) - 1,
|
||||||
|
f"ids={never_ids}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Finished summaries carry the #aisummaries tag, in the card and in
|
||||||
|
# the plain-text body (with a hashtag entity), so copied text and any
|
||||||
|
# text matching see it. Streaming renders stay untagged.
|
||||||
|
tagged_mo = injector.build_summary_message(
|
||||||
|
0, -4242, 0, 555, injector.virtual_id_for(1, 555),
|
||||||
|
"**Done** see [Alice](tg://user?id=1)", "", False, tagged=True,
|
||||||
|
)
|
||||||
|
tagged_owner = tagged_mo.messageOwner
|
||||||
|
last_block = tagged_owner.rich_message.blocks[-1]
|
||||||
|
entity = tagged_owner.entities[0] if len(tagged_owner.entities) else None
|
||||||
|
tag_node = getattr(last_block, "text", None)
|
||||||
|
check(
|
||||||
|
"a finished summary ends with a native #aisummaries hashtag node",
|
||||||
|
last_block.tl_name == "pageBlockParagraph"
|
||||||
|
and getattr(tag_node, "tl_name", None) == "textHashtag"
|
||||||
|
and getattr(tag_node.text, "text", None) == "#aisummaries",
|
||||||
|
f"last={last_block.tl_name} node={getattr(tag_node, '__dict__', None)}",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"the plain-text body is searchable text plus a hashtag entity",
|
||||||
|
tagged_owner.message == "Done see Alice\n\n#aisummaries"
|
||||||
|
and entity is not None
|
||||||
|
and entity.tl_name == "TL_messageEntityHashtag"
|
||||||
|
and entity.offset == len("Done see Alice\n\n")
|
||||||
|
and entity.length == len("#aisummaries"),
|
||||||
|
f"message={tagged_owner.message!r} entity={getattr(entity, '__dict__', None)}",
|
||||||
|
)
|
||||||
|
streaming_mo = injector.build_summary_message(
|
||||||
|
0, -4242, 0, 555, injector.virtual_id_for(1, 555), "partial", "", True
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"a streaming render is not tagged",
|
||||||
|
"#aisummaries" not in streaming_mo.messageOwner.message
|
||||||
|
and len(streaming_mo.messageOwner.entities) == 0,
|
||||||
|
f"message={streaming_mo.messageOwner.message!r}",
|
||||||
|
)
|
||||||
|
|
||||||
# Re-inserting the same virtual id must replace, never duplicate.
|
# Re-inserting the same virtual id must replace, never duplicate.
|
||||||
inj2 = injector.SummaryInjection(activity, 0, -4242, 0, 555, virtual_id)
|
inj2 = injector.SummaryInjection(activity, 0, -4242, 0, 555, virtual_id)
|
||||||
inj2.insert(answer_markdown="again", thinking="", streaming=False)
|
inj2.insert(answer_markdown="again", thinking="", streaming=False)
|
||||||
@@ -1816,6 +2290,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),
|
||||||
|
|||||||
Reference in new issue
Block a user