generated from exteraGramPlugins/pluginBase
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 }}" \
|
||||||
|
|||||||
@@ -126,10 +126,20 @@ token with the `write:repository` scope (which covers releases). Set it once und
|
|||||||
repo-level secret of the same name takes precedence if one repo ever needs different
|
repo-level secret of the same name takes precedence if one repo ever needs different
|
||||||
credentials.
|
credentials.
|
||||||
|
|
||||||
If you would rather not manage a token, Gitea also injects a per-job `secrets.GITEA_TOKEN`
|
The workflow passes it to `release.py` as `RELEASE_TOKEN`, **not** `GITEA_TOKEN`. The Actions
|
||||||
that can create releases in its own repo — swap the `env:` line in the workflow and drop the
|
runner injects its own `GITEA_TOKEN` into every step, and that value overrides a step-level
|
||||||
org secret. It is scoped to the single job and expires with it, so it is the safer default;
|
`env:` entry of the same name, so the org secret would be silently ignored.
|
||||||
`API_TOKEN` is what you want when publishing needs to reach beyond this repo.
|
|
||||||
|
If the org secret does not reach a repository the publish still works: `release.py` tries
|
||||||
|
`RELEASE_TOKEN` first and then falls back to the runner's `GITEA_TOKEN`, which can publish
|
||||||
|
releases in its own repository, and prints which one it used. An organisation secret is only
|
||||||
|
exposed to a repository that has Actions enabled, so check **Repo → Settings → Actions** if a
|
||||||
|
repo unexpectedly falls back.
|
||||||
|
|
||||||
|
If you would rather not manage a token at all, that injected `GITEA_TOKEN` can create
|
||||||
|
releases in its own repo: pass `--token-env GITEA_TOKEN` and drop the org secret. It is
|
||||||
|
scoped to the single job and expires with it, so it is the safer default; `API_TOKEN` is what
|
||||||
|
you want when publishing needs to reach beyond this repo.
|
||||||
|
|
||||||
## Writing your own plugin
|
## Writing your own plugin
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
"name": "Chat Summaries",
|
"name": "Chat Summaries",
|
||||||
"description": "Summarize the last N messages (with offset) in any chat, group, channel, or topic using AI.",
|
"description": "Summarize the last N messages (with offset) in any chat, group, channel, or topic using AI.",
|
||||||
"author": "@lyuksovannyy",
|
"author": "@lyuksovannyy",
|
||||||
"version": "1.0.0",
|
"version": "1.0.3",
|
||||||
"icon": "exteraPlugins/1",
|
"icon": "exteraPlugins/1",
|
||||||
"app_version": ">=12.5.1",
|
"app_version": ">=12.5.1",
|
||||||
"sdk_version": ">=1.4.4.3",
|
"sdk_version": ">=1.4.4.3",
|
||||||
|
|||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Companion to scripts/pre-commit.
|
||||||
|
#
|
||||||
|
# `git commit -- <pathspec>` (also how some IDEs commit a file selection) builds
|
||||||
|
# the commit from a temporary index. The pre-commit bump therefore lands in the
|
||||||
|
# commit, but the real index keeps the pre-bump blob and plugin.json is left
|
||||||
|
# looking modified. The real index cannot be updated during the commit because
|
||||||
|
# git holds index.lock, so refresh it here instead.
|
||||||
|
#
|
||||||
|
# Must never fail a commit that already succeeded.
|
||||||
|
git add -- plugin.json 2>/dev/null || true
|
||||||
|
exit 0
|
||||||
Executable
+262
@@ -0,0 +1,262 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Increment the patch component of plugin.json's version as part of the commit.
|
||||||
|
|
||||||
|
Runs as a pre-commit hook, which is the only commit hook whose `git add` is
|
||||||
|
picked up by the commit currently being created:
|
||||||
|
|
||||||
|
* pre-commit -> staged changes land in THIS commit (correct)
|
||||||
|
* prepare-commit-msg -> staged changes land in the NEXT commit
|
||||||
|
* commit-msg -> staged changes are ignored entirely
|
||||||
|
|
||||||
|
The commit message is read from the invoking `git` process' argv (-m/-F/-C and
|
||||||
|
long forms). `.git/COMMIT_EDITMSG` is deliberately NOT consulted: at pre-commit
|
||||||
|
time it still holds the PREVIOUS commit's message, so a stop word there would
|
||||||
|
silently suppress every later bump.
|
||||||
|
|
||||||
|
Bump is skipped when:
|
||||||
|
* the commit message carries a stop word (see STOP_PATTERNS),
|
||||||
|
* NO_BUMP / GIT_NO_BUMP is set in the environment,
|
||||||
|
* plugin.json's version already differs from HEAD (manual bump),
|
||||||
|
* a merge or rebase is in progress,
|
||||||
|
* plugin.json is absent or its version is not x.y.z.
|
||||||
|
|
||||||
|
For editor-driven commits (`git commit` with no -m) the message does not exist
|
||||||
|
yet when this hook runs, so use `NO_BUMP=1 git commit` to skip the bump there.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PLUGIN_FILE = "plugin.json"
|
||||||
|
|
||||||
|
STOP_PATTERNS = (
|
||||||
|
r"\[\s*no[-_ ]?bump\s*\]",
|
||||||
|
r"\[\s*skip[-_ ]?bump\s*\]",
|
||||||
|
r"\[\s*no[-_ ]?version[-_ ]?bump\s*\]",
|
||||||
|
r"\[\s*skip[-_ ]?version\s*\]",
|
||||||
|
)
|
||||||
|
STOP_RE = re.compile("|".join(STOP_PATTERNS), re.IGNORECASE)
|
||||||
|
|
||||||
|
VERSION_RE = re.compile(r"\A(\d+)\.(\d+)\.(\d+)(.*)\Z", re.DOTALL)
|
||||||
|
|
||||||
|
# options whose value IS the message text
|
||||||
|
TEXT_OPTS = ("-m", "--message")
|
||||||
|
TEXT_OPTS_EQ = ("--message=",)
|
||||||
|
# options whose value NAMES a file containing the message
|
||||||
|
FILE_OPTS = ("-F", "--file")
|
||||||
|
FILE_OPTS_EQ = ("--file=",)
|
||||||
|
# options whose value names a commit to take the message from
|
||||||
|
COMMIT_OPTS = ("-C", "--reuse-message", "-c", "--reedit-message")
|
||||||
|
COMMIT_OPTS_EQ = ("--reuse-message=", "--reedit-message=")
|
||||||
|
|
||||||
|
|
||||||
|
def repo_root() -> Path:
|
||||||
|
out = subprocess.run(
|
||||||
|
["git", "rev-parse", "--show-toplevel"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if out.returncode != 0:
|
||||||
|
raise RuntimeError(out.stderr.strip() or "not inside a git work tree")
|
||||||
|
return Path(out.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def git_dir() -> Path:
|
||||||
|
out = subprocess.run(
|
||||||
|
["git", "rev-parse", "--absolute-git-dir"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if out.returncode != 0:
|
||||||
|
raise RuntimeError(out.stderr.strip() or "cannot resolve git dir")
|
||||||
|
return Path(out.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def bump_patch(version: str) -> str:
|
||||||
|
m = VERSION_RE.match(version.strip())
|
||||||
|
if not m:
|
||||||
|
raise ValueError(f"version {version!r} is not x.y.z")
|
||||||
|
major, minor, patch, suffix = m.groups()
|
||||||
|
return f"{major}.{minor}.{int(patch) + 1}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def head_version(root: Path) -> str | None:
|
||||||
|
"""Version recorded for plugin.json in HEAD, or None when unavailable."""
|
||||||
|
if subprocess.run(
|
||||||
|
["git", "rev-parse", "--verify", "--quiet", "HEAD"],
|
||||||
|
cwd=root,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
).returncode != 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
show = subprocess.run(
|
||||||
|
["git", "show", f"HEAD:{PLUGIN_FILE}"],
|
||||||
|
cwd=root,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if show.returncode != 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return str(json.loads(show.stdout)["version"])
|
||||||
|
except (json.JSONDecodeError, KeyError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def commit_message_text(root: Path) -> str:
|
||||||
|
"""Commit message text of the git process that spawned this hook.
|
||||||
|
|
||||||
|
Covers `-m/--message` (inline), `-F/--file` (read the file) and
|
||||||
|
`-C/-c/--reuse-message/--reedit-message` (read that commit's message).
|
||||||
|
Returns "" for editor-driven commits, where no message exists yet.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
argv = Path(f"/proc/{os.getppid()}/cmdline").read_bytes().decode("utf-8", "replace").split("\0")
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
texts: list[str] = []
|
||||||
|
pending: str | None = None # "text" | "file" | "commit"
|
||||||
|
|
||||||
|
def take(kind: str, value: str) -> None:
|
||||||
|
if kind == "text":
|
||||||
|
texts.append(value)
|
||||||
|
elif kind == "file":
|
||||||
|
try:
|
||||||
|
texts.append(Path(value).read_text(encoding="utf-8", errors="replace"))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
out = subprocess.run(
|
||||||
|
["git", "log", "-1", "--format=%B", value],
|
||||||
|
cwd=root,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if out.returncode == 0:
|
||||||
|
texts.append(out.stdout)
|
||||||
|
|
||||||
|
for arg in argv:
|
||||||
|
if not arg:
|
||||||
|
continue
|
||||||
|
if pending:
|
||||||
|
take(pending, arg)
|
||||||
|
pending = None
|
||||||
|
continue
|
||||||
|
if arg in TEXT_OPTS:
|
||||||
|
pending = "text"
|
||||||
|
elif arg in FILE_OPTS:
|
||||||
|
pending = "file"
|
||||||
|
elif arg in COMMIT_OPTS:
|
||||||
|
pending = "commit"
|
||||||
|
elif arg.startswith(TEXT_OPTS_EQ):
|
||||||
|
take("text", arg.split("=", 1)[1])
|
||||||
|
elif arg.startswith(FILE_OPTS_EQ):
|
||||||
|
take("file", arg.split("=", 1)[1])
|
||||||
|
elif arg.startswith(COMMIT_OPTS_EQ):
|
||||||
|
take("commit", arg.split("=", 1)[1])
|
||||||
|
elif len(arg) > 1 and arg[0] == "-" and arg[1] != "-":
|
||||||
|
# clustered short flags, e.g. -am "text" or -am"text"
|
||||||
|
for kind, letter in (("text", "m"), ("file", "F"), ("commit", "C")):
|
||||||
|
idx = arg.find(letter, 1)
|
||||||
|
if idx == -1:
|
||||||
|
continue
|
||||||
|
if idx + 1 < len(arg):
|
||||||
|
take(kind, arg[idx + 1:])
|
||||||
|
else:
|
||||||
|
pending = kind
|
||||||
|
break
|
||||||
|
|
||||||
|
return "\n".join(texts)
|
||||||
|
|
||||||
|
|
||||||
|
def strip_comments(text: str) -> str:
|
||||||
|
return " ".join(
|
||||||
|
line for line in text.splitlines() if not line.lstrip().startswith("#")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def stop_word_reason(root: Path) -> str | None:
|
||||||
|
if os.environ.get("NO_BUMP") or os.environ.get("GIT_NO_BUMP"):
|
||||||
|
return "NO_BUMP is set"
|
||||||
|
match = STOP_RE.search(strip_comments(commit_message_text(root)))
|
||||||
|
if match:
|
||||||
|
return f"commit message contains {match.group(0)!r}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def merge_or_rebase_in_progress(gitdir: Path) -> bool:
|
||||||
|
return any(
|
||||||
|
(gitdir / name).exists()
|
||||||
|
for name in ("MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "rebase-merge", "rebase-apply")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
try:
|
||||||
|
root = repo_root()
|
||||||
|
gitdir = git_dir()
|
||||||
|
except RuntimeError as exc:
|
||||||
|
print(f"pre-commit: {exc}", file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if merge_or_rebase_in_progress(gitdir):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
reason = stop_word_reason(root)
|
||||||
|
if reason:
|
||||||
|
print(f"pre-commit: {reason}, keeping version unchanged")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
path = root / PLUGIN_FILE
|
||||||
|
if not path.exists():
|
||||||
|
return 0
|
||||||
|
|
||||||
|
raw = path.read_text(encoding="utf-8")
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
print(f"pre-commit: {PLUGIN_FILE} is not valid JSON: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
current = str(data.get("version", ""))
|
||||||
|
previous = head_version(root)
|
||||||
|
if previous is not None and current != previous:
|
||||||
|
print(
|
||||||
|
f"pre-commit: version already changed ({previous} -> {current}), keeping it"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_version = bump_patch(current)
|
||||||
|
except ValueError as exc:
|
||||||
|
print(f"pre-commit: {exc}, keeping version unchanged", file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
data["version"] = new_version
|
||||||
|
trailing = "\n" if raw.endswith("\n") else ""
|
||||||
|
path.write_text(json.dumps(data, indent=2) + trailing, encoding="utf-8")
|
||||||
|
|
||||||
|
# Stage into the index git is actually building for this commit. For
|
||||||
|
# `git commit -- <pathspec>` that is a temporary index named by
|
||||||
|
# GIT_INDEX_FILE, so honouring the inherited environment is what makes the
|
||||||
|
# bump part of THIS commit.
|
||||||
|
add = subprocess.run(["git", "add", "--", PLUGIN_FILE], cwd=root)
|
||||||
|
if add.returncode != 0:
|
||||||
|
print(f"pre-commit: failed to stage {PLUGIN_FILE}", file=sys.stderr)
|
||||||
|
return add.returncode
|
||||||
|
|
||||||
|
# The real index may still hold the pre-bump blob after a pathspec commit;
|
||||||
|
# scripts/post-commit refreshes it once git releases index.lock.
|
||||||
|
|
||||||
|
print(f"pre-commit: version {current} -> {new_version}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+81
-20
@@ -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:
|
||||||
|
|||||||
+17
@@ -298,6 +298,13 @@ class ChatSummariesPlugin(BasePlugin):
|
|||||||
`skip` mirrors the summarize offset, so with an offset the guess lands
|
`skip` mirrors the summarize offset, so with an offset the guess lands
|
||||||
where the analyzed range actually ends instead of at the bottom of the
|
where the analyzed range actually ends instead of at the bottom of the
|
||||||
chat. Injected cards carry negative ids and are ignored.
|
chat. Injected cards carry negative ids and are ignored.
|
||||||
|
|
||||||
|
When the offset reaches past the loaded window there is no message to
|
||||||
|
skip to, so fall back to the oldest loaded message rather than 0: the
|
||||||
|
card only needs somewhere valid to appear while "Fetching messages…" is
|
||||||
|
shown, and reanchor() moves it to the exact spot once the range is
|
||||||
|
known. Returning 0 would make the insert fail and the user would click
|
||||||
|
Summarize and see nothing happen.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from services.rich_injector import get_messages_list
|
from services.rich_injector import get_messages_list
|
||||||
@@ -306,15 +313,18 @@ class ChatSummariesPlugin(BasePlugin):
|
|||||||
if messages_list is None:
|
if messages_list is None:
|
||||||
return 0
|
return 0
|
||||||
remaining = max(0, int(skip))
|
remaining = max(0, int(skip))
|
||||||
|
oldest = 0
|
||||||
for i in range(messages_list.size()):
|
for i in range(messages_list.size()):
|
||||||
owner = getattr(messages_list.get(i), "messageOwner", None)
|
owner = getattr(messages_list.get(i), "messageOwner", None)
|
||||||
msg_id = int(getattr(owner, "id", 0) or 0) if owner is not None else 0
|
msg_id = int(getattr(owner, "id", 0) or 0) if owner is not None else 0
|
||||||
if msg_id <= 0:
|
if msg_id <= 0:
|
||||||
continue
|
continue
|
||||||
|
oldest = msg_id
|
||||||
if remaining:
|
if remaining:
|
||||||
remaining -= 1
|
remaining -= 1
|
||||||
continue
|
continue
|
||||||
return msg_id
|
return msg_id
|
||||||
|
return oldest
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
diagnostics.record_error("_newest_visible_message_id", exc)
|
diagnostics.record_error("_newest_visible_message_id", exc)
|
||||||
return 0
|
return 0
|
||||||
@@ -412,6 +422,13 @@ class ChatSummariesPlugin(BasePlugin):
|
|||||||
rendered("thinking", final=True),
|
rendered("thinking", final=True),
|
||||||
len(messages),
|
len(messages),
|
||||||
)
|
)
|
||||||
|
# The restore gate skips scopes it has already handled, so
|
||||||
|
# the newly saved row has to invalidate that cache or it
|
||||||
|
# would not be restored until the chat was reopened.
|
||||||
|
try:
|
||||||
|
self.restore_hook.invalidate()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
diagnostics.record_error("_stream_into:db.save", exc)
|
diagnostics.record_error("_stream_into:db.save", exc)
|
||||||
|
|
||||||
|
|||||||
+116
-22
@@ -181,25 +181,34 @@ def get_chat_adapter(chat_activity: Any) -> Optional[Any]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def find_index_for_anchor(messages_list: Any, anchor_msg_id: int) -> int:
|
def find_index_for_anchor(messages_list: Any, anchor_msg_id: int, require_anchor: bool = True) -> int:
|
||||||
"""Index at which to insert so the summary sits directly after the anchor.
|
"""Index at which to insert so the summary sits directly after the anchor.
|
||||||
|
|
||||||
ChatActivity.messages is newest-first, so "directly after the anchor"
|
ChatActivity.messages is newest-first, so "directly after the anchor"
|
||||||
chronologically means "immediately before it" in list order.
|
chronologically means "immediately before it" in list order.
|
||||||
|
|
||||||
The anchor is the newest message that was actually analyzed, which is not
|
Returns -1 when the anchor message is not loaded in this view and
|
||||||
the newest message in the chat whenever a non-zero offset was used. If the
|
`require_anchor` is set. Positioning by id order alone is not safe: with a
|
||||||
anchor itself is not loaded (paged out, or never fetched into this view),
|
non-zero offset, or a range that has since paged out, the nearest-by-id slot
|
||||||
fall back to ordering by id rather than to index 0: index 0 is the bottom
|
can sit among messages the summary never analyzed, which is how cards ended
|
||||||
of the chat and would place the card after messages the summary never saw.
|
up attached to the wrong message. Callers skip the insert instead and try
|
||||||
|
again once the anchor is paged in.
|
||||||
"""
|
"""
|
||||||
anchor_msg_id = int(anchor_msg_id)
|
anchor_msg_id = int(anchor_msg_id)
|
||||||
|
# Anchors are real, positive message ids. A non-positive anchor must never
|
||||||
|
# match: date separators are local MessageObjects whose messageOwner.id is
|
||||||
|
# 0, so scanning for 0 would "find" the first separator and place the card
|
||||||
|
# against a divider instead of a message.
|
||||||
|
if anchor_msg_id <= 0:
|
||||||
|
return -1
|
||||||
try:
|
try:
|
||||||
size = messages_list.size()
|
size = messages_list.size()
|
||||||
for i in range(size):
|
for i in range(size):
|
||||||
owner = getattr(messages_list.get(i), "messageOwner", None)
|
owner = getattr(messages_list.get(i), "messageOwner", None)
|
||||||
if owner is not None and int(getattr(owner, "id", 0) or 0) == anchor_msg_id:
|
if owner is not None and int(getattr(owner, "id", 0) or 0) == anchor_msg_id:
|
||||||
return i
|
return i
|
||||||
|
if require_anchor:
|
||||||
|
return -1
|
||||||
|
|
||||||
# Anchor absent: insert before the first real message whose id is at
|
# Anchor absent: insert before the first real message whose id is at
|
||||||
# most the anchor's, i.e. the newest message not newer than the
|
# most the anchor's, i.e. the newest message not newer than the
|
||||||
@@ -215,7 +224,7 @@ def find_index_for_anchor(messages_list: Any, anchor_msg_id: int) -> int:
|
|||||||
return size
|
return size
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
diagnostics.record_error("injector.find_index_for_anchor", exc)
|
diagnostics.record_error("injector.find_index_for_anchor", exc)
|
||||||
return 0
|
return -1
|
||||||
|
|
||||||
|
|
||||||
def find_existing(messages_list: Any, virtual_id: int) -> int:
|
def find_existing(messages_list: Any, virtual_id: int) -> int:
|
||||||
@@ -294,15 +303,43 @@ class SummaryInjection:
|
|||||||
return False
|
return False
|
||||||
self.message_object = message_object
|
self.message_object = message_object
|
||||||
|
|
||||||
existing = find_existing(messages_list, self.virtual_id)
|
# The index must be computed on the UI thread, immediately before the
|
||||||
index = existing if existing >= 0 else find_index_for_anchor(messages_list, self.anchor_msg_id)
|
# mutation that uses it. Computing it here would race: loading a large
|
||||||
|
# range takes long enough for paging or an incoming message to shift the
|
||||||
|
# list, and the stale index then lands the card after the wrong message.
|
||||||
def run():
|
def run():
|
||||||
try:
|
try:
|
||||||
|
messages_list_now = get_messages_list(self.chat_activity)
|
||||||
|
if messages_list_now is None:
|
||||||
|
return
|
||||||
|
existing = find_existing(messages_list_now, self.virtual_id)
|
||||||
if existing >= 0:
|
if existing >= 0:
|
||||||
messages_list.set(index, message_object)
|
messages_list_now.set(existing, message_object)
|
||||||
else:
|
else:
|
||||||
messages_list.add(index, message_object)
|
# A live card is a direct response to the user pressing
|
||||||
|
# Summarize, so it must become visible even when the
|
||||||
|
# provisional anchor is not loaded (a large offset reaches
|
||||||
|
# past the loaded window). Fall back to id ordering, which
|
||||||
|
# still keeps every newer message below the card, and let
|
||||||
|
# reanchor() move it to the exact spot once the analyzed
|
||||||
|
# range is known. Restoring saved summaries stays strict --
|
||||||
|
# there is no follow-up correction there.
|
||||||
|
index = find_index_for_anchor(messages_list_now, self.anchor_msg_id)
|
||||||
|
if index < 0:
|
||||||
|
index = find_index_for_anchor(
|
||||||
|
messages_list_now, self.anchor_msg_id, require_anchor=False
|
||||||
|
)
|
||||||
|
if index < 0:
|
||||||
|
# No anchor at all (nothing loaded yet, or the offset
|
||||||
|
# reached past everything). Show the card at the bottom,
|
||||||
|
# where the user is looking after pressing Summarize;
|
||||||
|
# reanchor() relocates it once the range is known.
|
||||||
|
diagnostics.note(
|
||||||
|
f"injector: anchor {self.anchor_msg_id} unresolved, "
|
||||||
|
"showing status card at the bottom"
|
||||||
|
)
|
||||||
|
index = 0
|
||||||
|
messages_list_now.add(index, message_object)
|
||||||
adapter = get_chat_adapter(self.chat_activity)
|
adapter = get_chat_adapter(self.chat_activity)
|
||||||
if adapter is not None:
|
if adapter is not None:
|
||||||
adapter.notifyDataSetChanged()
|
adapter.notifyDataSetChanged()
|
||||||
@@ -391,6 +428,10 @@ class SummaryInjection:
|
|||||||
messages_list.set(index, replacement)
|
messages_list.set(index, replacement)
|
||||||
else:
|
else:
|
||||||
index = find_index_for_anchor(messages_list, self.anchor_msg_id)
|
index = find_index_for_anchor(messages_list, self.anchor_msg_id)
|
||||||
|
if index < 0:
|
||||||
|
# Anchor paged out mid-stream. Keep the streamed
|
||||||
|
# text; it is re-inserted once the anchor is back.
|
||||||
|
return
|
||||||
messages_list.add(index, replacement)
|
messages_list.add(index, replacement)
|
||||||
inserted = True
|
inserted = True
|
||||||
for stale_id in self._used_ids:
|
for stale_id in self._used_ids:
|
||||||
@@ -497,8 +538,16 @@ class SummaryInjection:
|
|||||||
duplicate = find_existing(messages_list, virtual_id)
|
duplicate = find_existing(messages_list, virtual_id)
|
||||||
if duplicate >= 0:
|
if duplicate >= 0:
|
||||||
messages_list.remove(duplicate)
|
messages_list.remove(duplicate)
|
||||||
|
# The card was just removed, so it must go back somewhere:
|
||||||
|
# the new anchor came from the loaded list and is normally
|
||||||
|
# present, but if it paged out in between, fall back to id
|
||||||
|
# ordering rather than dropping the card entirely.
|
||||||
target = find_index_for_anchor(messages_list, anchor_msg_id)
|
target = find_index_for_anchor(messages_list, anchor_msg_id)
|
||||||
messages_list.add(target, message_object)
|
if target < 0:
|
||||||
|
target = find_index_for_anchor(
|
||||||
|
messages_list, anchor_msg_id, require_anchor=False
|
||||||
|
)
|
||||||
|
messages_list.add(max(target, 0), message_object)
|
||||||
adapter = get_chat_adapter(self.chat_activity)
|
adapter = get_chat_adapter(self.chat_activity)
|
||||||
if adapter is not None:
|
if adapter is not None:
|
||||||
adapter.notifyDataSetChanged()
|
adapter.notifyDataSetChanged()
|
||||||
@@ -520,17 +569,48 @@ def _on_ui(fn) -> None:
|
|||||||
fn()
|
fn()
|
||||||
|
|
||||||
|
|
||||||
|
def loaded_message_ids(messages_list: Any) -> set:
|
||||||
|
"""Ids currently loaded in the view, in one pass.
|
||||||
|
|
||||||
|
Restoring used to scan the whole list once per saved summary; a single set
|
||||||
|
keeps it linear in the list length no matter how many summaries exist.
|
||||||
|
"""
|
||||||
|
ids = set()
|
||||||
|
try:
|
||||||
|
for i in range(messages_list.size()):
|
||||||
|
owner = getattr(messages_list.get(i), "messageOwner", None)
|
||||||
|
if owner is None:
|
||||||
|
continue
|
||||||
|
msg_id = int(getattr(owner, "id", 0) or 0)
|
||||||
|
# Skip date separators (id 0). Keeping them would make a summary
|
||||||
|
# whose anchor id is 0 look restorable.
|
||||||
|
if msg_id:
|
||||||
|
ids.add(msg_id)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("injector.loaded_message_ids", exc)
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
def restore_saved_summaries(chat_activity: Any, account: int, dialog_id: int, topic_id: int, summaries) -> int:
|
def restore_saved_summaries(chat_activity: Any, account: int, dialog_id: int, topic_id: int, summaries) -> int:
|
||||||
"""Re-injects persisted summaries into a freshly opened chat. Returns count."""
|
"""Re-injects persisted summaries into a freshly opened chat.
|
||||||
|
|
||||||
|
Only summaries whose anchor is currently loaded are injected: a card whose
|
||||||
|
anchor is outside the loaded window has no correct position, and guessing
|
||||||
|
one by id order is what stacked every saved card together at the bottom of
|
||||||
|
the chat. The rest are picked up on a later call, once paging has brought
|
||||||
|
their anchor in. Returns how many were dispatched for insertion.
|
||||||
|
"""
|
||||||
messages_list = get_messages_list(chat_activity)
|
messages_list = get_messages_list(chat_activity)
|
||||||
if messages_list is None or not summaries:
|
if messages_list is None or not summaries:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
register_mock_sender(account)
|
loaded = loaded_message_ids(messages_list)
|
||||||
pending = []
|
pending = []
|
||||||
for summary in summaries:
|
for summary in summaries:
|
||||||
virtual_id = virtual_id_for(summary.id, summary.anchor_msg_id)
|
virtual_id = virtual_id_for(summary.id, summary.anchor_msg_id)
|
||||||
if find_existing(messages_list, virtual_id) >= 0:
|
if virtual_id in loaded:
|
||||||
|
continue
|
||||||
|
if int(summary.anchor_msg_id) not in loaded:
|
||||||
continue
|
continue
|
||||||
message_object = build_summary_message(
|
message_object = build_summary_message(
|
||||||
account,
|
account,
|
||||||
@@ -543,11 +623,12 @@ def restore_saved_summaries(chat_activity: Any, account: int, dialog_id: int, to
|
|||||||
False,
|
False,
|
||||||
)
|
)
|
||||||
if message_object is not None:
|
if message_object is not None:
|
||||||
pending.append((summary.anchor_msg_id, message_object))
|
pending.append((summary.anchor_msg_id, virtual_id, message_object))
|
||||||
|
|
||||||
if not pending:
|
if not pending:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
register_mock_sender(account)
|
||||||
# Newest anchor first: each insert shifts everything below it, so placing
|
# Newest anchor first: each insert shifts everything below it, so placing
|
||||||
# the newest card first keeps the remaining lookups pointing at unmoved
|
# the newest card first keeps the remaining lookups pointing at unmoved
|
||||||
# rows. The index is recomputed per card against the live list anyway.
|
# rows. The index is recomputed per card against the live list anyway.
|
||||||
@@ -555,11 +636,24 @@ def restore_saved_summaries(chat_activity: Any, account: int, dialog_id: int, to
|
|||||||
|
|
||||||
def run():
|
def run():
|
||||||
try:
|
try:
|
||||||
for anchor_msg_id, message_object in pending:
|
# Re-resolve on the UI thread: the list this was queued against may
|
||||||
messages_list.add(find_index_for_anchor(messages_list, anchor_msg_id), message_object)
|
# have paged or been replaced in between.
|
||||||
adapter = get_chat_adapter(chat_activity)
|
live = get_messages_list(chat_activity)
|
||||||
if adapter is not None:
|
if live is None:
|
||||||
adapter.notifyDataSetChanged()
|
return
|
||||||
|
added = 0
|
||||||
|
for anchor_msg_id, virtual_id, message_object in pending:
|
||||||
|
if find_existing(live, virtual_id) >= 0:
|
||||||
|
continue
|
||||||
|
index = find_index_for_anchor(live, anchor_msg_id)
|
||||||
|
if index < 0:
|
||||||
|
continue
|
||||||
|
live.add(index, message_object)
|
||||||
|
added += 1
|
||||||
|
if added:
|
||||||
|
adapter = get_chat_adapter(chat_activity)
|
||||||
|
if adapter is not None:
|
||||||
|
adapter.notifyDataSetChanged()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
diagnostics.record_error("injector.restore", exc)
|
diagnostics.record_error("injector.restore", exc)
|
||||||
|
|
||||||
|
|||||||
+32
-3
@@ -10,7 +10,9 @@ Stdlib only, so it runs on any runner image without a pip install step.
|
|||||||
Usage:
|
Usage:
|
||||||
tools/release.py --api <repo-api-url> --tag v1.0.0 --artifact dist/x.plugin
|
tools/release.py --api <repo-api-url> --tag v1.0.0 --artifact dist/x.plugin
|
||||||
|
|
||||||
The token is read from GITEA_TOKEN (or GITHUB_TOKEN as a fallback).
|
The token is read from RELEASE_TOKEN by default; override with --token-env.
|
||||||
|
Deliberately not GITEA_TOKEN: the Actions runner injects its own value for
|
||||||
|
that name into every step, which overrides a step-level `env:` entry.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -147,11 +149,38 @@ def main(argv: Optional[list] = None) -> int:
|
|||||||
parser.add_argument("--artifact", required=True, help="file to attach")
|
parser.add_argument("--artifact", required=True, help="file to attach")
|
||||||
parser.add_argument("--commitish", default="", help="target commit for a new tag")
|
parser.add_argument("--commitish", default="", help="target commit for a new tag")
|
||||||
parser.add_argument("--asset-name", default="", help="attachment name; defaults to the file name")
|
parser.add_argument("--asset-name", default="", help="attachment name; defaults to the file name")
|
||||||
|
parser.add_argument(
|
||||||
|
"--token-env",
|
||||||
|
default="RELEASE_TOKEN",
|
||||||
|
help="preferred environment variable holding the API token",
|
||||||
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
token = os.environ.get("GITEA_TOKEN") or os.environ.get("GITHUB_TOKEN") or ""
|
# Preferred name first, then the runner-provided job token. GITEA_TOKEN is
|
||||||
|
# not used as the primary name because the Actions runner injects its own
|
||||||
|
# value for it into every step, which overrides a step-level `env:` entry
|
||||||
|
# of the same name -- but it is a valid last resort, since it can publish
|
||||||
|
# releases in its own repository.
|
||||||
|
candidates = [args.token_env, "RELEASE_TOKEN", "GITEA_TOKEN", "GITHUB_TOKEN"]
|
||||||
|
token, token_source = "", ""
|
||||||
|
for name in candidates:
|
||||||
|
value = os.environ.get(name, "").strip()
|
||||||
|
if value:
|
||||||
|
token, token_source = value, name
|
||||||
|
break
|
||||||
if not token:
|
if not token:
|
||||||
fail("GITEA_TOKEN is not set")
|
present = sorted(n for n in candidates if n in os.environ)
|
||||||
|
fail(
|
||||||
|
f"no API token found; looked at {', '.join(dict.fromkeys(candidates))}. "
|
||||||
|
+ (
|
||||||
|
f"These exist but are empty: {', '.join(present)}. "
|
||||||
|
"An organisation secret is only exposed to a repository when Actions "
|
||||||
|
"is enabled on that repository, so check Repo > Settings > Actions."
|
||||||
|
if present
|
||||||
|
else "None of them are set in the environment."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
print(f"using token from {token_source}")
|
||||||
|
|
||||||
artifact = Path(args.artifact)
|
artifact = Path(args.artifact)
|
||||||
if not artifact.is_file() or artifact.stat().st_size == 0:
|
if not artifact.is_file() or artifact.stat().st_size == 0:
|
||||||
|
|||||||
+225
-8
@@ -794,6 +794,50 @@ def main(argv=None) -> int:
|
|||||||
f"installed hooks: {len(plugin.unread_hook._unhooks)} (expected 3)",
|
f"installed hooks: {len(plugin.unread_hook._unhooks)} (expected 3)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# unread_count decays while a chat is open (markDialogAsRead runs as
|
||||||
|
# messages are viewed), so pressing the unread button a second time used
|
||||||
|
# to prefill a far smaller range than the first press: 1100 then 15 then
|
||||||
|
# 75. The resolved count must not shrink while the chat stays open.
|
||||||
|
class _UnreadDialog:
|
||||||
|
def __init__(self, count):
|
||||||
|
self.unread_count = count
|
||||||
|
|
||||||
|
class _UnreadFragment:
|
||||||
|
def __init__(self, divider, ids):
|
||||||
|
self.createUnreadMessageAfterId = divider
|
||||||
|
self.messages = _JavaList()
|
||||||
|
for mid in ids:
|
||||||
|
owner_obj = _TLObj("TL_message")
|
||||||
|
owner_obj.id = mid
|
||||||
|
self.messages.add(_FakeMessageObject(0, owner_obj))
|
||||||
|
|
||||||
|
unread_dialog = _UnreadDialog(1100)
|
||||||
|
cu_mod = sys.modules["client_utils"]
|
||||||
|
real_controller = cu_mod.get_messages_controller
|
||||||
|
|
||||||
|
class _UnreadController:
|
||||||
|
class dialogs_dict:
|
||||||
|
@staticmethod
|
||||||
|
def get(_dialog_id):
|
||||||
|
return unread_dialog
|
||||||
|
|
||||||
|
cu_mod.get_messages_controller = lambda: _UnreadController
|
||||||
|
try:
|
||||||
|
unread_fragment = _UnreadFragment(171000, list(range(172700, 172670, -1)))
|
||||||
|
resolved = []
|
||||||
|
for decayed in (1100, 15, 75):
|
||||||
|
unread_dialog.unread_count = decayed
|
||||||
|
resolved.append(
|
||||||
|
plugin.unread_hook._resolve_unread_count(unread_fragment, -1978105175)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
cu_mod.get_messages_controller = real_controller
|
||||||
|
check(
|
||||||
|
"the unread prefill does not shrink as messages are marked read",
|
||||||
|
resolved == [1100, 1100, 1100],
|
||||||
|
f"resolved={resolved}",
|
||||||
|
)
|
||||||
|
|
||||||
settings_rows = plugin.create_settings()
|
settings_rows = plugin.create_settings()
|
||||||
settings_keys = {row.key for row in settings_rows if hasattr(row, "key")}
|
settings_keys = {row.key for row in settings_rows if hasattr(row, "key")}
|
||||||
expected_keys = {
|
expected_keys = {
|
||||||
@@ -1582,28 +1626,35 @@ def main(argv=None) -> int:
|
|||||||
# Anchor not loaded in this view: falling back to index 0 would put the
|
# Anchor not loaded in this view: falling back to index 0 would put the
|
||||||
# card below messages the summary never saw, which is the bug this
|
# card below messages the summary never saw, which is the bug this
|
||||||
# ordering fallback exists to prevent.
|
# ordering fallback exists to prevent.
|
||||||
|
# These exercise the id-ordering fallback itself, which is now opt-in:
|
||||||
|
# callers that must place a card somewhere (reanchor) ask for it, while
|
||||||
|
# the default refuses to guess a position for an unloaded anchor.
|
||||||
check(
|
check(
|
||||||
"an unloaded anchor is ordered by id instead of dropping to the bottom",
|
"requested id ordering places an unloaded anchor by id",
|
||||||
injector.find_index_for_anchor(offset_activity.messages, 750) == 2,
|
injector.find_index_for_anchor(offset_activity.messages, 750, require_anchor=False) == 2,
|
||||||
f"index={injector.find_index_for_anchor(offset_activity.messages, 750)}",
|
f"index={injector.find_index_for_anchor(offset_activity.messages, 750, require_anchor=False)}",
|
||||||
)
|
)
|
||||||
check(
|
check(
|
||||||
"an anchor older than everything loaded lands at the top of the list",
|
"an anchor older than everything loaded lands at the top of the list",
|
||||||
injector.find_index_for_anchor(offset_activity.messages, 100)
|
injector.find_index_for_anchor(offset_activity.messages, 100, require_anchor=False)
|
||||||
== offset_activity.messages.size(),
|
== offset_activity.messages.size(),
|
||||||
f"index={injector.find_index_for_anchor(offset_activity.messages, 100)}",
|
f"index={injector.find_index_for_anchor(offset_activity.messages, 100, require_anchor=False)}",
|
||||||
)
|
)
|
||||||
check(
|
check(
|
||||||
"an anchor newer than everything loaded lands at the bottom",
|
"an anchor newer than everything loaded lands at the bottom",
|
||||||
injector.find_index_for_anchor(offset_activity.messages, 1000) == 0,
|
injector.find_index_for_anchor(offset_activity.messages, 1000, require_anchor=False) == 0,
|
||||||
f"index={injector.find_index_for_anchor(offset_activity.messages, 1000)}",
|
f"index={injector.find_index_for_anchor(offset_activity.messages, 1000, require_anchor=False)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# The real invariant is positional, not a fixed index: every message
|
# The real invariant is positional, not a fixed index: every message
|
||||||
# newer than the anchor stays below the card, every older one above,
|
# newer than the anchor stays below the card, every older one above,
|
||||||
# regardless of injected cards already sitting in the list.
|
# regardless of injected cards already sitting in the list.
|
||||||
def neighbours(activity_obj, anchor):
|
def neighbours(activity_obj, anchor):
|
||||||
index = injector.find_index_for_anchor(activity_obj.messages, anchor)
|
# require_anchor=False so the invariant is checked for unloaded
|
||||||
|
# anchors too: when a position is chosen at all, it must be correct.
|
||||||
|
index = injector.find_index_for_anchor(
|
||||||
|
activity_obj.messages, anchor, require_anchor=False
|
||||||
|
)
|
||||||
ids = [m.messageOwner.id for m in activity_obj.messages]
|
ids = [m.messageOwner.id for m in activity_obj.messages]
|
||||||
real = [i for i in ids if i > 0]
|
real = [i for i in ids if i > 0]
|
||||||
below = [i for i in ids[:index] if i > 0]
|
below = [i for i in ids[:index] if i > 0]
|
||||||
@@ -1816,6 +1867,172 @@ def main(argv=None) -> int:
|
|||||||
f"ids={ids}",
|
f"ids={ids}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Bug: every saved summary appeared stacked in a row at the bottom of
|
||||||
|
# the chat. Cards whose anchor is outside the loaded window have no
|
||||||
|
# correct position, and placing them by id order alone collapsed them
|
||||||
|
# together. They must be skipped until paging brings their anchor in.
|
||||||
|
narrow = _FakeChatActivity([600, 590, 580])
|
||||||
|
stranded = injector.restore_saved_summaries(
|
||||||
|
narrow,
|
||||||
|
0,
|
||||||
|
-4242,
|
||||||
|
0,
|
||||||
|
[
|
||||||
|
summary_db_mod.StoredSummary(21, -4242, 0, 120, "ancient", "", 5, 0),
|
||||||
|
summary_db_mod.StoredSummary(22, -4242, 0, 130, "old", "", 5, 0),
|
||||||
|
summary_db_mod.StoredSummary(23, -4242, 0, 590, "loaded", "", 5, 0),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
narrow_ids = [m.messageOwner.id for m in narrow.messages]
|
||||||
|
injected = [i for i in narrow_ids if i < 0]
|
||||||
|
check(
|
||||||
|
"summaries whose anchor is not loaded are not injected at all",
|
||||||
|
stranded == 1 and injected == [injector.virtual_id_for(23, 590)],
|
||||||
|
f"dispatched={stranded} injected={injected} ids={narrow_ids}",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"the one restorable summary still lands directly above its anchor",
|
||||||
|
narrow_ids.index(injector.virtual_id_for(23, 590)) == narrow_ids.index(590) - 1,
|
||||||
|
f"ids={narrow_ids}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Once paging widens the window, the previously skipped summary has a
|
||||||
|
# real anchor and must be restored on the next attempt.
|
||||||
|
for older in (130, 120):
|
||||||
|
owner_obj = _TLObj("TL_message")
|
||||||
|
owner_obj.id = older
|
||||||
|
narrow.messages.add(_FakeMessageObject(0, owner_obj))
|
||||||
|
after_paging = injector.restore_saved_summaries(
|
||||||
|
narrow,
|
||||||
|
0,
|
||||||
|
-4242,
|
||||||
|
0,
|
||||||
|
[
|
||||||
|
summary_db_mod.StoredSummary(21, -4242, 0, 120, "ancient", "", 5, 0),
|
||||||
|
summary_db_mod.StoredSummary(22, -4242, 0, 130, "old", "", 5, 0),
|
||||||
|
summary_db_mod.StoredSummary(23, -4242, 0, 590, "loaded", "", 5, 0),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
paged_ids = [m.messageOwner.id for m in narrow.messages]
|
||||||
|
check(
|
||||||
|
"summaries are restored once paging loads their anchor",
|
||||||
|
after_paging == 2
|
||||||
|
and paged_ids.index(injector.virtual_id_for(21, 120)) == paged_ids.index(120) - 1
|
||||||
|
and paged_ids.index(injector.virtual_id_for(22, 130)) == paged_ids.index(130) - 1,
|
||||||
|
f"dispatched={after_paging} ids={paged_ids}",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"re-restoring does not duplicate the summary already on screen",
|
||||||
|
paged_ids.count(injector.virtual_id_for(23, 590)) == 1,
|
||||||
|
f"ids={paged_ids}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bug: the card appeared after the wrong message when many messages
|
||||||
|
# were included. The insert index was computed on the calling thread and
|
||||||
|
# applied later on the UI thread; anything arriving in between shifted
|
||||||
|
# the list and invalidated it. A fake dispatcher that mutates the list
|
||||||
|
# between computing and applying reproduces that window.
|
||||||
|
racing = _FakeChatActivity([700, 690, 680])
|
||||||
|
real_on_ui = injector._on_ui
|
||||||
|
|
||||||
|
def _racing_on_ui(fn):
|
||||||
|
owner_obj = _TLObj("TL_message")
|
||||||
|
owner_obj.id = 701
|
||||||
|
racing.messages.add(0, _FakeMessageObject(0, owner_obj))
|
||||||
|
fn()
|
||||||
|
|
||||||
|
injector._on_ui = _racing_on_ui
|
||||||
|
try:
|
||||||
|
racing_injection = injector.SummaryInjection(
|
||||||
|
racing, 0, -4242, 0, 690, injector.virtual_id_for(31, 690)
|
||||||
|
)
|
||||||
|
racing_injection.insert("summary body", "", streaming=False)
|
||||||
|
finally:
|
||||||
|
injector._on_ui = real_on_ui
|
||||||
|
racing_ids = [m.messageOwner.id for m in racing.messages]
|
||||||
|
card = injector.virtual_id_for(31, 690)
|
||||||
|
check(
|
||||||
|
"the card lands above its anchor even if the list shifts first",
|
||||||
|
racing_ids.index(card) == racing_ids.index(690) - 1,
|
||||||
|
f"ids={racing_ids}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# find_index_for_anchor must report an absent anchor rather than
|
||||||
|
# guessing index 0 (chat bottom) or a nearest-by-id slot.
|
||||||
|
absent = _FakeChatActivity([900, 800])
|
||||||
|
check(
|
||||||
|
"an unloaded anchor is reported as absent",
|
||||||
|
injector.find_index_for_anchor(absent.messages, 123) == -1,
|
||||||
|
f"index={injector.find_index_for_anchor(absent.messages, 123)}",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"id-ordered placement is still available when explicitly requested",
|
||||||
|
injector.find_index_for_anchor(absent.messages, 123, require_anchor=False) == 2,
|
||||||
|
f"index={injector.find_index_for_anchor(absent.messages, 123, require_anchor=False)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Every real chat interleaves date separators, which are local
|
||||||
|
# MessageObjects carrying messageOwner.id == 0 (ChatActivity builds them
|
||||||
|
# with `iput v12, ...Message->id` where v12 is 0, and gives them ids from
|
||||||
|
# a separate dateObjectsStableIds space). They must never be treated as
|
||||||
|
# the insertion point or counted as a loaded anchor.
|
||||||
|
dated = _FakeChatActivity([700, 690])
|
||||||
|
separator_owner = _TLObj("TL_messageService")
|
||||||
|
separator_owner.id = 0
|
||||||
|
dated.messages.add(1, _FakeMessageObject(0, separator_owner))
|
||||||
|
dated_index = injector.find_index_for_anchor(dated.messages, 690)
|
||||||
|
dated_ids = [m.messageOwner.id for m in dated.messages]
|
||||||
|
check(
|
||||||
|
"a date separator is not mistaken for the anchor",
|
||||||
|
dated_ids[dated_index] == 690,
|
||||||
|
f"index={dated_index} ids={dated_ids}",
|
||||||
|
)
|
||||||
|
# A separator's id 0 must not make "anchor 0" look loaded, and must not
|
||||||
|
# be offered as a position; anchor ids are always positive.
|
||||||
|
check(
|
||||||
|
"a date separator does not satisfy an anchor-loaded check",
|
||||||
|
injector.find_index_for_anchor(dated.messages, 0) == -1,
|
||||||
|
f"index={injector.find_index_for_anchor(dated.messages, 0)}",
|
||||||
|
)
|
||||||
|
# A summary anchored on a real message must still restore correctly when
|
||||||
|
# separators sit between the loaded messages.
|
||||||
|
sep_restore = _FakeChatActivity([800, 790])
|
||||||
|
sep_owner = _TLObj("TL_messageService")
|
||||||
|
sep_owner.id = 0
|
||||||
|
sep_restore.messages.add(1, _FakeMessageObject(0, sep_owner))
|
||||||
|
sep_n = injector.restore_saved_summaries(
|
||||||
|
sep_restore,
|
||||||
|
0,
|
||||||
|
-4242,
|
||||||
|
0,
|
||||||
|
[summary_db_mod.StoredSummary(41, -4242, 0, 790, "text", "", 5, 0)],
|
||||||
|
)
|
||||||
|
sep_ids = [m.messageOwner.id for m in sep_restore.messages]
|
||||||
|
sep_card = injector.virtual_id_for(41, 790)
|
||||||
|
check(
|
||||||
|
"summaries restore correctly around date separators",
|
||||||
|
sep_n == 1 and sep_ids.index(sep_card) == sep_ids.index(790) - 1,
|
||||||
|
f"dispatched={sep_n} ids={sep_ids}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# A live status card is the only feedback that pressing Summarize did
|
||||||
|
# anything, so it must appear even when the provisional anchor cannot be
|
||||||
|
# placed: a large offset reaches past the loaded window, and an
|
||||||
|
# unresolved anchor arrives as 0. Requiring a loaded anchor here left the
|
||||||
|
# user staring at an unchanged chat.
|
||||||
|
for label, anchor in (("unresolved anchor 0", 0), ("anchor past the window", 4242)):
|
||||||
|
live = _FakeChatActivity([700, 690, 680])
|
||||||
|
live_injection = injector.SummaryInjection(
|
||||||
|
live, 0, -4242, 0, anchor, injector.virtual_id_for(0, anchor or 1)
|
||||||
|
)
|
||||||
|
shown = live_injection.insert(status="Fetching messages…", streaming=True)
|
||||||
|
live_ids = [m.messageOwner.id for m in live.messages]
|
||||||
|
check(
|
||||||
|
f"the status card still appears with an {label}",
|
||||||
|
shown and len([i for i in live_ids if i < 0]) == 1,
|
||||||
|
f"inserted={shown} ids={live_ids}",
|
||||||
|
)
|
||||||
|
|
||||||
check(
|
check(
|
||||||
"sys.modules gained no entry for bundled plugin module names",
|
"sys.modules gained no entry for bundled plugin module names",
|
||||||
not ({"main", "config", "services", "providers", "hooks", "ui.summarize_dialog"} & new_modules),
|
not ({"main", "config", "services", "providers", "hooks", "ui.summarize_dialog"} & new_modules),
|
||||||
|
|||||||
Reference in new issue
Block a user