generated from exteraGramPlugins/pluginBase
Implement Chat Summaries plugin
Replace the sample plugin with an AI chat summarizer that renders its
output as a native RichMessage injected into the chat.
Summarization pipeline:
- Fetch chat/topic history via paginated TL requests.
- Send a system prompt, the user's instructions and an anonymized JSON
transcript as three separate roles. Only message text and attachment
names leave the device; senders and message ids are replaced by
indices, and out-of-range reply parents are included with negative ids
so replies keep their context.
- Stream the response over SSE, reading reasoning from reasoning_content,
reasoning or inline <think> tags.
- Substitute the model's {userN}/{msgN} placeholders back into real names
and deep links. Placeholders are used instead of Markdown links because
a half-streamed link renders as broken literal text.
Rendering:
- Compile Markdown into TL_iv page blocks (headings, lists, quotes, code,
dividers, inline styles) since the app's own converter works off
Android Spanned spans rather than Markdown source.
- Show progress stages immediately on click, animate reasoning while it
streams, then collapse it into a tappable details block.
- Attribute the card to a client-side mock sender and insert it directly
after the newest analyzed message, scrolling it into view.
Persistence:
- Store summaries in SQLite keyed by (dialog, topic, anchor) and
re-inject them when the chat is reopened.
Also add tools/release.py and a Gitea workflow that builds, verifies and
publishes dist/<id>.plugin to the Releases tab, and extend
tools/verify_bundle.py to 104 offline checks covering the transcript,
streaming, rendering, injection and storage behaviour.
This commit is contained in:
1 parent
a3b0b264c1
commit
196da742de
27 files changed
+5257
-117
No files matched your search
@@ -0,0 +1,76 @@
|
|||||||
|
name: Publish plugin
|
||||||
|
|
||||||
|
# Builds dist/<id>.plugin and publishes it to the Releases tab.
|
||||||
|
#
|
||||||
|
# Triggers:
|
||||||
|
# push a v* tag -> release named after that tag
|
||||||
|
# manual dispatch -> release named v<version from plugin.json>
|
||||||
|
#
|
||||||
|
# dist/ is gitignored, so the artifact is always built here rather than
|
||||||
|
# committed. The build uses --release because --dev embeds absolute paths from
|
||||||
|
# whichever machine produced it.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out sources
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Validate plugin metadata and sources
|
||||||
|
run: python3 tools/bundle.py --check
|
||||||
|
|
||||||
|
- name: Build release bundle
|
||||||
|
id: build
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
python3 tools/bundle.py --release
|
||||||
|
plugin_id=$(python3 -c "import json; print(json.load(open('plugin.json'))['id'])")
|
||||||
|
version=$(python3 -c "import json; print(json.load(open('plugin.json'))['version'])")
|
||||||
|
artifact="dist/${plugin_id}.plugin"
|
||||||
|
test -s "$artifact"
|
||||||
|
echo "artifact=${artifact}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "asset_name=${plugin_id}.plugin" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "built ${artifact} ($(wc -c < "$artifact") bytes)"
|
||||||
|
|
||||||
|
- name: Verify the built bundle
|
||||||
|
# Executes the bundle against faked SDK modules and asserts the
|
||||||
|
# plugin's observable behaviour. A build that fails this is not
|
||||||
|
# publishable, so this gates the upload.
|
||||||
|
run: python3 tools/verify_bundle.py "${{ steps.build.outputs.artifact }}"
|
||||||
|
|
||||||
|
- name: Resolve release tag
|
||||||
|
id: tag
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
|
||||||
|
tag="${GITHUB_REF_NAME}"
|
||||||
|
else
|
||||||
|
tag="v${{ steps.build.outputs.version }}"
|
||||||
|
fi
|
||||||
|
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "publishing as ${tag}"
|
||||||
|
|
||||||
|
- name: Publish release and upload plugin
|
||||||
|
env:
|
||||||
|
# Org-level secret holding a user token with write:repository scope.
|
||||||
|
# Not using a `||` fallback to GITEA_TOKEN: Gitea's expression engine
|
||||||
|
# supports only always(), so the operator would not evaluate.
|
||||||
|
GITEA_TOKEN: ${{ secrets.API_TOKEN }}
|
||||||
|
run: |
|
||||||
|
python3 tools/release.py \
|
||||||
|
--api "${{ github.server_url }}/api/v1/repos/${{ github.repository }}" \
|
||||||
|
--tag "${{ steps.tag.outputs.tag }}" \
|
||||||
|
--artifact "${{ steps.build.outputs.artifact }}" \
|
||||||
|
--asset-name "${{ steps.build.outputs.asset_name }}" \
|
||||||
|
--commitish "${{ github.sha }}"
|
||||||
@@ -34,12 +34,18 @@ This repo does not use the official Elyx/ElyxBuilder archive format — see
|
|||||||
plugin.json single source of truth for plugin metadata
|
plugin.json single source of truth for plugin metadata
|
||||||
src/ the plugin, written as an ordinary Python package
|
src/ the plugin, written as an ordinary Python package
|
||||||
main.py entry module — exactly one BasePlugin subclass, absolute imports only
|
main.py entry module — exactly one BasePlugin subclass, absolute imports only
|
||||||
greeting/ sample package (proves relative + cross-module imports survive bundling)
|
config.py setting keys, defaults and tunables
|
||||||
textutil.py sample top-level sibling module
|
diagnostics.py bounded in-memory log, mirrored to a file (logcat is restricted here)
|
||||||
|
providers/ endpoint settings, prompt assembly and SSE streaming
|
||||||
|
services/ history fetch, anonymized transcript, markdown→RichMessage, injection, SQLite store
|
||||||
|
hooks/ Xposed hooks (unread-button entry points, summary restore on chat open)
|
||||||
|
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
|
||||||
devclient.py speaks the on-device dev-server protocol (127.0.0.1:42690)
|
devclient.py speaks the on-device dev-server protocol (127.0.0.1:42690)
|
||||||
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)
|
||||||
|
.gitea/workflows/ CI that builds, verifies and publishes the .plugin to Releases
|
||||||
scripts/ bash automation for the ADB debug loop (see below)
|
scripts/ bash automation for the ADB debug loop (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
|
||||||
```
|
```
|
||||||
@@ -91,6 +97,36 @@ 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.
|
||||||
|
|
||||||
|
## Publishing a release
|
||||||
|
|
||||||
|
`.gitea/workflows/release.yml` builds, verifies and publishes the `.plugin` to the repo's
|
||||||
|
Releases tab. `dist/` is gitignored, so the artifact is always built by CI rather than
|
||||||
|
committed.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# tag a version and push it -> release named after the tag
|
||||||
|
git tag v1.0.1 && git push origin v1.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
The workflow also accepts a manual run (Actions → Publish plugin → Run workflow), which
|
||||||
|
derives the tag from `version` in `plugin.json`. Either way it runs `--check`, builds with
|
||||||
|
`--release`, and gates the upload on `verify_bundle.py`, so a bundle that fails verification
|
||||||
|
is never published.
|
||||||
|
|
||||||
|
Re-running against an existing tag reuses that release and replaces the attachment instead of
|
||||||
|
piling up duplicates.
|
||||||
|
|
||||||
|
Authentication uses `secrets.API_TOKEN`, an organisation-level Actions secret holding a user
|
||||||
|
token with the `write:repository` scope (which covers releases). Set it once under
|
||||||
|
**Org → Settings → Actions → Secrets** and every repo in the organisation inherits it; a
|
||||||
|
repo-level secret of the same name takes precedence if one repo ever needs different
|
||||||
|
credentials.
|
||||||
|
|
||||||
|
If you would rather not manage a token, Gitea also injects a per-job `secrets.GITEA_TOKEN`
|
||||||
|
that can create releases in its own repo — swap the `env:` line in the workflow 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. Edit `plugin.json` (id, name, description, etc.).
|
||||||
|
|||||||
+5
-5
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"id": "hello_bundle",
|
"id": "chat_summaries",
|
||||||
"name": "Hello Bundle",
|
"name": "Chat Summaries",
|
||||||
"description": "Multi-file exteraGram plugin built by tools/bundle.py",
|
"description": "Summarize the last N messages (with offset) in any chat, group, channel, or topic using AI.",
|
||||||
"author": "@yourhandle",
|
"author": "@lyuksovannyy",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"icon": "exteraPlugins/1",
|
"icon": "exteraPlugins/1",
|
||||||
"app_version": ">=12.5.1",
|
"app_version": ">=12.5.1",
|
||||||
"sdk_version": ">=1.4.4.3",
|
"sdk_version": ">=1.4.4.3",
|
||||||
"requirements": []
|
"requirements": ["requests", "debugpy"]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Configuration constants for the Chat Summaries plugin."""
|
||||||
|
|
||||||
|
# Message count/offset bounds (messages to include in a summary).
|
||||||
|
MIN_MSG_COUNT = 1
|
||||||
|
MAX_MSG_COUNT = 1000
|
||||||
|
DEFAULT_MSG_COUNT = 100
|
||||||
|
DEFAULT_MSG_OFFSET = 0
|
||||||
|
|
||||||
|
# Individual Telegram message text is truncated to this many characters when
|
||||||
|
# building the transcript, to keep prompts within a reasonable size.
|
||||||
|
MAX_SINGLE_MESSAGE_CHARS = 4000
|
||||||
|
|
||||||
|
# History is fetched from the Telegram API in chunks of at most this size.
|
||||||
|
HISTORY_CHUNK_SIZE = 100
|
||||||
|
|
||||||
|
# Single provider: any OpenAI-compatible chat-completions endpoint.
|
||||||
|
# OpenAI and Anthropic direct connectors were removed: both require an API
|
||||||
|
# key (no public OAuth flow exists for third-party inference access), and a
|
||||||
|
# custom endpoint already covers them -- point base_url at
|
||||||
|
# https://api.openai.com/v1 with an OpenAI key if that's what you want.
|
||||||
|
DEFAULT_CUSTOM_MODEL = "gpt-4o-mini"
|
||||||
|
|
||||||
|
# Setting keys (global settings page).
|
||||||
|
SETTING_CUSTOM_BASE_URL = "custom_base_url"
|
||||||
|
SETTING_CUSTOM_API_KEY = "custom_api_key"
|
||||||
|
SETTING_CUSTOM_MODEL = "custom_model"
|
||||||
|
SETTING_DEFAULT_COUNT = "default_count"
|
||||||
|
SETTING_DEFAULT_OFFSET = "default_offset"
|
||||||
|
SETTING_GLOBAL_PROMPT = "global_prompt"
|
||||||
|
SETTING_INCLUDE_ATTACHMENTS = "include_attachments"
|
||||||
|
|
||||||
|
DEFAULT_PROMPT = (
|
||||||
|
"Summarize the following chat conversation. Focus on key points, "
|
||||||
|
"decisions, and action items. Be concise."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Per-chat/per-topic prompt override key. dialog_id may be negative (groups,
|
||||||
|
# channels); topic_id is 0 outside forum topics.
|
||||||
|
PROMPT_OVERRIDE_KEY_TEMPLATE = "prompt_override_{dialog_id}_{topic_id}"
|
||||||
|
|
||||||
|
# Long-press threshold (seconds) distinguishing a hold from a tap on the
|
||||||
|
# unread/pagedown button.
|
||||||
|
LONG_PRESS_SECONDS = 0.4
|
||||||
|
|
||||||
|
# Minimum seconds between re-renders of a streaming summary message. Each push
|
||||||
|
# rebuilds the RichMessage blocks and rebinds the cell, so pushing on every SSE
|
||||||
|
# chunk would burn the UI thread for no visible gain.
|
||||||
|
STREAM_UPDATE_INTERVAL = 0.25
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Bounded in-memory diagnostics, since this device's logcat does not surface
|
||||||
|
Chaquopy stdout/stderr or android_utils.log output for this app. Every
|
||||||
|
exception this plugin would otherwise swallow is recorded here so it is
|
||||||
|
inspectable from the plugin's settings page ("Copy diagnostics") or, when the
|
||||||
|
UI is inconvenient, from the mirrored file under the app's files directory.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
_MAX_ENTRIES = 200
|
||||||
|
_ENTRIES: List[str] = []
|
||||||
|
_LOG_PATH = None
|
||||||
|
# Diagnostics mirror is a debugging aid, not a record to keep: restart the
|
||||||
|
# file once it passes this size rather than growing it forever.
|
||||||
|
_MAX_LOG_BYTES = 256 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _append(line: str) -> None:
|
||||||
|
if len(_ENTRIES) >= _MAX_ENTRIES:
|
||||||
|
_ENTRIES.pop(0)
|
||||||
|
_ENTRIES.append(line)
|
||||||
|
try:
|
||||||
|
from android_utils import log as android_log
|
||||||
|
|
||||||
|
android_log(line)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_mirror_to_file(line)
|
||||||
|
|
||||||
|
|
||||||
|
def _mirror_to_file(line: str) -> None:
|
||||||
|
"""Mirrors one entry to a file under the app's files dir.
|
||||||
|
|
||||||
|
This device's logcat does not surface Chaquopy output and the clipboard is
|
||||||
|
not readable over adb, so a file is the only way to inspect diagnostics
|
||||||
|
without driving the UI. Truncated once it exceeds _MAX_LOG_BYTES so it
|
||||||
|
cannot grow without bound.
|
||||||
|
"""
|
||||||
|
global _LOG_PATH
|
||||||
|
if _LOG_PATH is None:
|
||||||
|
try:
|
||||||
|
import os
|
||||||
|
|
||||||
|
from file_utils import get_files_dir
|
||||||
|
|
||||||
|
directory = os.path.join(get_files_dir(), "chat_summaries")
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
_LOG_PATH = os.path.join(directory, "diagnostics.log")
|
||||||
|
except Exception:
|
||||||
|
_LOG_PATH = ""
|
||||||
|
if not _LOG_PATH:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import os
|
||||||
|
|
||||||
|
mode = "a"
|
||||||
|
if os.path.exists(_LOG_PATH) and os.path.getsize(_LOG_PATH) > _MAX_LOG_BYTES:
|
||||||
|
mode = "w"
|
||||||
|
with open(_LOG_PATH, mode, encoding="utf-8") as handle:
|
||||||
|
handle.write(line + "\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def note(message: str) -> None:
|
||||||
|
"""Records a fact (successful step, resolved value, etc.)."""
|
||||||
|
_append(f"[fact] {message}")
|
||||||
|
|
||||||
|
|
||||||
|
def record_error(location: str, exc: BaseException) -> None:
|
||||||
|
"""Records an exception with its full traceback."""
|
||||||
|
tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
||||||
|
_append(f"[error] {location}: {exc}\n{tb}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_report() -> str:
|
||||||
|
if not _ENTRIES:
|
||||||
|
return "No diagnostics recorded yet."
|
||||||
|
return "\n".join(_ENTRIES)
|
||||||
|
|
||||||
|
|
||||||
|
def clear() -> None:
|
||||||
|
_ENTRIES.clear()
|
||||||
@@ -1 +0,0 @@
|
|||||||
from .template import render, TemplateError
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
from textutil import normalize
|
|
||||||
|
|
||||||
|
|
||||||
class TemplateError(ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def render(template: str, name: str) -> str:
|
|
||||||
normalized = normalize(name)
|
|
||||||
try:
|
|
||||||
return template.format(name=normalized)
|
|
||||||
except (KeyError, IndexError, ValueError):
|
|
||||||
raise TemplateError(f"unsupported placeholder in {template!r}")
|
|
||||||
Whitespace-only changes.
@@ -0,0 +1,157 @@
|
|||||||
|
"""Re-injects persisted summaries when a chat is opened.
|
||||||
|
|
||||||
|
Injected summaries are view-only: they live in the open ChatActivity's message
|
||||||
|
list and are gone once it is recreated. This hook runs after ChatActivity's
|
||||||
|
`didReceivedNotification_messagesDidLoad` (the point at which the message list
|
||||||
|
is populated) and re-inserts whatever the local DB holds for that chat/topic.
|
||||||
|
|
||||||
|
Method name verified against exteraGram 12.5.x: there is no
|
||||||
|
`processLoadedMessages`; the messagesDidLoad notification handler is a separate
|
||||||
|
method. Reflection approach mirrors hooks/unread_hook.py: enumerate declared
|
||||||
|
methods by name rather than resolving exact Java parameter types from Python.
|
||||||
|
"""
|
||||||
|
from typing import Any, List
|
||||||
|
|
||||||
|
import diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def _class_candidates(cls: Any) -> List[Any]:
|
||||||
|
candidates = [cls]
|
||||||
|
for attr in ("getClass",):
|
||||||
|
try:
|
||||||
|
resolved = getattr(cls, attr)()
|
||||||
|
if resolved is not None:
|
||||||
|
candidates.append(resolved)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _find_methods_by_name(cls: Any, name: str) -> List[Any]:
|
||||||
|
for source in _class_candidates(cls):
|
||||||
|
if not hasattr(source, "getDeclaredMethods"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
methods = [m for m in source.getDeclaredMethods() if m.getName() == name]
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("restore_hook._find_methods_by_name", exc)
|
||||||
|
continue
|
||||||
|
if methods:
|
||||||
|
return methods
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class RestoreHook:
|
||||||
|
"""Restores stored summaries into freshly opened chats."""
|
||||||
|
|
||||||
|
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("restore_hook.install:imports", exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
chat_activity_class = find_class("org.telegram.ui.ChatActivity")
|
||||||
|
diagnostics.note(f"restore_hook.install: ChatActivity found={bool(chat_activity_class)}")
|
||||||
|
if not chat_activity_class:
|
||||||
|
return
|
||||||
|
|
||||||
|
# `didReceivedNotification_messagesDidLoad` exists but is never
|
||||||
|
# observed firing through Xposed on this build, so anchor on the
|
||||||
|
# fragment lifecycle instead: `updateMessagesVisiblePart` runs on every
|
||||||
|
# chat open and after each list change, which is exactly when a
|
||||||
|
# restored card could be missing.
|
||||||
|
methods = []
|
||||||
|
for name in ("updateMessagesVisiblePart", "didReceivedNotification_messagesDidLoad"):
|
||||||
|
found = _find_methods_by_name(chat_activity_class, name)
|
||||||
|
diagnostics.note(f"restore_hook.install: {name} methods found={len(found)}")
|
||||||
|
methods.extend(found)
|
||||||
|
if not methods:
|
||||||
|
return
|
||||||
|
|
||||||
|
plugin = self.plugin
|
||||||
|
# updateMessagesVisiblePart fires on every scroll frame, so restoring
|
||||||
|
# is gated on the card actually being absent: restore_into() bails as
|
||||||
|
# soon as the summary is already in the list.
|
||||||
|
last_scope = {"key": None}
|
||||||
|
|
||||||
|
class _RestoreAfterLoad(MethodHook):
|
||||||
|
def after_hooked_method(self, param):
|
||||||
|
try:
|
||||||
|
chat_activity = getattr(param, "thisObject", None)
|
||||||
|
if chat_activity is None:
|
||||||
|
return
|
||||||
|
restore_into(plugin, chat_activity, last_scope)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("restore_hook.after_hooked_method", exc)
|
||||||
|
|
||||||
|
for method in methods:
|
||||||
|
try:
|
||||||
|
ref = self.plugin.hook_method(method, _RestoreAfterLoad())
|
||||||
|
if ref:
|
||||||
|
self._unhooks.append(ref)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("restore_hook.install:hook_method", exc)
|
||||||
|
|
||||||
|
def uninstall(self) -> None:
|
||||||
|
for ref in self._unhooks:
|
||||||
|
try:
|
||||||
|
ref.unhook()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._unhooks = []
|
||||||
|
|
||||||
|
|
||||||
|
def restore_into(plugin: Any, chat_activity: Any, last_scope: Any = None) -> None:
|
||||||
|
"""Injects stored summaries for the chat this ChatActivity is showing.
|
||||||
|
|
||||||
|
Called from a high-frequency lifecycle hook, so it must be cheap: it does
|
||||||
|
one DB read per (dialog, topic) it has not seen yet, and
|
||||||
|
restore_saved_summaries() skips anything already present in the list.
|
||||||
|
"""
|
||||||
|
from services.rich_injector import restore_saved_summaries
|
||||||
|
|
||||||
|
db = plugin._get_summary_db()
|
||||||
|
if db is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
dialog_id = int(chat_activity.getDialogId())
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
if not dialog_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
topic_id = 0
|
||||||
|
try:
|
||||||
|
if hasattr(chat_activity, "getTopicId"):
|
||||||
|
topic_id = int(chat_activity.getTopicId() or 0)
|
||||||
|
except Exception:
|
||||||
|
topic_id = 0
|
||||||
|
|
||||||
|
# Avoid a DB round-trip on every scroll frame; a new scope means a
|
||||||
|
# different chat/topic was opened, which is the only time a restore is due.
|
||||||
|
scope_key = (id(chat_activity), dialog_id, topic_id)
|
||||||
|
if last_scope is not None and last_scope.get("key") == scope_key:
|
||||||
|
return
|
||||||
|
if last_scope is not None:
|
||||||
|
last_scope["key"] = scope_key
|
||||||
|
|
||||||
|
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 finishes.
|
||||||
|
summaries = [s for s in summaries if (s.text or "").strip()]
|
||||||
|
if not summaries:
|
||||||
|
return
|
||||||
|
|
||||||
|
count = restore_saved_summaries(chat_activity, plugin._get_account(), dialog_id, topic_id, summaries)
|
||||||
|
if count:
|
||||||
|
diagnostics.note(f"restore_hook: re-injected {count} summary message(s)")
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
"""Hooks two independent entry points so holding either opens the summarize
|
||||||
|
dialog, pre-filled with the chat's actual unread count/offset:
|
||||||
|
|
||||||
|
1. ChatActivity.onPageDownClicked() -- the floating "jump to newest" button.
|
||||||
|
This build exposes no long-click listener on that button; its only
|
||||||
|
stable hookable entry point is this method. To distinguish a hold from a
|
||||||
|
tap, touch DOWN/UP timing is captured globally at
|
||||||
|
LaunchActivity.dispatchTouchEvent and consulted here.
|
||||||
|
2. ChatUnreadCell -- the inline "N unread messages" divider shown in the
|
||||||
|
message list. Its constructor is hooked so a click/long-click listener
|
||||||
|
can be attached directly to each instantiated cell.
|
||||||
|
|
||||||
|
Reflection notes (grounded on-device): hook_utils.find_class(...) returns a
|
||||||
|
Chaquopy class wrapper, not a java.lang.Class -- only .getClass() reaches
|
||||||
|
getDeclaredMethods()/getDeclaredConstructors(). Methods are looked up by
|
||||||
|
enumerating getDeclaredMethods() and matching by name rather than by exact
|
||||||
|
getDeclaredMethod(name, *paramTypes) signature, since resolving the correct
|
||||||
|
Java parameter-type Class objects from Python is brittle and unnecessary
|
||||||
|
when the method name alone is unambiguous.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
|
import diagnostics
|
||||||
|
from config import LONG_PRESS_SECONDS, MAX_MSG_COUNT
|
||||||
|
|
||||||
|
_GESTURE = {"down_at": 0.0, "released_at": 0.0, "consumed": False}
|
||||||
|
_STALE_GESTURE_SECONDS = 0.75
|
||||||
|
|
||||||
|
|
||||||
|
def _held_duration() -> float:
|
||||||
|
down_at = _GESTURE["down_at"]
|
||||||
|
if not down_at:
|
||||||
|
return 0.0
|
||||||
|
released_at = _GESTURE["released_at"]
|
||||||
|
end = released_at or time.monotonic()
|
||||||
|
if released_at and (time.monotonic() - released_at) > _STALE_GESTURE_SECONDS:
|
||||||
|
return 0.0
|
||||||
|
return max(0.0, end - down_at)
|
||||||
|
|
||||||
|
|
||||||
|
def _class_candidates(cls: Any) -> List[Any]:
|
||||||
|
"""Returns every object that might expose real reflection members, best first."""
|
||||||
|
candidates: List[Any] = []
|
||||||
|
if cls is None:
|
||||||
|
return candidates
|
||||||
|
try:
|
||||||
|
inner = cls.getClass()
|
||||||
|
if inner is not None:
|
||||||
|
candidates.append(inner)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not any(cls is c for c in candidates):
|
||||||
|
candidates.append(cls)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _find_methods_by_name(cls: Any, name: str) -> List[Any]:
|
||||||
|
"""Enumerates declared/public methods matching `name` across candidates."""
|
||||||
|
for source in _class_candidates(cls):
|
||||||
|
for accessor in ("getDeclaredMethods", "getMethods"):
|
||||||
|
if not hasattr(source, accessor):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
methods = getattr(source, accessor)()
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error(f"_find_methods_by_name:{accessor}", exc)
|
||||||
|
continue
|
||||||
|
found = [m for m in methods if _method_name(m) == name]
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _method_name(method: Any) -> Optional[str]:
|
||||||
|
try:
|
||||||
|
return method.getName()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_constructors(cls: Any) -> List[Any]:
|
||||||
|
for source in _class_candidates(cls):
|
||||||
|
if not hasattr(source, "getDeclaredConstructors"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ctors = list(source.getDeclaredConstructors())
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("_find_constructors", exc)
|
||||||
|
continue
|
||||||
|
if ctors:
|
||||||
|
return ctors
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class UnreadHook:
|
||||||
|
"""Installs the touch-timing tracker plus both pagedown-button and
|
||||||
|
unread-divider-cell interceptors.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, plugin):
|
||||||
|
self.plugin = plugin
|
||||||
|
self._unhooks: List[Any] = []
|
||||||
|
self._hooked_view_ids: set = set()
|
||||||
|
|
||||||
|
def install(self) -> None:
|
||||||
|
try:
|
||||||
|
from base_plugin import MethodHook
|
||||||
|
from hook_utils import find_class
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("unread_hook.install:imports", exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._install_touch_tracker(find_class, MethodHook)
|
||||||
|
self._install_pagedown_hook(find_class, MethodHook)
|
||||||
|
self._install_unread_cell_hook(find_class, MethodHook)
|
||||||
|
|
||||||
|
def _install_touch_tracker(self, find_class, MethodHook) -> None:
|
||||||
|
gesture_ref = _GESTURE
|
||||||
|
launch_activity_class = find_class("org.telegram.ui.LaunchActivity")
|
||||||
|
diagnostics.note(f"unread_hook.install: LaunchActivity found={bool(launch_activity_class)}")
|
||||||
|
if not launch_activity_class:
|
||||||
|
return
|
||||||
|
methods = _find_methods_by_name(launch_activity_class, "dispatchTouchEvent")
|
||||||
|
diagnostics.note(f"unread_hook.install: dispatchTouchEvent methods found={len(methods)}")
|
||||||
|
if not methods:
|
||||||
|
return
|
||||||
|
|
||||||
|
class _TouchTrackHook(MethodHook):
|
||||||
|
def before_hooked_method(self, param):
|
||||||
|
try:
|
||||||
|
event = param.args[0]
|
||||||
|
action = event.getActionMasked()
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
now = time.monotonic()
|
||||||
|
if action == 0: # ACTION_DOWN
|
||||||
|
gesture_ref["down_at"] = now
|
||||||
|
gesture_ref["released_at"] = 0.0
|
||||||
|
gesture_ref["consumed"] = False
|
||||||
|
elif action == 1: # ACTION_UP
|
||||||
|
gesture_ref["released_at"] = now
|
||||||
|
elif action == 3: # ACTION_CANCEL
|
||||||
|
gesture_ref["down_at"] = 0.0
|
||||||
|
gesture_ref["released_at"] = 0.0
|
||||||
|
gesture_ref["consumed"] = False
|
||||||
|
|
||||||
|
for method in methods:
|
||||||
|
try:
|
||||||
|
ref = self.plugin.hook_method(method, _TouchTrackHook())
|
||||||
|
diagnostics.note(f"unread_hook.install: dispatchTouchEvent hook ref={ref!r}")
|
||||||
|
if ref:
|
||||||
|
self._unhooks.append(ref)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("unread_hook.install:dispatchTouchEvent", exc)
|
||||||
|
|
||||||
|
def _install_pagedown_hook(self, find_class, MethodHook) -> None:
|
||||||
|
chat_activity_class = find_class("org.telegram.ui.ChatActivity")
|
||||||
|
diagnostics.note(f"unread_hook.install: ChatActivity found={bool(chat_activity_class)}")
|
||||||
|
if not chat_activity_class:
|
||||||
|
diagnostics.record_error("unread_hook.install", RuntimeError("ChatActivity class not found"))
|
||||||
|
return
|
||||||
|
methods = _find_methods_by_name(chat_activity_class, "onPageDownClicked")
|
||||||
|
diagnostics.note(f"unread_hook.install: onPageDownClicked methods found={len(methods)}")
|
||||||
|
if not methods:
|
||||||
|
diagnostics.record_error(
|
||||||
|
"unread_hook.install", RuntimeError("onPageDownClicked method not found")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
gesture_ref = _GESTURE
|
||||||
|
outer = self
|
||||||
|
|
||||||
|
class _PageDownHook(MethodHook):
|
||||||
|
def before_hooked_method(self, param):
|
||||||
|
try:
|
||||||
|
duration = _held_duration()
|
||||||
|
diagnostics.note(f"unread_hook: onPageDownClicked duration={duration:.3f}")
|
||||||
|
if duration < LONG_PRESS_SECONDS or gesture_ref["consumed"]:
|
||||||
|
return
|
||||||
|
chat_activity = param.thisObject
|
||||||
|
if chat_activity is None:
|
||||||
|
return
|
||||||
|
gesture_ref["consumed"] = True
|
||||||
|
param.setResult(None) # abort the normal scroll-to-newest
|
||||||
|
outer._trigger(chat_activity)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("unread_hook:onPageDownClicked", exc)
|
||||||
|
|
||||||
|
for method in methods:
|
||||||
|
try:
|
||||||
|
ref = self.plugin.hook_method(method, _PageDownHook())
|
||||||
|
diagnostics.note(f"unread_hook.install: onPageDownClicked hook ref={ref!r}")
|
||||||
|
if ref:
|
||||||
|
self._unhooks.append(ref)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("unread_hook.install:onPageDownClicked", exc)
|
||||||
|
|
||||||
|
def _install_unread_cell_hook(self, find_class, MethodHook) -> None:
|
||||||
|
"""Hooks ChatUnreadCell's constructors to attach a click/long-click
|
||||||
|
listener to the inline "N unread messages" divider cell.
|
||||||
|
"""
|
||||||
|
unread_cell_class = find_class("org.telegram.ui.Cells.ChatUnreadCell")
|
||||||
|
diagnostics.note(f"unread_hook.install: ChatUnreadCell found={bool(unread_cell_class)}")
|
||||||
|
if not unread_cell_class:
|
||||||
|
return
|
||||||
|
ctors = _find_constructors(unread_cell_class)
|
||||||
|
diagnostics.note(f"unread_hook.install: ChatUnreadCell constructors found={len(ctors)}")
|
||||||
|
if not ctors:
|
||||||
|
return
|
||||||
|
|
||||||
|
outer = self
|
||||||
|
|
||||||
|
class _UnreadCellHook(MethodHook):
|
||||||
|
def after_hooked_method(self, param):
|
||||||
|
try:
|
||||||
|
cell = param.thisObject
|
||||||
|
if cell is not None:
|
||||||
|
outer._setup_unread_cell(cell)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("unread_hook:ChatUnreadCell.after", exc)
|
||||||
|
|
||||||
|
cell_hook = _UnreadCellHook()
|
||||||
|
attached = 0
|
||||||
|
for ctor in ctors:
|
||||||
|
try:
|
||||||
|
ref = self.plugin.hook_method(ctor, cell_hook)
|
||||||
|
if ref:
|
||||||
|
self._unhooks.append(ref)
|
||||||
|
attached += 1
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("unread_hook.install:ChatUnreadCell_ctor", exc)
|
||||||
|
diagnostics.note(f"unread_hook.install: ChatUnreadCell constructors hooked={attached}")
|
||||||
|
|
||||||
|
def _mark_hooked(self, view: Any) -> bool:
|
||||||
|
try:
|
||||||
|
key = id(view)
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
if key in self._hooked_view_ids:
|
||||||
|
return False
|
||||||
|
if len(self._hooked_view_ids) > 64:
|
||||||
|
self._hooked_view_ids.clear()
|
||||||
|
self._hooked_view_ids.add(key)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _setup_unread_cell(self, unread_cell: Any) -> None:
|
||||||
|
"""Attaches click and long-click listeners to a ChatUnreadCell instance."""
|
||||||
|
try:
|
||||||
|
from android_utils import OnClickListener, OnLongClickListener, run_on_ui_thread
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("unread_hook.cell:imports", exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
outer = self
|
||||||
|
|
||||||
|
def _init():
|
||||||
|
try:
|
||||||
|
if not outer._mark_hooked(unread_cell):
|
||||||
|
return
|
||||||
|
unread_cell.setClickable(True)
|
||||||
|
unread_cell.setFocusable(True)
|
||||||
|
unread_cell.setLongClickable(True)
|
||||||
|
|
||||||
|
def on_action(*_args):
|
||||||
|
outer._trigger(None)
|
||||||
|
return True
|
||||||
|
|
||||||
|
unread_cell.setOnClickListener(OnClickListener(lambda v=None: on_action()))
|
||||||
|
unread_cell.setOnLongClickListener(OnLongClickListener(lambda v=None: on_action()))
|
||||||
|
diagnostics.note("unread_hook: ChatUnreadCell listeners attached")
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("unread_hook.cell:_init", exc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
run_on_ui_thread(_init)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("unread_hook.cell:run_on_ui_thread", exc)
|
||||||
|
|
||||||
|
def uninstall(self) -> None:
|
||||||
|
try:
|
||||||
|
for ref in self._unhooks:
|
||||||
|
self.plugin.unhook_method(ref)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("unread_hook.uninstall", exc)
|
||||||
|
self._unhooks = []
|
||||||
|
self._hooked_view_ids.clear()
|
||||||
|
|
||||||
|
def _trigger(self, chat_activity: Optional[Any]) -> None:
|
||||||
|
"""Resolves the chat's unread count/offset and opens the summarize
|
||||||
|
dialog. `chat_activity` may be None (e.g. triggered from the unread
|
||||||
|
cell rather than the pagedown button), in which case the current
|
||||||
|
fragment is used to resolve the chat instead.
|
||||||
|
"""
|
||||||
|
diagnostics.note("unread_hook: _trigger fired")
|
||||||
|
try:
|
||||||
|
from client_utils import get_last_fragment, get_messages_controller
|
||||||
|
|
||||||
|
fragment = chat_activity or get_last_fragment()
|
||||||
|
if fragment is None:
|
||||||
|
diagnostics.record_error("unread_hook._trigger", RuntimeError("no fragment available"))
|
||||||
|
return
|
||||||
|
|
||||||
|
dialog_id = self._resolve_dialog_id(fragment)
|
||||||
|
diagnostics.note(f"unread_hook: resolved dialog_id={dialog_id}")
|
||||||
|
if not dialog_id:
|
||||||
|
diagnostics.record_error("unread_hook._trigger", RuntimeError("could not resolve dialog_id"))
|
||||||
|
return
|
||||||
|
|
||||||
|
raw_unread = 0
|
||||||
|
try:
|
||||||
|
controller = get_messages_controller()
|
||||||
|
dialog = controller.dialogs_dict.get(dialog_id) if controller else None
|
||||||
|
if dialog is not None:
|
||||||
|
raw_unread = int(getattr(dialog, "unread_count", 0) or 0)
|
||||||
|
except Exception:
|
||||||
|
raw_unread = 0
|
||||||
|
|
||||||
|
if raw_unread > 0:
|
||||||
|
count = min(MAX_MSG_COUNT, max(1, raw_unread))
|
||||||
|
offset = max(0, raw_unread - count)
|
||||||
|
else:
|
||||||
|
count = None # let caller fall back to configured defaults
|
||||||
|
offset = 0
|
||||||
|
|
||||||
|
topic_id = self._resolve_topic_id(fragment)
|
||||||
|
self.plugin.open_summarize_dialog(
|
||||||
|
fragment, dialog_id, topic_id, prefill_count=count, prefill_offset=offset
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("unread_hook._trigger", exc)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_dialog_id(fragment: Any) -> Optional[int]:
|
||||||
|
try:
|
||||||
|
if hasattr(fragment, "getDialogId"):
|
||||||
|
return int(fragment.getDialogId())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from hook_utils import get_private_field
|
||||||
|
|
||||||
|
dialog_id = get_private_field(fragment, "dialog_id")
|
||||||
|
if dialog_id:
|
||||||
|
return int(dialog_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_topic_id(fragment: Any) -> int:
|
||||||
|
try:
|
||||||
|
if hasattr(fragment, "getTopicId"):
|
||||||
|
return int(fragment.getTopicId() or 0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from hook_utils import get_private_field
|
||||||
|
|
||||||
|
topic_id = get_private_field(fragment, "topicId")
|
||||||
|
if topic_id:
|
||||||
|
return int(topic_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return 0
|
||||||
+487
-33
@@ -1,50 +1,504 @@
|
|||||||
from base_plugin import BasePlugin, HookResult, HookStrategy
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from base_plugin import BasePlugin, MenuItemData, MenuItemType
|
||||||
from android_utils import log
|
from android_utils import log
|
||||||
from ui.settings import Header, Input, Text
|
from client_utils import get_last_fragment
|
||||||
from greeting import render, TemplateError
|
from ui.bulletin import BulletinHelper
|
||||||
|
from ui.settings import Divider, EditText, Header, Input, Switch, Text
|
||||||
|
|
||||||
DEFAULT_TEMPLATE = "Hello, {name}!"
|
import diagnostics
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
DEFAULT_CUSTOM_MODEL,
|
||||||
|
DEFAULT_MSG_COUNT,
|
||||||
|
DEFAULT_MSG_OFFSET,
|
||||||
|
DEFAULT_PROMPT,
|
||||||
|
PROMPT_OVERRIDE_KEY_TEMPLATE,
|
||||||
|
SETTING_CUSTOM_API_KEY,
|
||||||
|
SETTING_CUSTOM_BASE_URL,
|
||||||
|
SETTING_CUSTOM_MODEL,
|
||||||
|
SETTING_DEFAULT_COUNT,
|
||||||
|
SETTING_DEFAULT_OFFSET,
|
||||||
|
SETTING_GLOBAL_PROMPT,
|
||||||
|
SETTING_INCLUDE_ATTACHMENTS,
|
||||||
|
STREAM_UPDATE_INTERVAL,
|
||||||
|
)
|
||||||
|
from hooks.restore_hook import RestoreHook
|
||||||
|
from hooks.unread_hook import UnreadHook
|
||||||
|
from providers.dispatcher import Dispatcher
|
||||||
|
from services.message_fetcher import MessageFetcher
|
||||||
|
from ui.summarize_dialog import show_summarize_dialog
|
||||||
|
|
||||||
|
|
||||||
class HelloPlugin(BasePlugin):
|
class ChatSummariesPlugin(BasePlugin):
|
||||||
def on_plugin_load(self):
|
def __init__(self):
|
||||||
log("hello_bundle loaded")
|
super().__init__()
|
||||||
self.add_on_send_message_hook()
|
self.fetcher = MessageFetcher()
|
||||||
|
self.dispatcher = Dispatcher(self)
|
||||||
|
self.unread_hook = UnreadHook(self)
|
||||||
|
self.restore_hook = RestoreHook(self)
|
||||||
|
self.summary_db = None
|
||||||
|
|
||||||
def on_plugin_unload(self):
|
def _get_summary_db(self):
|
||||||
log("hello_bundle unloaded")
|
"""Opened lazily: a failure here must not break plugin load."""
|
||||||
|
if self.summary_db is None:
|
||||||
|
try:
|
||||||
|
from services.summary_db import SummaryDb
|
||||||
|
|
||||||
|
self.summary_db = SummaryDb()
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("summary_db.open", exc)
|
||||||
|
return self.summary_db
|
||||||
|
|
||||||
|
def on_plugin_load(self) -> None:
|
||||||
|
diagnostics.clear()
|
||||||
|
log("chat_summaries loaded")
|
||||||
|
try:
|
||||||
|
item_id = self.add_menu_item(
|
||||||
|
MenuItemData(
|
||||||
|
menu_type=MenuItemType.CHAT_ACTION_MENU,
|
||||||
|
text="Summarize",
|
||||||
|
icon="msg_bot",
|
||||||
|
on_click=self.on_menu_click,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("on_plugin_load:add_menu_item", exc)
|
||||||
|
try:
|
||||||
|
diag_item_id = self.add_menu_item(
|
||||||
|
MenuItemData(
|
||||||
|
menu_type=MenuItemType.CHAT_ACTION_MENU,
|
||||||
|
text="Copy diagnostics",
|
||||||
|
icon="msg_info",
|
||||||
|
on_click=self._on_copy_diagnostics,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("on_plugin_load:add_menu_item(diagnostics)", exc)
|
||||||
|
try:
|
||||||
|
self.unread_hook.install()
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("on_plugin_load:unread_hook.install", exc)
|
||||||
|
try:
|
||||||
|
self.restore_hook.install()
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("on_plugin_load:restore_hook.install", exc)
|
||||||
|
|
||||||
|
def on_plugin_unload(self) -> None:
|
||||||
|
self.unread_hook.uninstall()
|
||||||
|
self.restore_hook.uninstall()
|
||||||
|
log("chat_summaries unloaded")
|
||||||
|
|
||||||
def create_settings(self):
|
def create_settings(self):
|
||||||
return [
|
return [
|
||||||
Header(text="Hello Bundle"),
|
Header(text="AI Endpoint"),
|
||||||
Input(
|
Input(
|
||||||
key="template",
|
key=SETTING_CUSTOM_BASE_URL,
|
||||||
text="Greeting template",
|
text="Base URL",
|
||||||
default=DEFAULT_TEMPLATE,
|
default="",
|
||||||
subtext="Use {name} as placeholder.",
|
subtext="Any OpenAI-compatible API, e.g. https://api.openai.com/v1, https://openrouter.ai/api/v1, or http://<host>:11434/v1 for Ollama.",
|
||||||
icon="msg_edit",
|
icon="msg_edit",
|
||||||
),
|
),
|
||||||
|
Input(
|
||||||
|
key=SETTING_CUSTOM_API_KEY,
|
||||||
|
text="API key",
|
||||||
|
default="",
|
||||||
|
subtext="Leave empty for endpoints that don't require auth (e.g. local Ollama).",
|
||||||
|
icon="lock_solar",
|
||||||
|
),
|
||||||
|
Input(
|
||||||
|
key=SETTING_CUSTOM_MODEL,
|
||||||
|
text="Model",
|
||||||
|
default=DEFAULT_CUSTOM_MODEL,
|
||||||
|
icon="msg_edit",
|
||||||
|
),
|
||||||
|
Divider(text="Defaults"),
|
||||||
|
EditText(
|
||||||
|
key=SETTING_DEFAULT_COUNT,
|
||||||
|
default=str(DEFAULT_MSG_COUNT),
|
||||||
|
hint="Default message count (1-1000)",
|
||||||
|
mask="[0-9]*",
|
||||||
|
),
|
||||||
|
EditText(
|
||||||
|
key=SETTING_DEFAULT_OFFSET,
|
||||||
|
default=str(DEFAULT_MSG_OFFSET),
|
||||||
|
hint="Default offset",
|
||||||
|
mask="[0-9]*",
|
||||||
|
),
|
||||||
|
Input(
|
||||||
|
key=SETTING_GLOBAL_PROMPT,
|
||||||
|
text="Default prompt",
|
||||||
|
default=DEFAULT_PROMPT,
|
||||||
|
subtext="Used when a chat has no saved prompt override.",
|
||||||
|
icon="msg_edit",
|
||||||
|
),
|
||||||
|
Switch(
|
||||||
|
key=SETTING_INCLUDE_ATTACHMENTS,
|
||||||
|
text="Include attachments",
|
||||||
|
default=False,
|
||||||
|
subtext="Send attached file names along with the messages, so the "
|
||||||
|
"summary can mention shared files. Message text is sent either way.",
|
||||||
|
icon="msg_photos",
|
||||||
|
),
|
||||||
Text(
|
Text(
|
||||||
text="Example",
|
text="About",
|
||||||
subtext=".hello alice smith -> Hello, Alice Smith!",
|
subtext="Summarize via ⋮ → Plugins → Summarize, or hold the unread button to auto-fill unread messages. Diagnostics are also available from that menu.",
|
||||||
icon="msg_info",
|
icon="msg_info",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
def on_send_message_hook(self, account, params):
|
def _on_copy_diagnostics(self, context: Dict[str, Any]) -> None:
|
||||||
message = getattr(params, "message", None)
|
fragment = context.get("fragment") if isinstance(context, dict) else None
|
||||||
if not isinstance(message, str):
|
|
||||||
return HookResult()
|
|
||||||
stripped = message.strip()
|
|
||||||
if not stripped.startswith(".hello"):
|
|
||||||
return HookResult()
|
|
||||||
parts = stripped.split(" ", 1)
|
|
||||||
name = parts[1] if len(parts) > 1 else ""
|
|
||||||
if not name.strip():
|
|
||||||
params.message = "Usage: .hello <name>"
|
|
||||||
else:
|
|
||||||
try:
|
try:
|
||||||
params.message = render(self.get_setting("template", DEFAULT_TEMPLATE), name)
|
from android_utils import copy_to_clipboard
|
||||||
except TemplateError as exc:
|
|
||||||
params.message = "Bad template: " + str(exc)
|
copy_to_clipboard(diagnostics.get_report())
|
||||||
return HookResult(strategy=HookStrategy.MODIFY, params=params)
|
BulletinHelper.show_success("Diagnostics copied to clipboard", fragment)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("_on_copy_diagnostics", exc)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 3-dot "Summarize" menu entry
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def on_menu_click(self, context: Dict[str, Any]) -> None:
|
||||||
|
try:
|
||||||
|
|
||||||
|
fragment = context.get("fragment") or get_last_fragment()
|
||||||
|
dialog_id = context.get("dialog_id")
|
||||||
|
if dialog_id is None:
|
||||||
|
chat = context.get("chat")
|
||||||
|
dialog_id = -chat.id if chat is not None else context.get("chatId")
|
||||||
|
if dialog_id is None:
|
||||||
|
user_id = context.get("userId")
|
||||||
|
dialog_id = user_id
|
||||||
|
if dialog_id is None or fragment is None:
|
||||||
|
BulletinHelper.show_error("Chat Summaries: cannot resolve current chat", fragment)
|
||||||
|
return
|
||||||
|
|
||||||
|
topic_id = 0
|
||||||
|
try:
|
||||||
|
if hasattr(fragment, "getTopicId"):
|
||||||
|
topic_id = int(fragment.getTopicId() or 0)
|
||||||
|
except Exception:
|
||||||
|
topic_id = 0
|
||||||
|
|
||||||
|
activity = fragment.getParentActivity()
|
||||||
|
if activity is None:
|
||||||
|
BulletinHelper.show_error("Chat Summaries: no activity available", fragment)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.open_summarize_dialog(fragment, int(dialog_id), topic_id, activity=activity)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("on_menu_click", exc)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Shared entrypoint: opens the dialog, runs the fetch+summarize pipeline
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def open_summarize_dialog(
|
||||||
|
self,
|
||||||
|
fragment_or_activity_owner: Any,
|
||||||
|
dialog_id: int,
|
||||||
|
topic_id: int,
|
||||||
|
prefill_count: Optional[int] = None,
|
||||||
|
prefill_offset: int = 0,
|
||||||
|
activity: Any = None,
|
||||||
|
) -> None:
|
||||||
|
resolved_activity = activity
|
||||||
|
if resolved_activity is None:
|
||||||
|
try:
|
||||||
|
resolved_activity = fragment_or_activity_owner.getParentActivity()
|
||||||
|
except Exception:
|
||||||
|
resolved_activity = None
|
||||||
|
if resolved_activity is None:
|
||||||
|
diagnostics.record_error("open_summarize_dialog", RuntimeError("no activity available"))
|
||||||
|
return
|
||||||
|
|
||||||
|
default_count = prefill_count if prefill_count is not None else self._get_default_count()
|
||||||
|
default_offset = prefill_offset or self._get_default_offset()
|
||||||
|
default_prompt = self._get_prompt_for(dialog_id, topic_id)
|
||||||
|
|
||||||
|
def on_submit(count: int, offset: int, prompt: str) -> None:
|
||||||
|
self._save_prompt_for(dialog_id, topic_id, prompt)
|
||||||
|
self._run_summary(resolved_activity, fragment_or_activity_owner, dialog_id, topic_id, count, offset, prompt)
|
||||||
|
|
||||||
|
try:
|
||||||
|
show_summarize_dialog(
|
||||||
|
resolved_activity,
|
||||||
|
fragment_or_activity_owner,
|
||||||
|
default_count,
|
||||||
|
default_offset,
|
||||||
|
default_prompt,
|
||||||
|
on_submit,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("open_summarize_dialog:show_summarize_dialog", exc)
|
||||||
|
|
||||||
|
def _run_summary(
|
||||||
|
self,
|
||||||
|
activity: Any,
|
||||||
|
fragment: Any,
|
||||||
|
dialog_id: int,
|
||||||
|
topic_id: int,
|
||||||
|
count: int,
|
||||||
|
offset: int,
|
||||||
|
prompt: str,
|
||||||
|
) -> None:
|
||||||
|
"""Shows the summary card immediately, then drives it through every
|
||||||
|
stage: fetching history, contacting the endpoint, streaming reasoning,
|
||||||
|
streaming the answer.
|
||||||
|
|
||||||
|
The card is injected before the fetch so the click has instant visible
|
||||||
|
feedback. Its anchor is provisional until the fetch returns (the newest
|
||||||
|
analyzed message id is not known yet) and is corrected via reanchor().
|
||||||
|
"""
|
||||||
|
from services.rich_injector import SummaryInjection, virtual_id_for
|
||||||
|
|
||||||
|
account = self._get_account()
|
||||||
|
# With a non-zero offset the analyzed range ends partway up the
|
||||||
|
# history, so skip that many messages when guessing where the card
|
||||||
|
# goes. reanchor() corrects it exactly once the fetch returns.
|
||||||
|
provisional_anchor = self._newest_visible_message_id(fragment, skip=offset)
|
||||||
|
|
||||||
|
injection = SummaryInjection(
|
||||||
|
fragment,
|
||||||
|
account,
|
||||||
|
dialog_id,
|
||||||
|
topic_id,
|
||||||
|
provisional_anchor,
|
||||||
|
virtual_id_for(0, provisional_anchor or 1),
|
||||||
|
)
|
||||||
|
if not injection.insert(status="Fetching messages…", streaming=True):
|
||||||
|
BulletinHelper.show_error("Chat Summaries: cannot inject into this chat", fragment)
|
||||||
|
return
|
||||||
|
|
||||||
|
def fail(message: str) -> None:
|
||||||
|
injection.update(answer_markdown=f"**Error:** {message}", streaming=False)
|
||||||
|
BulletinHelper.show_error(f"Chat Summaries: {message}", fragment)
|
||||||
|
|
||||||
|
def on_fetched(messages, error):
|
||||||
|
if error:
|
||||||
|
fail(str(error))
|
||||||
|
return
|
||||||
|
if not messages:
|
||||||
|
fail("no messages found")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._stream_into(injection, fragment, dialog_id, topic_id, prompt, messages)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("on_fetched:_stream_into", exc)
|
||||||
|
fail(str(exc))
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""Provisional anchor: the newest real message the analysis will cover.
|
||||||
|
|
||||||
|
`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(
|
||||||
|
self,
|
||||||
|
injection: Any,
|
||||||
|
fragment: Any,
|
||||||
|
dialog_id: int,
|
||||||
|
topic_id: int,
|
||||||
|
prompt: str,
|
||||||
|
messages: list,
|
||||||
|
) -> None:
|
||||||
|
"""Streams reasoning, then the answer, into the already-visible card."""
|
||||||
|
from services.rich_injector import virtual_id_for
|
||||||
|
|
||||||
|
anchor_msg_id = max(int(getattr(m, "id", 0) or 0) for m in messages)
|
||||||
|
|
||||||
|
db = self._get_summary_db()
|
||||||
|
# Reserve the row now so the virtual id is stable for the whole stream.
|
||||||
|
summary_id = 0
|
||||||
|
if db is not None:
|
||||||
|
try:
|
||||||
|
summary_id = db.save(dialog_id, topic_id, anchor_msg_id, "", "", len(messages))
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("_stream_into:db.reserve", exc)
|
||||||
|
|
||||||
|
injection.reanchor(anchor_msg_id, virtual_id_for(summary_id, anchor_msg_id))
|
||||||
|
injection.update(status=f"Summarizing {len(messages)} messages…", streaming=True)
|
||||||
|
|
||||||
|
state = {"answer": "", "thinking": "", "last_push": 0.0, "mapping": None}
|
||||||
|
|
||||||
|
def rendered(key: str, final: bool = False) -> str:
|
||||||
|
"""Placeholder-substituted view of `answer` or `thinking`.
|
||||||
|
|
||||||
|
Reasoning is substituted too: the model writes {userN}/{msgN} there
|
||||||
|
as well, and leaving them raw exposes the anonymized namespace to
|
||||||
|
the reader.
|
||||||
|
|
||||||
|
While streaming, a placeholder still being received is dropped so
|
||||||
|
it never flashes as literal `{msg1`; on the final render the text
|
||||||
|
is complete and nothing is stripped.
|
||||||
|
"""
|
||||||
|
from services.transcript import deanonymize, strip_partial_placeholder
|
||||||
|
|
||||||
|
text = state[key]
|
||||||
|
if not final:
|
||||||
|
text = strip_partial_placeholder(text)
|
||||||
|
mapping = state["mapping"]
|
||||||
|
if mapping is None:
|
||||||
|
return text
|
||||||
|
return deanonymize(text, mapping, dialog_id)
|
||||||
|
|
||||||
|
def push(force: bool = False) -> None:
|
||||||
|
"""Rate-limits re-renders; each one rebuilds blocks and rebinds cells."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
if not force and now - state["last_push"] < STREAM_UPDATE_INTERVAL:
|
||||||
|
return
|
||||||
|
state["last_push"] = now
|
||||||
|
injection.update(
|
||||||
|
answer_markdown=rendered("answer", final=force),
|
||||||
|
thinking=rendered("thinking", final=force),
|
||||||
|
streaming=not force,
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_mapping(mapping) -> None:
|
||||||
|
state["mapping"] = mapping
|
||||||
|
|
||||||
|
def on_delta(kind: str, text: str) -> None:
|
||||||
|
if kind == "thinking":
|
||||||
|
state["thinking"] += text
|
||||||
|
else:
|
||||||
|
state["answer"] += text
|
||||||
|
push()
|
||||||
|
|
||||||
|
def on_done(answer: Optional[str], thinking: Optional[str], error: Optional[str]) -> None:
|
||||||
|
if error:
|
||||||
|
state["answer"] = (state["answer"] or "") + f"\n\n**Error:** {error}"
|
||||||
|
push(force=True)
|
||||||
|
BulletinHelper.show_error(f"Chat Summaries: {error}", fragment)
|
||||||
|
return
|
||||||
|
state["answer"] = answer or state["answer"]
|
||||||
|
state["thinking"] = thinking or state["thinking"]
|
||||||
|
push(force=True)
|
||||||
|
if db is not None and summary_id:
|
||||||
|
try:
|
||||||
|
# Persist real names and links, not placeholders: the
|
||||||
|
# mapping is per-request and would be gone by the time the
|
||||||
|
# summary is restored into a reopened chat.
|
||||||
|
db.save(
|
||||||
|
dialog_id, topic_id, anchor_msg_id,
|
||||||
|
rendered("answer", final=True),
|
||||||
|
rendered("thinking", final=True),
|
||||||
|
len(messages),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("_stream_into:db.save", exc)
|
||||||
|
|
||||||
|
self.dispatcher.summarize_streaming(
|
||||||
|
messages,
|
||||||
|
prompt,
|
||||||
|
on_delta,
|
||||||
|
on_done,
|
||||||
|
extra_by_id=self._fetch_reply_context(messages),
|
||||||
|
on_mapping=on_mapping,
|
||||||
|
topic_id=topic_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fetch_reply_context(self, messages: list) -> dict:
|
||||||
|
"""Reply parents that fall outside the analyzed range.
|
||||||
|
|
||||||
|
A reply whose parent was not analyzed loses its meaning, so the parent
|
||||||
|
is looked up among the messages the open chat already holds and passed
|
||||||
|
along as context (emitted with a negative id by the transcript
|
||||||
|
builder). No extra network request: anything not already loaded is
|
||||||
|
simply omitted.
|
||||||
|
"""
|
||||||
|
analyzed = {int(getattr(m, "id", 0) or 0) for m in messages}
|
||||||
|
wanted = {
|
||||||
|
int(getattr(m, "reply_to_id", 0) or 0)
|
||||||
|
for m in messages
|
||||||
|
if int(getattr(m, "reply_to_id", 0) or 0)
|
||||||
|
} - analyzed
|
||||||
|
if not wanted:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
from client_utils import get_last_fragment
|
||||||
|
from services.rich_injector import get_messages_list
|
||||||
|
|
||||||
|
messages_list = get_messages_list(get_last_fragment())
|
||||||
|
if messages_list is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
found = {}
|
||||||
|
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)
|
||||||
|
if msg_id in wanted:
|
||||||
|
formatted = self.fetcher._format(owner)
|
||||||
|
if formatted is not None:
|
||||||
|
found[msg_id] = formatted
|
||||||
|
return found
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("_fetch_reply_context", exc)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _get_account(self) -> int:
|
||||||
|
try:
|
||||||
|
from client_utils import get_selected_account
|
||||||
|
|
||||||
|
return int(get_selected_account())
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Settings helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_default_count(self) -> int:
|
||||||
|
try:
|
||||||
|
return int(self.get_setting(SETTING_DEFAULT_COUNT, str(DEFAULT_MSG_COUNT)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return DEFAULT_MSG_COUNT
|
||||||
|
|
||||||
|
def _get_default_offset(self) -> int:
|
||||||
|
try:
|
||||||
|
return int(self.get_setting(SETTING_DEFAULT_OFFSET, str(DEFAULT_MSG_OFFSET)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return DEFAULT_MSG_OFFSET
|
||||||
|
|
||||||
|
def _prompt_key(self, dialog_id: int, topic_id: int) -> str:
|
||||||
|
return PROMPT_OVERRIDE_KEY_TEMPLATE.format(dialog_id=dialog_id, topic_id=topic_id)
|
||||||
|
|
||||||
|
def _get_prompt_for(self, dialog_id: int, topic_id: int) -> str:
|
||||||
|
override = self.get_setting(self._prompt_key(dialog_id, topic_id), "")
|
||||||
|
if override:
|
||||||
|
return override
|
||||||
|
return self.get_setting(SETTING_GLOBAL_PROMPT, DEFAULT_PROMPT)
|
||||||
|
|
||||||
|
def _save_prompt_for(self, dialog_id: int, topic_id: int, prompt: str) -> None:
|
||||||
|
if prompt:
|
||||||
|
self.set_setting(self._prompt_key(dialog_id, topic_id), prompt)
|
||||||
Whitespace-only changes.
@@ -0,0 +1,62 @@
|
|||||||
|
"""Shared helpers for AI provider connectors: prompt building and HTTP calls."""
|
||||||
|
from typing import Any, List, Optional, Tuple
|
||||||
|
|
||||||
|
from config import DEFAULT_PROMPT
|
||||||
|
from services.message_fetcher import FormattedMessage
|
||||||
|
|
||||||
|
|
||||||
|
def build_chat_messages(
|
||||||
|
messages: List[FormattedMessage],
|
||||||
|
instructions: Optional[str],
|
||||||
|
include_attachments: bool = False,
|
||||||
|
extra_by_id: Optional[dict] = None,
|
||||||
|
topic_id: int = 0,
|
||||||
|
) -> Tuple[List[dict], Any]:
|
||||||
|
"""Builds the chat-completions message list and the id mapping.
|
||||||
|
|
||||||
|
Three roles, kept separate so the model is not asked to infer structure
|
||||||
|
from one blob: a fixed system prompt describing the JSON schema and the
|
||||||
|
placeholder syntax, the user's own summarization instructions, and the
|
||||||
|
anonymized JSON transcript.
|
||||||
|
"""
|
||||||
|
from services.transcript import SYSTEM_PROMPT, build_transcript
|
||||||
|
|
||||||
|
transcript, mapping = build_transcript(
|
||||||
|
messages,
|
||||||
|
include_attachments=include_attachments,
|
||||||
|
extra_by_id=extra_by_id,
|
||||||
|
topic_id=topic_id,
|
||||||
|
)
|
||||||
|
prompt = (instructions or DEFAULT_PROMPT).strip() or DEFAULT_PROMPT
|
||||||
|
return (
|
||||||
|
[
|
||||||
|
{"role": "system", "content": SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
{"role": "user", "content": transcript},
|
||||||
|
],
|
||||||
|
mapping,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderError(Exception):
|
||||||
|
"""Raised when a provider request fails (network error, bad response, missing key)."""
|
||||||
|
|
||||||
|
|
||||||
|
def http_post_json(url: str, headers: dict, payload: dict, timeout: float = 60.0) -> dict:
|
||||||
|
"""POSTs JSON and returns the parsed JSON response, raising ProviderError on failure."""
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
except ImportError:
|
||||||
|
raise ProviderError(
|
||||||
|
"the 'requests' package is not installed; add it to plugin.json requirements"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = requests.post(url, headers=headers, json=payload, timeout=timeout)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ProviderError(f"request failed: {exc}")
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise ProviderError(f"HTTP {response.status_code}: {response.text[:500]}")
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except Exception as exc:
|
||||||
|
raise ProviderError(f"invalid JSON response: {exc}")
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Runs a summarization request against the configured endpoint."""
|
||||||
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
DEFAULT_CUSTOM_MODEL,
|
||||||
|
SETTING_CUSTOM_API_KEY,
|
||||||
|
SETTING_CUSTOM_BASE_URL,
|
||||||
|
SETTING_CUSTOM_MODEL,
|
||||||
|
SETTING_INCLUDE_ATTACHMENTS,
|
||||||
|
)
|
||||||
|
from providers import streaming
|
||||||
|
from providers.base import ProviderError
|
||||||
|
from services.message_fetcher import FormattedMessage
|
||||||
|
|
||||||
|
|
||||||
|
class Dispatcher:
|
||||||
|
"""Reads endpoint settings from the plugin and streams off the UI thread."""
|
||||||
|
|
||||||
|
def __init__(self, plugin):
|
||||||
|
self.plugin = plugin
|
||||||
|
|
||||||
|
def include_attachments(self) -> bool:
|
||||||
|
value = self.plugin.get_setting(SETTING_INCLUDE_ATTACHMENTS, False)
|
||||||
|
return value in (True, "true", "True", 1, "1")
|
||||||
|
|
||||||
|
def summarize_streaming(
|
||||||
|
self,
|
||||||
|
messages: List[FormattedMessage],
|
||||||
|
instructions: Optional[str],
|
||||||
|
on_delta: Callable[[str, str], None],
|
||||||
|
on_done: Callable[[Optional[str], Optional[str], Optional[str]], None],
|
||||||
|
extra_by_id: Optional[Dict[int, Any]] = None,
|
||||||
|
on_mapping: Optional[Callable[[Any], None]] = None,
|
||||||
|
topic_id: int = 0,
|
||||||
|
) -> None:
|
||||||
|
"""Streams a summary off the UI thread.
|
||||||
|
|
||||||
|
on_delta(kind, text) fires per chunk with kind in {"thinking","answer"}.
|
||||||
|
on_mapping(mapping) fires once before the request, handing over the
|
||||||
|
anonymized-id mapping needed to de-anonymize the reply.
|
||||||
|
on_done(answer, thinking, error) fires exactly once.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def work():
|
||||||
|
try:
|
||||||
|
answer, thinking = streaming.stream_summary(
|
||||||
|
messages,
|
||||||
|
instructions,
|
||||||
|
base_url=self.plugin.get_setting(SETTING_CUSTOM_BASE_URL, ""),
|
||||||
|
api_key=self.plugin.get_setting(SETTING_CUSTOM_API_KEY, ""),
|
||||||
|
model=self.plugin.get_setting(SETTING_CUSTOM_MODEL, DEFAULT_CUSTOM_MODEL),
|
||||||
|
on_delta=on_delta,
|
||||||
|
include_attachments=self.include_attachments(),
|
||||||
|
extra_by_id=extra_by_id,
|
||||||
|
on_mapping=on_mapping,
|
||||||
|
topic_id=topic_id,
|
||||||
|
)
|
||||||
|
on_done(answer, thinking, None)
|
||||||
|
except ProviderError as exc:
|
||||||
|
on_done(None, None, str(exc))
|
||||||
|
except Exception as exc:
|
||||||
|
on_done(None, None, f"unexpected error: {exc}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from client_utils import run_on_queue
|
||||||
|
|
||||||
|
run_on_queue(work)
|
||||||
|
except ImportError:
|
||||||
|
work()
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""Server-sent-events streaming for OpenAI-compatible chat completions.
|
||||||
|
|
||||||
|
Yields incremental ("thinking", text) / ("answer", text) deltas so the caller
|
||||||
|
can render reasoning while it arrives and the answer once it starts.
|
||||||
|
|
||||||
|
Reasoning deltas are read from whichever field the endpoint uses --
|
||||||
|
`reasoning_content` (DeepSeek), `reasoning` (OpenRouter), or `<think>` tags
|
||||||
|
inlined in normal content (many local models) -- because there is no single
|
||||||
|
standard for it.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from typing import Callable, Iterator, List, Optional, Tuple
|
||||||
|
|
||||||
|
from providers.base import ProviderError, build_chat_messages
|
||||||
|
from services.message_fetcher import FormattedMessage
|
||||||
|
|
||||||
|
THINK_OPEN = "<think>"
|
||||||
|
THINK_CLOSE = "</think>"
|
||||||
|
|
||||||
|
# ("thinking" | "answer", delta_text)
|
||||||
|
Delta = Tuple[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_delta(chunk: dict) -> Delta:
|
||||||
|
"""Maps one SSE data object to a (kind, text) delta; ("", "") when empty."""
|
||||||
|
try:
|
||||||
|
delta = chunk["choices"][0].get("delta") or {}
|
||||||
|
except (KeyError, IndexError, TypeError):
|
||||||
|
return ("", "")
|
||||||
|
|
||||||
|
for key in ("reasoning_content", "reasoning"):
|
||||||
|
value = delta.get(key)
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
return ("thinking", value)
|
||||||
|
|
||||||
|
content = delta.get("content")
|
||||||
|
if isinstance(content, str) and content:
|
||||||
|
return ("answer", content)
|
||||||
|
return ("", "")
|
||||||
|
|
||||||
|
|
||||||
|
class _ThinkTagSplitter:
|
||||||
|
"""Reclassifies inline <think>...</think> content as thinking deltas.
|
||||||
|
|
||||||
|
Kept as a state machine because tags can be split across SSE chunks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._in_think = False
|
||||||
|
self._buf = ""
|
||||||
|
|
||||||
|
def feed(self, text: str) -> List[Delta]:
|
||||||
|
out: List[Delta] = []
|
||||||
|
self._buf += text
|
||||||
|
while self._buf:
|
||||||
|
if self._in_think:
|
||||||
|
idx = self._buf.find(THINK_CLOSE)
|
||||||
|
if idx < 0:
|
||||||
|
# Hold back a possible partial closing tag.
|
||||||
|
keep = _partial_tail(self._buf, THINK_CLOSE)
|
||||||
|
emit, self._buf = self._buf[: len(self._buf) - keep], self._buf[len(self._buf) - keep :]
|
||||||
|
if emit:
|
||||||
|
out.append(("thinking", emit))
|
||||||
|
return out
|
||||||
|
if idx:
|
||||||
|
out.append(("thinking", self._buf[:idx]))
|
||||||
|
self._buf = self._buf[idx + len(THINK_CLOSE) :]
|
||||||
|
self._in_think = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
idx = self._buf.find(THINK_OPEN)
|
||||||
|
if idx < 0:
|
||||||
|
keep = _partial_tail(self._buf, THINK_OPEN)
|
||||||
|
emit, self._buf = self._buf[: len(self._buf) - keep], self._buf[len(self._buf) - keep :]
|
||||||
|
if emit:
|
||||||
|
out.append(("answer", emit))
|
||||||
|
return out
|
||||||
|
if idx:
|
||||||
|
out.append(("answer", self._buf[:idx]))
|
||||||
|
self._buf = self._buf[idx + len(THINK_OPEN) :]
|
||||||
|
self._in_think = True
|
||||||
|
return out
|
||||||
|
|
||||||
|
def flush(self) -> List[Delta]:
|
||||||
|
if not self._buf:
|
||||||
|
return []
|
||||||
|
kind = "thinking" if self._in_think else "answer"
|
||||||
|
out = [(kind, self._buf)]
|
||||||
|
self._buf = ""
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _partial_tail(buf: str, tag: str) -> int:
|
||||||
|
"""Length of the trailing substring of `buf` that could start `tag`."""
|
||||||
|
for n in range(min(len(tag) - 1, len(buf)), 0, -1):
|
||||||
|
if tag.startswith(buf[-n:]):
|
||||||
|
return n
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def iter_sse_objects(lines: Iterator[bytes]) -> Iterator[dict]:
|
||||||
|
"""Parses an SSE byte-line stream into JSON objects, stopping at [DONE]."""
|
||||||
|
for raw in lines:
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
line = raw.decode("utf-8", "replace").strip() if isinstance(raw, (bytes, bytearray)) else raw.strip()
|
||||||
|
if not line or not line.startswith("data:"):
|
||||||
|
continue
|
||||||
|
payload = line[5:].strip()
|
||||||
|
if payload == "[DONE]":
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
yield json.loads(payload)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
def stream_summary(
|
||||||
|
messages: List[FormattedMessage],
|
||||||
|
instructions: Optional[str],
|
||||||
|
base_url: str,
|
||||||
|
api_key: str,
|
||||||
|
model: str,
|
||||||
|
on_delta: Callable[[str, str], None],
|
||||||
|
timeout: float = 300.0,
|
||||||
|
include_attachments: bool = False,
|
||||||
|
extra_by_id: Optional[dict] = None,
|
||||||
|
on_mapping: Optional[Callable[[object], None]] = None,
|
||||||
|
topic_id: int = 0,
|
||||||
|
) -> Tuple[str, str]:
|
||||||
|
"""Streams a completion, invoking on_delta(kind, text) per chunk.
|
||||||
|
|
||||||
|
Returns the accumulated (answer, thinking) markdown.
|
||||||
|
"""
|
||||||
|
if not base_url:
|
||||||
|
raise ProviderError("no base URL is configured (Settings > AI Endpoint)")
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
except ImportError:
|
||||||
|
raise ProviderError("the 'requests' package is not installed; add it to plugin.json requirements")
|
||||||
|
|
||||||
|
chat_messages, mapping = build_chat_messages(
|
||||||
|
messages,
|
||||||
|
instructions,
|
||||||
|
include_attachments=include_attachments,
|
||||||
|
extra_by_id=extra_by_id,
|
||||||
|
topic_id=topic_id,
|
||||||
|
)
|
||||||
|
# Handed over before the request so the caller can de-anonymize deltas as
|
||||||
|
# they stream in, not just the final answer.
|
||||||
|
if on_mapping is not None:
|
||||||
|
on_mapping(mapping)
|
||||||
|
|
||||||
|
url = base_url.rstrip("/") + "/chat/completions"
|
||||||
|
headers = {"Content-Type": "application/json", "Accept": "text/event-stream"}
|
||||||
|
if api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
payload = {"model": model, "messages": chat_messages, "stream": True}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(url, headers=headers, json=payload, timeout=timeout, stream=True)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ProviderError(f"request failed: {exc}")
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
body = ""
|
||||||
|
try:
|
||||||
|
body = response.text[:500]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise ProviderError(f"HTTP {response.status_code}: {body}")
|
||||||
|
|
||||||
|
answer_parts: List[str] = []
|
||||||
|
thinking_parts: List[str] = []
|
||||||
|
splitter = _ThinkTagSplitter()
|
||||||
|
|
||||||
|
def emit(kind: str, text: str) -> None:
|
||||||
|
if kind == "thinking":
|
||||||
|
thinking_parts.append(text)
|
||||||
|
else:
|
||||||
|
answer_parts.append(text)
|
||||||
|
on_delta(kind, text)
|
||||||
|
|
||||||
|
try:
|
||||||
|
for chunk in iter_sse_objects(response.iter_lines()):
|
||||||
|
kind, text = _extract_delta(chunk)
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
if kind == "answer":
|
||||||
|
for k, t in splitter.feed(text):
|
||||||
|
emit(k, t)
|
||||||
|
else:
|
||||||
|
emit(kind, text)
|
||||||
|
for k, t in splitter.flush():
|
||||||
|
emit(k, t)
|
||||||
|
except ProviderError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise ProviderError(f"stream failed: {exc}")
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
response.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
answer = "".join(answer_parts).strip()
|
||||||
|
thinking = "".join(thinking_parts).strip()
|
||||||
|
if not answer and not thinking:
|
||||||
|
raise ProviderError("endpoint returned an empty stream")
|
||||||
|
return answer, thinking
|
||||||
Whitespace-only changes.
@@ -0,0 +1,351 @@
|
|||||||
|
"""Fetches and formats Telegram chat history for summarization.
|
||||||
|
|
||||||
|
Uses raw TLRPC requests (TL_messages_getHistory / TL_messages_getReplies)
|
||||||
|
paginated in HISTORY_CHUNK_SIZE chunks, since exteraGram's SDK does not
|
||||||
|
expose a higher-level "get N messages with offset" helper.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
|
import diagnostics
|
||||||
|
from config import HISTORY_CHUNK_SIZE, MAX_MSG_COUNT, MAX_SINGLE_MESSAGE_CHARS, MIN_MSG_COUNT
|
||||||
|
|
||||||
|
|
||||||
|
class FormattedMessage:
|
||||||
|
"""A sanitized Telegram message ready to be included in a transcript.
|
||||||
|
|
||||||
|
Plain class, not @dataclass: dataclass() internally resolves
|
||||||
|
sys.modules[cls.__module__] to detect the KW_ONLY sentinel (CPython
|
||||||
|
3.10+), but bundled modules are deliberately never registered in
|
||||||
|
sys.modules (see tools/bundle.py) to avoid cross-plugin namespace
|
||||||
|
collisions. Using @dataclass here would crash at plugin load.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
id: int,
|
||||||
|
date: int,
|
||||||
|
sender_name: str,
|
||||||
|
text: str,
|
||||||
|
sender_id: int = 0,
|
||||||
|
sender_kind: str = "user",
|
||||||
|
reply_to_id: int = 0,
|
||||||
|
reply_highlight: str = "",
|
||||||
|
attachments: Optional[List[str]] = None,
|
||||||
|
):
|
||||||
|
self.id = id
|
||||||
|
self.date = date
|
||||||
|
self.sender_name = sender_name
|
||||||
|
self.text = text
|
||||||
|
# Real Telegram identity, kept locally so the transcript can be
|
||||||
|
# anonymized and the model's reply de-anonymized. Never sent.
|
||||||
|
self.sender_id = int(sender_id or 0)
|
||||||
|
self.sender_kind = sender_kind or "user"
|
||||||
|
self.reply_to_id = int(reply_to_id or 0)
|
||||||
|
self.reply_highlight = reply_highlight or ""
|
||||||
|
self.attachments: List[str] = list(attachments or [])
|
||||||
|
|
||||||
|
def to_transcript_line(self) -> str:
|
||||||
|
time_str = datetime.fromtimestamp(self.date, tz=timezone.utc).strftime("%H:%M")
|
||||||
|
return f"[{time_str}] {self.sender_name}: {self.text}"
|
||||||
|
|
||||||
|
|
||||||
|
class MessageFetcher:
|
||||||
|
"""Fetches up to MAX_MSG_COUNT messages from a chat/topic history."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._user_cache: Dict[int, Any] = {}
|
||||||
|
self._chat_cache: Dict[int, Any] = {}
|
||||||
|
# Strong references to in-flight RequestCallback proxies. Without this
|
||||||
|
# the proxy can be collected between send_request() and the network
|
||||||
|
# reply, and the response is dropped with no error.
|
||||||
|
self._pending: List[Any] = []
|
||||||
|
|
||||||
|
def fetch_history(
|
||||||
|
self,
|
||||||
|
dialog_id: int,
|
||||||
|
count: int,
|
||||||
|
offset: int = 0,
|
||||||
|
topic_id: int = 0,
|
||||||
|
callback: Optional[Callable[[List[FormattedMessage], Optional[str]], None]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Fetches up to `count` messages starting `offset` messages back from
|
||||||
|
the newest, in a chat/group/channel or a specific forum topic.
|
||||||
|
|
||||||
|
Calls `callback(messages, error)` on completion; `error` is None on
|
||||||
|
success (messages may still be a shorter list if history ran out).
|
||||||
|
"""
|
||||||
|
target_count = max(MIN_MSG_COUNT, min(MAX_MSG_COUNT, count))
|
||||||
|
target_offset = max(0, offset)
|
||||||
|
self._user_cache.clear()
|
||||||
|
self._chat_cache.clear()
|
||||||
|
self._fetch_paginated(
|
||||||
|
dialog_id=dialog_id,
|
||||||
|
target_count=target_count,
|
||||||
|
topic_id=topic_id,
|
||||||
|
offset_id=0,
|
||||||
|
add_offset=target_offset,
|
||||||
|
accumulated=[],
|
||||||
|
callback=callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fetch_paginated(
|
||||||
|
self,
|
||||||
|
dialog_id: int,
|
||||||
|
target_count: int,
|
||||||
|
topic_id: int,
|
||||||
|
offset_id: int,
|
||||||
|
add_offset: int,
|
||||||
|
accumulated: List[Any],
|
||||||
|
callback: Optional[Callable[[List[FormattedMessage], Optional[str]], None]],
|
||||||
|
) -> None:
|
||||||
|
remaining = target_count - len(accumulated)
|
||||||
|
if remaining <= 0:
|
||||||
|
self._finish(accumulated, callback)
|
||||||
|
return
|
||||||
|
|
||||||
|
chunk_limit = min(HISTORY_CHUNK_SIZE, remaining)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from client_utils import RequestCallback, get_messages_controller, send_request
|
||||||
|
from org.telegram.tgnet import TLRPC
|
||||||
|
|
||||||
|
messages_controller = get_messages_controller()
|
||||||
|
if messages_controller is None:
|
||||||
|
raise RuntimeError("messages controller unavailable")
|
||||||
|
peer = self._resolve_input_peer(messages_controller, TLRPC, dialog_id)
|
||||||
|
if peer is None:
|
||||||
|
raise RuntimeError(f"could not resolve peer for dialog {dialog_id}")
|
||||||
|
|
||||||
|
if topic_id:
|
||||||
|
req = TLRPC.TL_messages_getReplies()
|
||||||
|
req.peer = peer
|
||||||
|
req.msg_id = topic_id
|
||||||
|
else:
|
||||||
|
req = TLRPC.TL_messages_getHistory()
|
||||||
|
req.peer = peer
|
||||||
|
req.offset_id = offset_id
|
||||||
|
req.offset_date = 0
|
||||||
|
req.add_offset = add_offset if offset_id == 0 else 0
|
||||||
|
req.limit = chunk_limit
|
||||||
|
req.max_id = 0
|
||||||
|
req.min_id = 0
|
||||||
|
req.hash = 0
|
||||||
|
|
||||||
|
# Filled in just before send_request; on_complete drops the strong
|
||||||
|
# reference through it once the reply arrives.
|
||||||
|
proxy_box: List[Any] = []
|
||||||
|
|
||||||
|
def on_complete(response, error):
|
||||||
|
# Runs on a Java callback thread, where an escaping exception
|
||||||
|
# is swallowed by the JVM and looks identical to a response
|
||||||
|
# that never arrived, so everything here is guarded.
|
||||||
|
try:
|
||||||
|
self._release(proxy_box)
|
||||||
|
self._handle_response(
|
||||||
|
response, error, accumulated, callback, dialog_id,
|
||||||
|
target_count, topic_id, add_offset, chunk_limit,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("fetcher.on_complete", exc)
|
||||||
|
self._fail(str(exc), callback)
|
||||||
|
|
||||||
|
# Two requirements, both learned the hard way:
|
||||||
|
# 1. The callback MUST be wrapped in RequestCallback. send_request
|
||||||
|
# hands it to Java as a listener; a bare Python function is
|
||||||
|
# accepted but never invoked and the response is dropped.
|
||||||
|
# 2. A strong reference to that wrapper MUST outlive the call, or
|
||||||
|
# it can be collected before the reply arrives.
|
||||||
|
proxy = RequestCallback(on_complete)
|
||||||
|
proxy_box.append(proxy)
|
||||||
|
self._pending.append(proxy)
|
||||||
|
send_request(req, proxy)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("fetcher._fetch_paginated", exc)
|
||||||
|
if accumulated:
|
||||||
|
self._finish(accumulated, callback)
|
||||||
|
else:
|
||||||
|
self._fail(str(exc), callback)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _java_list_to_python(value: Any) -> List[Any]:
|
||||||
|
"""Copies a Java ArrayList into a Python list.
|
||||||
|
|
||||||
|
Chaquopy exposes java.util.ArrayList without the iterator protocol, so
|
||||||
|
list()/for-in raise TypeError; only size()/get(i) are available.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, list):
|
||||||
|
return list(value)
|
||||||
|
try:
|
||||||
|
return [value.get(i) for i in range(value.size())]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _handle_response(
|
||||||
|
self,
|
||||||
|
response: Any,
|
||||||
|
error: Any,
|
||||||
|
accumulated: List[Any],
|
||||||
|
callback,
|
||||||
|
dialog_id: int,
|
||||||
|
target_count: int,
|
||||||
|
topic_id: int,
|
||||||
|
add_offset: int,
|
||||||
|
chunk_limit: int,
|
||||||
|
) -> None:
|
||||||
|
"""Consumes one history page and either finishes or fetches the next."""
|
||||||
|
if error:
|
||||||
|
err_text = getattr(error, "text", str(error))
|
||||||
|
if accumulated:
|
||||||
|
self._finish(accumulated, callback)
|
||||||
|
else:
|
||||||
|
self._fail(f"Telegram API error: {err_text}", callback)
|
||||||
|
return
|
||||||
|
|
||||||
|
raw_messages = self._java_list_to_python(getattr(response, "messages", None))
|
||||||
|
if not raw_messages:
|
||||||
|
self._finish(accumulated, callback)
|
||||||
|
return
|
||||||
|
|
||||||
|
for user in self._java_list_to_python(getattr(response, "users", None)):
|
||||||
|
self._user_cache[user.id] = user
|
||||||
|
for chat in self._java_list_to_python(getattr(response, "chats", None)):
|
||||||
|
self._chat_cache[chat.id] = chat
|
||||||
|
|
||||||
|
accumulated.extend(raw_messages)
|
||||||
|
|
||||||
|
# A short page means history ran out, so stop rather than loop forever.
|
||||||
|
if len(raw_messages) < chunk_limit:
|
||||||
|
self._finish(accumulated, callback)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._fetch_paginated(
|
||||||
|
dialog_id=dialog_id,
|
||||||
|
target_count=target_count,
|
||||||
|
topic_id=topic_id,
|
||||||
|
offset_id=int(raw_messages[-1].id),
|
||||||
|
add_offset=0,
|
||||||
|
accumulated=accumulated,
|
||||||
|
callback=callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _release(self, proxy_box: List[Any]) -> None:
|
||||||
|
"""Drops the strong reference to a settled RequestCallback proxy."""
|
||||||
|
for proxy in proxy_box:
|
||||||
|
try:
|
||||||
|
self._pending.remove(proxy)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
proxy_box.clear()
|
||||||
|
|
||||||
|
def _finish(self, raw_messages: List[Any], callback) -> None:
|
||||||
|
formatted = [m for m in (self._format(raw) for raw in raw_messages) if m is not None]
|
||||||
|
formatted.reverse() # oldest first, for a natural reading transcript
|
||||||
|
if callback:
|
||||||
|
# Guarded: _finish runs on a Java callback thread, so a consumer
|
||||||
|
# exception would otherwise vanish and look like a lost response.
|
||||||
|
try:
|
||||||
|
callback(formatted, None)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("fetcher._finish.callback", exc)
|
||||||
|
|
||||||
|
def _fail(self, message: str, callback) -> None:
|
||||||
|
if callback:
|
||||||
|
try:
|
||||||
|
callback([], message)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("fetcher._fail.callback", exc)
|
||||||
|
|
||||||
|
def _format(self, raw: Any) -> Optional[FormattedMessage]:
|
||||||
|
try:
|
||||||
|
from services.transcript import describe_attachments
|
||||||
|
|
||||||
|
text = getattr(raw, "message", "") or ""
|
||||||
|
attachments = describe_attachments(raw)
|
||||||
|
# A message with no text but with media is still meaningful once
|
||||||
|
# attachments are listed, so keep it with an empty text field.
|
||||||
|
if not text and not attachments and getattr(raw, "media", None) is None:
|
||||||
|
return None
|
||||||
|
if len(text) > MAX_SINGLE_MESSAGE_CHARS:
|
||||||
|
text = text[:MAX_SINGLE_MESSAGE_CHARS] + "…"
|
||||||
|
|
||||||
|
reply_to_id, reply_highlight = self._resolve_reply(raw)
|
||||||
|
sender_id, sender_kind = self._resolve_sender(raw)
|
||||||
|
return FormattedMessage(
|
||||||
|
id=raw.id,
|
||||||
|
date=raw.date,
|
||||||
|
sender_name=self._resolve_sender_name(raw),
|
||||||
|
text=text,
|
||||||
|
sender_id=sender_id,
|
||||||
|
sender_kind=sender_kind,
|
||||||
|
reply_to_id=reply_to_id,
|
||||||
|
reply_highlight=reply_highlight,
|
||||||
|
attachments=attachments,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("fetcher._format", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_sender(raw: Any) -> tuple:
|
||||||
|
"""(real sender id, "user"|"chat") used locally to build mentions.
|
||||||
|
|
||||||
|
Channels and group senders are not users: opening one with tg://user
|
||||||
|
reports "user not found", so the kind has to travel with the id.
|
||||||
|
"""
|
||||||
|
from_id = getattr(raw, "from_id", None)
|
||||||
|
if from_id is None:
|
||||||
|
# A channel post carries no from_id; the sender is the channel.
|
||||||
|
peer = getattr(raw, "peer_id", None)
|
||||||
|
channel_id = int(getattr(peer, "channel_id", 0) or 0) if peer is not None else 0
|
||||||
|
return (channel_id, "chat") if channel_id else (0, "user")
|
||||||
|
user_id = int(getattr(from_id, "user_id", 0) or 0)
|
||||||
|
if user_id:
|
||||||
|
return user_id, "user"
|
||||||
|
for attribute in ("channel_id", "chat_id"):
|
||||||
|
value = int(getattr(from_id, attribute, 0) or 0)
|
||||||
|
if value:
|
||||||
|
return value, "chat"
|
||||||
|
return 0, "user"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_reply(raw: Any) -> tuple:
|
||||||
|
"""(replied-to message id, quoted fragment) for a reply, else (0, "")."""
|
||||||
|
header = getattr(raw, "reply_to", None)
|
||||||
|
if header is None:
|
||||||
|
return 0, ""
|
||||||
|
reply_to_id = int(getattr(header, "reply_to_msg_id", 0) or 0)
|
||||||
|
highlight = getattr(header, "quote_text", None) or ""
|
||||||
|
return reply_to_id, str(highlight)
|
||||||
|
|
||||||
|
def _resolve_sender_name(self, raw: Any) -> str:
|
||||||
|
try:
|
||||||
|
from_id = getattr(raw, "from_id", None)
|
||||||
|
if from_id is not None:
|
||||||
|
user_id = getattr(from_id, "user_id", 0)
|
||||||
|
if user_id and user_id in self._user_cache:
|
||||||
|
user = self._user_cache[user_id]
|
||||||
|
first = getattr(user, "first_name", "") or ""
|
||||||
|
last = getattr(user, "last_name", "") or ""
|
||||||
|
name = (first + " " + last).strip()
|
||||||
|
return name or f"User {user_id}"
|
||||||
|
channel_id = getattr(from_id, "channel_id", 0)
|
||||||
|
if channel_id and channel_id in self._chat_cache:
|
||||||
|
chat = self._chat_cache[channel_id]
|
||||||
|
return getattr(chat, "title", None) or f"Channel {channel_id}"
|
||||||
|
chat_id = getattr(from_id, "chat_id", 0)
|
||||||
|
if chat_id and chat_id in self._chat_cache:
|
||||||
|
chat = self._chat_cache[chat_id]
|
||||||
|
return getattr(chat, "title", None) or f"Chat {chat_id}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_input_peer(messages_controller: Any, tlrpc: Any, dialog_id: int) -> Any:
|
||||||
|
try:
|
||||||
|
return messages_controller.getInputPeer(dialog_id)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
@@ -0,0 +1,567 @@
|
|||||||
|
"""Client-side injection of summary messages rendered as RichMessage.
|
||||||
|
|
||||||
|
A summary is not a real Telegram message: it is a locally built
|
||||||
|
`TLRPC.TL_message` carrying `rich_message`, attributed to a mock sender that
|
||||||
|
exists only in this client's `MessagesController` cache. It is inserted into the
|
||||||
|
open `ChatActivity`'s message list right after the newest analyzed message.
|
||||||
|
|
||||||
|
Verified against exteraGram 12.5.x (dex inspection):
|
||||||
|
- `Message.rich_message` + `flags2 |= 1 << 12` makes `MessageObject.setType`
|
||||||
|
return 36, which is the gate for `RichMessageLayout`.
|
||||||
|
- `RichMessage.part` makes the layout fade its bottom edge and hide the
|
||||||
|
timestamp, i.e. the app's own "still streaming" presentation.
|
||||||
|
- The mock sender id MUST be positive: negative ids are chat/channel space, so
|
||||||
|
`getUser()` can never resolve one and the cell falls back to "DELETED".
|
||||||
|
"""
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import diagnostics
|
||||||
|
from services.rich_markdown import build_rich_message
|
||||||
|
|
||||||
|
# Above Telegram's issued id range, so this user exists only locally.
|
||||||
|
MOCK_SENDER_ID = 8000000000000001
|
||||||
|
MOCK_SENDER_NAME = "AI Summary"
|
||||||
|
MOCK_SENDER_USERNAME = "ai_summary"
|
||||||
|
|
||||||
|
FLAGS2_RICH_MESSAGE = 1 << 12
|
||||||
|
|
||||||
|
# Telegram's animated robot custom emoji, used as the mock sender's status.
|
||||||
|
ROBOT_EMOJI_DOCUMENT_ID = 5397634513026043777
|
||||||
|
|
||||||
|
|
||||||
|
def _tlrpc():
|
||||||
|
from org.telegram.tgnet import TLRPC
|
||||||
|
|
||||||
|
return TLRPC
|
||||||
|
|
||||||
|
|
||||||
|
def virtual_id_for(summary_id: int, anchor_msg_id: int) -> int:
|
||||||
|
"""Deterministic negative id, so re-injection is idempotent and detectable."""
|
||||||
|
return -abs(int(anchor_msg_id) * 1000 + (int(summary_id) % 1000)) or -1
|
||||||
|
|
||||||
|
|
||||||
|
def register_mock_sender(account: int) -> Optional[Any]:
|
||||||
|
"""Puts the mock sender into the local user cache so the cell renders a name."""
|
||||||
|
try:
|
||||||
|
TLRPC = _tlrpc()
|
||||||
|
from org.telegram.messenger import MessagesController
|
||||||
|
|
||||||
|
user = TLRPC.TL_user()
|
||||||
|
user.id = MOCK_SENDER_ID
|
||||||
|
user.first_name = MOCK_SENDER_NAME
|
||||||
|
user.last_name = ""
|
||||||
|
user.username = MOCK_SENDER_USERNAME
|
||||||
|
user.bot = True
|
||||||
|
user.verified = True
|
||||||
|
user.deleted = False
|
||||||
|
user.access_hash = 0
|
||||||
|
_attach_robot_status(user, account, TLRPC)
|
||||||
|
MessagesController.getInstance(account).putUser(user, False)
|
||||||
|
return user
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("injector.register_mock_sender", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_robot_status(user: Any, account: int, TLRPC: Any) -> None:
|
||||||
|
try:
|
||||||
|
from org.telegram.messenger import AnimatedEmojiDrawable
|
||||||
|
|
||||||
|
if AnimatedEmojiDrawable.findDocument(account, ROBOT_EMOJI_DOCUMENT_ID) is None:
|
||||||
|
return
|
||||||
|
status = TLRPC.TL_emojiStatus()
|
||||||
|
status.document_id = ROBOT_EMOJI_DOCUMENT_ID
|
||||||
|
user.emoji_status = status
|
||||||
|
except Exception:
|
||||||
|
# Purely cosmetic; a missing emoji pack must not break injection.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def peer_for_dialog(dialog_id: int) -> Any:
|
||||||
|
"""Builds the Peer subtype matching the dialog kind.
|
||||||
|
|
||||||
|
The subtype matters: `MessageObject.isOutOwner` inspects it, and a
|
||||||
|
`TL_peerUser` in a group makes the summary render as an outgoing message.
|
||||||
|
"""
|
||||||
|
TLRPC = _tlrpc()
|
||||||
|
dialog_id = int(dialog_id)
|
||||||
|
if dialog_id > 0:
|
||||||
|
peer = TLRPC.TL_peerUser()
|
||||||
|
peer.user_id = dialog_id
|
||||||
|
return peer
|
||||||
|
if dialog_id <= -1000000000000:
|
||||||
|
peer = TLRPC.TL_peerChannel()
|
||||||
|
peer.channel_id = -dialog_id - 1000000000000
|
||||||
|
return peer
|
||||||
|
peer = TLRPC.TL_peerChat()
|
||||||
|
peer.chat_id = -dialog_id
|
||||||
|
return peer
|
||||||
|
|
||||||
|
|
||||||
|
def build_summary_message(
|
||||||
|
account: int,
|
||||||
|
dialog_id: int,
|
||||||
|
topic_id: int,
|
||||||
|
anchor_msg_id: int,
|
||||||
|
virtual_id: int,
|
||||||
|
answer_markdown: str,
|
||||||
|
thinking: str = "",
|
||||||
|
streaming: bool = False,
|
||||||
|
status: str = "",
|
||||||
|
) -> Optional[Any]:
|
||||||
|
"""Builds the local MessageObject for a summary, or None on failure."""
|
||||||
|
try:
|
||||||
|
import time
|
||||||
|
|
||||||
|
TLRPC = _tlrpc()
|
||||||
|
from org.telegram.messenger import MessageObject
|
||||||
|
|
||||||
|
msg = TLRPC.TL_message()
|
||||||
|
msg.id = int(virtual_id)
|
||||||
|
msg.dialog_id = int(dialog_id)
|
||||||
|
msg.date = int(time.time())
|
||||||
|
msg.out = False
|
||||||
|
msg.unread = False
|
||||||
|
msg.post = False
|
||||||
|
msg.flags = 0
|
||||||
|
msg.message = ""
|
||||||
|
msg.peer_id = peer_for_dialog(dialog_id)
|
||||||
|
|
||||||
|
sender = TLRPC.TL_peerUser()
|
||||||
|
sender.user_id = MOCK_SENDER_ID
|
||||||
|
msg.from_id = sender
|
||||||
|
|
||||||
|
# Reply header points at the newest analyzed message, so the summary
|
||||||
|
# visually attaches to the range it covers and taps navigate there.
|
||||||
|
if anchor_msg_id:
|
||||||
|
try:
|
||||||
|
header = TLRPC.TL_messageReplyHeader()
|
||||||
|
header.reply_to_msg_id = int(anchor_msg_id)
|
||||||
|
if topic_id:
|
||||||
|
header.reply_to_top_id = int(topic_id)
|
||||||
|
msg.reply_to = header
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("injector.reply_header", exc)
|
||||||
|
|
||||||
|
msg.rich_message = build_rich_message(answer_markdown, thinking, streaming, status)
|
||||||
|
msg.flags2 = int(getattr(msg, "flags2", 0) or 0) | FLAGS2_RICH_MESSAGE
|
||||||
|
|
||||||
|
message_object = MessageObject(account, msg, True, True)
|
||||||
|
# Pin attribution rather than let it be derived: in Saved Messages
|
||||||
|
# isOutOwner() returns true for anything without forward info.
|
||||||
|
message_object.isOutOwnerCached = False
|
||||||
|
return message_object
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("injector.build_summary_message", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_messages_list(chat_activity: Any) -> Optional[Any]:
|
||||||
|
"""Returns the ChatActivity's live ArrayList<MessageObject>, or None."""
|
||||||
|
try:
|
||||||
|
from hook_utils import get_private_field
|
||||||
|
|
||||||
|
candidate = getattr(chat_activity, "messages", None) or get_private_field(chat_activity, "messages")
|
||||||
|
if candidate is not None and hasattr(candidate, "size"):
|
||||||
|
return candidate
|
||||||
|
diagnostics.note(
|
||||||
|
f"injector.get_messages_list: unusable candidate={type(candidate).__name__ if candidate is not None else None}"
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("injector.get_messages_list", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_chat_adapter(chat_activity: Any) -> Optional[Any]:
|
||||||
|
try:
|
||||||
|
from hook_utils import get_private_field
|
||||||
|
|
||||||
|
return getattr(chat_activity, "chatAdapter", None) or get_private_field(chat_activity, "chatAdapter")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_index_for_anchor(messages_list: Any, anchor_msg_id: int) -> int:
|
||||||
|
"""Index at which to insert so the summary sits directly after the anchor.
|
||||||
|
|
||||||
|
ChatActivity.messages is newest-first, so "directly after the anchor"
|
||||||
|
chronologically means "immediately before it" in list order.
|
||||||
|
|
||||||
|
The anchor is the newest message that was actually analyzed, which is not
|
||||||
|
the newest message in the chat whenever a non-zero offset was used. If the
|
||||||
|
anchor itself is not loaded (paged out, or never fetched into this view),
|
||||||
|
fall back to ordering by id rather than to index 0: index 0 is the bottom
|
||||||
|
of the chat and would place the card after messages the summary never saw.
|
||||||
|
"""
|
||||||
|
anchor_msg_id = int(anchor_msg_id)
|
||||||
|
try:
|
||||||
|
size = messages_list.size()
|
||||||
|
for i in range(size):
|
||||||
|
owner = getattr(messages_list.get(i), "messageOwner", None)
|
||||||
|
if owner is not None and int(getattr(owner, "id", 0) or 0) == anchor_msg_id:
|
||||||
|
return i
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# analyzed range. Injected cards carry negative ids and are skipped so
|
||||||
|
# they cannot drag the position around.
|
||||||
|
for i in range(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 0 < msg_id <= anchor_msg_id:
|
||||||
|
return i
|
||||||
|
# Every loaded message is newer than the anchor, so the analyzed range
|
||||||
|
# ends above everything here: the card belongs at the very top.
|
||||||
|
return size
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("injector.find_index_for_anchor", exc)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def find_existing(messages_list: Any, virtual_id: int) -> int:
|
||||||
|
"""Index of an already-injected message with this virtual id, else -1."""
|
||||||
|
try:
|
||||||
|
size = messages_list.size()
|
||||||
|
for i in range(size):
|
||||||
|
owner = getattr(messages_list.get(i), "messageOwner", None)
|
||||||
|
if owner is not None and int(getattr(owner, "id", 0) or 0) == int(virtual_id):
|
||||||
|
return i
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
def notify_adapter(chat_activity: Any) -> None:
|
||||||
|
adapter = get_chat_adapter(chat_activity)
|
||||||
|
if adapter is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def run():
|
||||||
|
try:
|
||||||
|
adapter.notifyDataSetChanged()
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("injector.notifyDataSetChanged", exc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from android_utils import run_on_ui_thread
|
||||||
|
|
||||||
|
run_on_ui_thread(run)
|
||||||
|
except Exception:
|
||||||
|
run()
|
||||||
|
|
||||||
|
|
||||||
|
class SummaryInjection:
|
||||||
|
"""A single injected summary message, updatable in place while streaming."""
|
||||||
|
|
||||||
|
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.account = int(account)
|
||||||
|
self.dialog_id = int(dialog_id)
|
||||||
|
self.topic_id = int(topic_id)
|
||||||
|
self.anchor_msg_id = int(anchor_msg_id)
|
||||||
|
self.virtual_id = int(virtual_id)
|
||||||
|
self.message_object: Optional[Any] = None
|
||||||
|
# Every id this injection has ever placed in the list. reanchor()
|
||||||
|
# changes the id mid-flight, so an update must be able to find and
|
||||||
|
# remove the card under its previous id instead of adding a second one.
|
||||||
|
self._used_ids = {int(virtual_id)}
|
||||||
|
|
||||||
|
def insert(
|
||||||
|
self,
|
||||||
|
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.dialog_id,
|
||||||
|
self.topic_id,
|
||||||
|
self.anchor_msg_id,
|
||||||
|
self.virtual_id,
|
||||||
|
answer_markdown,
|
||||||
|
thinking,
|
||||||
|
streaming,
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
if message_object is None:
|
||||||
|
return False
|
||||||
|
self.message_object = message_object
|
||||||
|
|
||||||
|
existing = find_existing(messages_list, self.virtual_id)
|
||||||
|
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
|
||||||
|
|
||||||
|
def scroll_into_view(self) -> bool:
|
||||||
|
"""Scrolls the chat so the summary card is visible.
|
||||||
|
|
||||||
|
Targets the anchor, not the card: the anchor is a real message the app
|
||||||
|
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:
|
||||||
|
self.chat_activity.scrollToMessageId(
|
||||||
|
int(self.anchor_msg_id), 0, False, 0, True, 0
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("injector.scroll_into_view", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def update(
|
||||||
|
self,
|
||||||
|
answer_markdown: str = "",
|
||||||
|
thinking: str = "",
|
||||||
|
streaming: bool = True,
|
||||||
|
status: str = "",
|
||||||
|
) -> None:
|
||||||
|
"""Re-renders the card in place, without rebuilding the whole list.
|
||||||
|
|
||||||
|
Mutating rich_message alone is not enough: MessageObject caches
|
||||||
|
messageText, type, and its measured layout, and ChatMessageCell keeps
|
||||||
|
its own cached sizes, so a bare swap renders as an empty bubble.
|
||||||
|
|
||||||
|
The app's own live-update path (ChatActivity.applyFullRichMessageLocally)
|
||||||
|
does exactly three things: set rich_message, null richLayout, then call
|
||||||
|
chatMessageCellDelegate.forceUpdate(cell, true, true). That repaints the
|
||||||
|
single affected cell and is what keeps a streaming card visually stable.
|
||||||
|
notifyDataSetChanged() rebinds every row instead, which is what made the
|
||||||
|
card flicker and lose scroll position on each delta; it is only used as
|
||||||
|
a fallback when the live cell cannot be found.
|
||||||
|
"""
|
||||||
|
if self.message_object is None:
|
||||||
|
self.insert(answer_markdown, thinking, streaming, status)
|
||||||
|
return
|
||||||
|
|
||||||
|
replacement = build_summary_message(
|
||||||
|
self.account,
|
||||||
|
self.dialog_id,
|
||||||
|
self.topic_id,
|
||||||
|
self.anchor_msg_id,
|
||||||
|
self.virtual_id,
|
||||||
|
answer_markdown,
|
||||||
|
thinking,
|
||||||
|
streaming,
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
if replacement is None:
|
||||||
|
return
|
||||||
|
self.message_object = replacement
|
||||||
|
|
||||||
|
def run():
|
||||||
|
try:
|
||||||
|
messages_list = get_messages_list(self.chat_activity)
|
||||||
|
inserted = False
|
||||||
|
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)
|
||||||
|
if index >= 0:
|
||||||
|
messages_list.set(index, replacement)
|
||||||
|
else:
|
||||||
|
index = find_index_for_anchor(messages_list, self.anchor_msg_id)
|
||||||
|
messages_list.add(index, replacement)
|
||||||
|
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
|
||||||
|
# changed and only a full rebind is correct.
|
||||||
|
if inserted or not self._force_update_cell(replacement):
|
||||||
|
adapter = get_chat_adapter(self.chat_activity)
|
||||||
|
if adapter is not None:
|
||||||
|
adapter.notifyDataSetChanged()
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("injector.update", exc)
|
||||||
|
|
||||||
|
_on_ui(run)
|
||||||
|
|
||||||
|
def _force_update_cell(self, message_object: Any) -> bool:
|
||||||
|
"""Repaints just this card's cell. Returns False if it is not on screen.
|
||||||
|
|
||||||
|
Mirrors ChatActivity.applyFullRichMessageLocally: locate the live
|
||||||
|
ChatMessageCell bound to this message and hand it to
|
||||||
|
chatMessageCellDelegate.forceUpdate, which remeasures and redraws that
|
||||||
|
one row instead of rebinding the whole list.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from hook_utils import get_private_field
|
||||||
|
|
||||||
|
delegate = getattr(self.chat_activity, "chatMessageCellDelegate", None) or get_private_field(
|
||||||
|
self.chat_activity, "chatMessageCellDelegate"
|
||||||
|
)
|
||||||
|
list_view = getattr(self.chat_activity, "chatListView", None) or get_private_field(
|
||||||
|
self.chat_activity, "chatListView"
|
||||||
|
)
|
||||||
|
if delegate is None or list_view is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for i in range(list_view.getChildCount()):
|
||||||
|
child = list_view.getChildAt(i)
|
||||||
|
if child is None or not hasattr(child, "getMessageObject"):
|
||||||
|
continue
|
||||||
|
bound = child.getMessageObject()
|
||||||
|
owner = getattr(bound, "messageOwner", None) if bound is not None else None
|
||||||
|
if owner is None or int(getattr(owner, "id", 0) or 0) != self.virtual_id:
|
||||||
|
continue
|
||||||
|
# setMessageObject(messageObject, groupedMessages, pinnedBottom,
|
||||||
|
# pinnedTop, firstInChat) -- verified arity against the APK.
|
||||||
|
child.setMessageObject(message_object, None, False, False, False)
|
||||||
|
delegate.forceUpdate(child, False, True)
|
||||||
|
child.invalidate()
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("injector._force_update_cell", exc)
|
||||||
|
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
|
||||||
|
the anchor and the DB row id only become known later. Moving it keeps
|
||||||
|
the promise that a summary sits directly after its newest analyzed
|
||||||
|
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
|
||||||
|
old_virtual_id = self.virtual_id
|
||||||
|
self.anchor_msg_id = anchor_msg_id
|
||||||
|
self.virtual_id = virtual_id
|
||||||
|
self._used_ids.add(virtual_id)
|
||||||
|
if message_object is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
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
|
||||||
|
try:
|
||||||
|
header = _tlrpc().TL_messageReplyHeader()
|
||||||
|
header.reply_to_msg_id = anchor_msg_id
|
||||||
|
if self.topic_id:
|
||||||
|
header.reply_to_top_id = int(self.topic_id)
|
||||||
|
owner.reply_to = header
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("injector.reanchor.reply_header", exc)
|
||||||
|
|
||||||
|
if messages_list is not None and current >= 0:
|
||||||
|
messages_list.remove(current)
|
||||||
|
# A card may already sit under the new id -- e.g. the
|
||||||
|
# restore hook re-injected this summary's saved row. Claim
|
||||||
|
# that id rather than ending up with two cards.
|
||||||
|
duplicate = find_existing(messages_list, virtual_id)
|
||||||
|
if duplicate >= 0:
|
||||||
|
messages_list.remove(duplicate)
|
||||||
|
target = find_index_for_anchor(messages_list, anchor_msg_id)
|
||||||
|
messages_list.add(target, message_object)
|
||||||
|
adapter = get_chat_adapter(self.chat_activity)
|
||||||
|
if adapter is not None:
|
||||||
|
adapter.notifyDataSetChanged()
|
||||||
|
# The card just moved to the real end of the analyzed range,
|
||||||
|
# which may be somewhere else entirely, so follow it there.
|
||||||
|
self.scroll_into_view()
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("injector.reanchor", exc)
|
||||||
|
|
||||||
|
_on_ui(run)
|
||||||
|
|
||||||
|
|
||||||
|
def _on_ui(fn) -> None:
|
||||||
|
try:
|
||||||
|
from android_utils import run_on_ui_thread
|
||||||
|
|
||||||
|
run_on_ui_thread(fn)
|
||||||
|
except Exception:
|
||||||
|
fn()
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
messages_list = get_messages_list(chat_activity)
|
||||||
|
if messages_list is None or not summaries:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
register_mock_sender(account)
|
||||||
|
pending = []
|
||||||
|
for summary in summaries:
|
||||||
|
virtual_id = virtual_id_for(summary.id, summary.anchor_msg_id)
|
||||||
|
if find_existing(messages_list, virtual_id) >= 0:
|
||||||
|
continue
|
||||||
|
message_object = build_summary_message(
|
||||||
|
account,
|
||||||
|
dialog_id,
|
||||||
|
topic_id,
|
||||||
|
summary.anchor_msg_id,
|
||||||
|
virtual_id,
|
||||||
|
summary.text,
|
||||||
|
summary.thinking,
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
if message_object is not None:
|
||||||
|
pending.append((summary.anchor_msg_id, message_object))
|
||||||
|
|
||||||
|
if not pending:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Newest anchor first: each insert shifts everything below it, so placing
|
||||||
|
# the newest card first keeps the remaining lookups pointing at unmoved
|
||||||
|
# rows. The index is recomputed per card against the live list anyway.
|
||||||
|
pending.sort(key=lambda item: item[0], reverse=True)
|
||||||
|
|
||||||
|
def run():
|
||||||
|
try:
|
||||||
|
for anchor_msg_id, message_object in pending:
|
||||||
|
messages_list.add(find_index_for_anchor(messages_list, anchor_msg_id), message_object)
|
||||||
|
adapter = get_chat_adapter(chat_activity)
|
||||||
|
if adapter is not None:
|
||||||
|
adapter.notifyDataSetChanged()
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("injector.restore", exc)
|
||||||
|
|
||||||
|
_on_ui(run)
|
||||||
|
return len(pending)
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
"""Markdown -> RichMessage block serializer.
|
||||||
|
|
||||||
|
The app's own `RichMessageConvert.blocksFromCharSequence` works off Android
|
||||||
|
`Spanned` spans (CodeHighlighting$Span / QuoteSpan), not markdown source, so it
|
||||||
|
cannot parse markdown text for us. This module converts markdown directly into
|
||||||
|
`TL_iv` page blocks, which is what `RichMessageLayout` renders.
|
||||||
|
|
||||||
|
Block mapping:
|
||||||
|
# / ## / ###+ -> pageBlockHeading1 / Heading2 / Heading3
|
||||||
|
```lang ... ``` -> pageBlockPreformatted (language carried through)
|
||||||
|
> quote -> pageBlockBlockquote
|
||||||
|
- / * / + item -> pageBlockList (ordered=False)
|
||||||
|
1. item -> pageBlockOrderedList
|
||||||
|
--- -> pageBlockDivider
|
||||||
|
paragraph -> pageBlockParagraph
|
||||||
|
Inline: **bold**, *italic*/_italic_, `code`, ~~strike~~, [text](url).
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
|
# Title of the collapsed reasoning container; tapping it reveals or hides the
|
||||||
|
# model's reasoning once the answer has started arriving.
|
||||||
|
THINKING_TITLE = "Reasoning"
|
||||||
|
|
||||||
|
_FENCE_RE = re.compile(r"^\s*```+\s*([A-Za-z0-9_+#.-]*)\s*$")
|
||||||
|
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$")
|
||||||
|
_QUOTE_RE = re.compile(r"^\s{0,3}>\s?(.*)$")
|
||||||
|
_BULLET_RE = re.compile(r"^\s{0,3}[-*+]\s+(.*)$")
|
||||||
|
_ORDERED_RE = re.compile(r"^\s{0,3}(\d{1,9})[.)]\s+(.*)$")
|
||||||
|
_RULE_RE = re.compile(r"^\s{0,3}(?:-\s*-\s*-[-\s]*|\*\s*\*\s*\*[*\s]*|_\s*_\s*_[_\s]*)$")
|
||||||
|
|
||||||
|
# Inline markers, longest-first so ** wins over *.
|
||||||
|
_INLINE_RE = re.compile(
|
||||||
|
r"(?P<code>`+)(?P<code_body>.+?)(?P=code)"
|
||||||
|
r"|\[(?P<link_text>[^\]]+)\]\((?P<link_url>[^)\s]+)\)"
|
||||||
|
r"|(?P<bold>\*\*|__)(?P<bold_body>.+?)(?P=bold)"
|
||||||
|
r"|(?P<strike>~~)(?P<strike_body>.+?)(?P=strike)"
|
||||||
|
r"|(?P<em>[*_])(?P<em_body>[^*_]+?)(?P=em)",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tl_iv():
|
||||||
|
from org.telegram.tgnet.tl import TL_iv
|
||||||
|
|
||||||
|
return TL_iv
|
||||||
|
|
||||||
|
|
||||||
|
def _plain(TL_iv, text: str) -> Any:
|
||||||
|
node = TL_iv.textPlain()
|
||||||
|
node.text = text
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap(TL_iv, kind: str, inner: Any) -> Any:
|
||||||
|
node = getattr(TL_iv, kind)()
|
||||||
|
node.text = inner
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
def _concat(TL_iv, parts: List[Any]) -> Any:
|
||||||
|
"""textConcat for >1 part; the single part itself otherwise."""
|
||||||
|
if not parts:
|
||||||
|
return TL_iv.textEmpty()
|
||||||
|
if len(parts) == 1:
|
||||||
|
return parts[0]
|
||||||
|
node = TL_iv.textConcat()
|
||||||
|
for p in parts:
|
||||||
|
node.texts.add(p)
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
def build_rich_text(markdown: str, TL_iv: Optional[Any] = None) -> Any:
|
||||||
|
"""Converts one paragraph of inline markdown into a RichText tree."""
|
||||||
|
TL_iv = TL_iv or _tl_iv()
|
||||||
|
parts: List[Any] = []
|
||||||
|
pos = 0
|
||||||
|
for m in _INLINE_RE.finditer(markdown):
|
||||||
|
if m.start() > pos:
|
||||||
|
parts.append(_plain(TL_iv, markdown[pos:m.start()]))
|
||||||
|
if m.group("code") is not None:
|
||||||
|
parts.append(_wrap(TL_iv, "textFixed", _plain(TL_iv, m.group("code_body"))))
|
||||||
|
elif m.group("link_text") is not None:
|
||||||
|
node = TL_iv.textUrl()
|
||||||
|
node.text = build_rich_text(m.group("link_text"), TL_iv)
|
||||||
|
node.url = m.group("link_url")
|
||||||
|
node.webpage_id = 0
|
||||||
|
parts.append(node)
|
||||||
|
elif m.group("bold") is not None:
|
||||||
|
parts.append(_wrap(TL_iv, "textBold", build_rich_text(m.group("bold_body"), TL_iv)))
|
||||||
|
elif m.group("strike") is not None:
|
||||||
|
parts.append(_wrap(TL_iv, "textStrike", build_rich_text(m.group("strike_body"), TL_iv)))
|
||||||
|
elif m.group("em") is not None:
|
||||||
|
parts.append(_wrap(TL_iv, "textItalic", build_rich_text(m.group("em_body"), TL_iv)))
|
||||||
|
pos = m.end()
|
||||||
|
if pos < len(markdown):
|
||||||
|
parts.append(_plain(TL_iv, markdown[pos:]))
|
||||||
|
return _concat(TL_iv, parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _block(TL_iv, kind: str, text: Optional[Any] = None, **fields) -> Any:
|
||||||
|
node = getattr(TL_iv, kind)()
|
||||||
|
if text is not None:
|
||||||
|
node.text = text
|
||||||
|
for k, v in fields.items():
|
||||||
|
setattr(node, k, v)
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
def _list_item(TL_iv, markdown: str) -> Any:
|
||||||
|
item = TL_iv.TL_pageListItemText()
|
||||||
|
item.text = build_rich_text(markdown, TL_iv)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _ordered_item(TL_iv, markdown: str, num: int) -> Any:
|
||||||
|
item = TL_iv.TL_pageListOrderedItemText()
|
||||||
|
item.text = build_rich_text(markdown, TL_iv)
|
||||||
|
item.num = str(num)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _flush_paragraph(TL_iv, buf: List[str], out: List[Any]) -> None:
|
||||||
|
if not buf:
|
||||||
|
return
|
||||||
|
text = "\n".join(buf).strip()
|
||||||
|
buf.clear()
|
||||||
|
if text:
|
||||||
|
out.append(_block(TL_iv, "pageBlockParagraph", build_rich_text(text, TL_iv)))
|
||||||
|
|
||||||
|
|
||||||
|
def markdown_to_blocks(markdown: str, TL_iv: Optional[Any] = None) -> List[Any]:
|
||||||
|
"""Converts markdown into a list of TL_iv page blocks."""
|
||||||
|
TL_iv = TL_iv or _tl_iv()
|
||||||
|
out: List[Any] = []
|
||||||
|
para: List[str] = []
|
||||||
|
lines = (markdown or "").replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||||
|
i = 0
|
||||||
|
|
||||||
|
while i < len(lines):
|
||||||
|
line = lines[i]
|
||||||
|
|
||||||
|
fence = _FENCE_RE.match(line)
|
||||||
|
if fence:
|
||||||
|
lang = fence.group(1) or ""
|
||||||
|
i += 1
|
||||||
|
body: List[str] = []
|
||||||
|
while i < len(lines) and not _FENCE_RE.match(lines[i]):
|
||||||
|
body.append(lines[i])
|
||||||
|
i += 1
|
||||||
|
i += 1 # closing fence (or EOF, which closes an unterminated block)
|
||||||
|
_flush_paragraph(TL_iv, para, out)
|
||||||
|
block = _block(TL_iv, "pageBlockPreformatted", _plain(TL_iv, "\n".join(body)))
|
||||||
|
block.language = lang
|
||||||
|
out.append(block)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if _RULE_RE.match(line):
|
||||||
|
_flush_paragraph(TL_iv, para, out)
|
||||||
|
out.append(_block(TL_iv, "pageBlockDivider"))
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
heading = _HEADING_RE.match(line)
|
||||||
|
if heading:
|
||||||
|
_flush_paragraph(TL_iv, para, out)
|
||||||
|
level = len(heading.group(1))
|
||||||
|
kind = "pageBlockHeading1" if level == 1 else ("pageBlockHeading2" if level == 2 else "pageBlockHeading3")
|
||||||
|
out.append(_block(TL_iv, kind, build_rich_text(heading.group(2).strip(), TL_iv)))
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if _QUOTE_RE.match(line):
|
||||||
|
_flush_paragraph(TL_iv, para, out)
|
||||||
|
quoted: List[str] = []
|
||||||
|
while i < len(lines):
|
||||||
|
qm = _QUOTE_RE.match(lines[i])
|
||||||
|
if not qm:
|
||||||
|
break
|
||||||
|
quoted.append(qm.group(1))
|
||||||
|
i += 1
|
||||||
|
block = _block(TL_iv, "pageBlockBlockquote", build_rich_text("\n".join(quoted).strip(), TL_iv))
|
||||||
|
block.caption = TL_iv.textEmpty()
|
||||||
|
out.append(block)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if _BULLET_RE.match(line):
|
||||||
|
_flush_paragraph(TL_iv, para, out)
|
||||||
|
block = _block(TL_iv, "pageBlockList")
|
||||||
|
block.ordered = False
|
||||||
|
while i < len(lines):
|
||||||
|
bm = _BULLET_RE.match(lines[i])
|
||||||
|
if not bm:
|
||||||
|
break
|
||||||
|
block.items.add(_list_item(TL_iv, bm.group(1).strip()))
|
||||||
|
i += 1
|
||||||
|
out.append(block)
|
||||||
|
continue
|
||||||
|
|
||||||
|
ordered = _ORDERED_RE.match(line)
|
||||||
|
if ordered:
|
||||||
|
_flush_paragraph(TL_iv, para, out)
|
||||||
|
block = _block(TL_iv, "pageBlockOrderedList")
|
||||||
|
start = int(ordered.group(1))
|
||||||
|
block.start = start
|
||||||
|
block.reversed = False
|
||||||
|
block.type = ""
|
||||||
|
block.flags = 0
|
||||||
|
num = start
|
||||||
|
while i < len(lines):
|
||||||
|
om = _ORDERED_RE.match(lines[i])
|
||||||
|
if not om:
|
||||||
|
break
|
||||||
|
block.items.add(_ordered_item(TL_iv, om.group(2).strip(), num))
|
||||||
|
num += 1
|
||||||
|
i += 1
|
||||||
|
out.append(block)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not line.strip():
|
||||||
|
_flush_paragraph(TL_iv, para, out)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
para.append(line)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
_flush_paragraph(TL_iv, para, out)
|
||||||
|
if not out:
|
||||||
|
out.append(_block(TL_iv, "pageBlockParagraph", TL_iv.textEmpty()))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_rich_message(
|
||||||
|
answer_markdown: str,
|
||||||
|
thinking: str = "",
|
||||||
|
streaming: bool = False,
|
||||||
|
status: str = "",
|
||||||
|
TL_iv: Optional[Any] = None,
|
||||||
|
) -> Any:
|
||||||
|
"""Assembles a RichMessage: status/reasoning, then the answer blocks.
|
||||||
|
|
||||||
|
`status` is the current pipeline stage ("Fetching messages…" etc.) and is
|
||||||
|
rendered as a thinking block so it gets the shimmer treatment; it is
|
||||||
|
dropped as soon as real reasoning or answer text arrives.
|
||||||
|
|
||||||
|
While reasoning is still streaming it stays a `pageBlockThinking`, which
|
||||||
|
animates. Once the answer starts arriving the reasoning is finished, so it
|
||||||
|
moves into a collapsed `pageBlockDetails` -- the app's own expandable
|
||||||
|
container (RichDetailsBlock, with toggle()/isOpen()) -- and can be revealed
|
||||||
|
or hidden by tapping its title.
|
||||||
|
|
||||||
|
`RichMessage.part` is deliberately always False. Setting it makes
|
||||||
|
RichMessageLayout.draw take its partial-content branch, which fades the
|
||||||
|
bottom edge AND draws a "Show more" button wired to the app's
|
||||||
|
loadFullRichMessage server fetch. That button is meaningless for a
|
||||||
|
locally generated summary -- there is nothing more to load -- and the two
|
||||||
|
effects share one branch, so the only way to drop the collapse is to leave
|
||||||
|
`part` unset.
|
||||||
|
"""
|
||||||
|
TL_iv = TL_iv or _tl_iv()
|
||||||
|
rm = TL_iv.RichMessage()
|
||||||
|
thinking = (thinking or "").strip()
|
||||||
|
answer_markdown = (answer_markdown or "").strip()
|
||||||
|
status = (status or "").strip()
|
||||||
|
|
||||||
|
if thinking and answer_markdown:
|
||||||
|
rm.blocks.add(_thinking_details(TL_iv, thinking))
|
||||||
|
elif thinking:
|
||||||
|
rm.blocks.add(_block(TL_iv, "pageBlockThinking", build_rich_text(thinking, TL_iv)))
|
||||||
|
elif status and not answer_markdown:
|
||||||
|
rm.blocks.add(_block(TL_iv, "pageBlockThinking", build_rich_text(status, TL_iv)))
|
||||||
|
|
||||||
|
if answer_markdown:
|
||||||
|
for b in markdown_to_blocks(answer_markdown, TL_iv):
|
||||||
|
rm.blocks.add(b)
|
||||||
|
|
||||||
|
if not rm.blocks:
|
||||||
|
rm.blocks.add(_block(TL_iv, "pageBlockParagraph", TL_iv.textEmpty()))
|
||||||
|
|
||||||
|
rm.part = False
|
||||||
|
rm.rtl = False
|
||||||
|
return rm
|
||||||
|
|
||||||
|
|
||||||
|
def _thinking_details(TL_iv: Any, thinking: str) -> Any:
|
||||||
|
"""Reasoning wrapped in a collapsed, tappable details block."""
|
||||||
|
details = TL_iv.pageBlockDetails()
|
||||||
|
details.title = build_rich_text(THINKING_TITLE, TL_iv)
|
||||||
|
details.open = False
|
||||||
|
details.flags = 0
|
||||||
|
for paragraph in thinking.split("\n\n"):
|
||||||
|
paragraph = paragraph.strip()
|
||||||
|
if paragraph:
|
||||||
|
details.blocks.add(_block(TL_iv, "pageBlockParagraph", build_rich_text(paragraph, TL_iv)))
|
||||||
|
if not details.blocks:
|
||||||
|
details.blocks.add(_block(TL_iv, "pageBlockParagraph", build_rich_text(thinking, TL_iv)))
|
||||||
|
return details
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""Local SQLite store for generated chat summaries.
|
||||||
|
|
||||||
|
One row per summary: the final markdown text, the chat/topic it was issued in,
|
||||||
|
and the id of the newest message included in the analysis. That anchor id is
|
||||||
|
what lets the injector re-insert the summary directly after the last analyzed
|
||||||
|
message when the chat is reopened.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
DB_DIRNAME = "chat_summaries"
|
||||||
|
DB_FILENAME = "summaries.db"
|
||||||
|
|
||||||
|
_SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS summaries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
dialog_id INTEGER NOT NULL,
|
||||||
|
topic_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
anchor_msg_id INTEGER NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
thinking TEXT NOT NULL DEFAULT '',
|
||||||
|
msg_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_summaries_scope
|
||||||
|
ON summaries (dialog_id, topic_id, anchor_msg_id);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class StoredSummary:
|
||||||
|
__slots__ = ("id", "dialog_id", "topic_id", "anchor_msg_id", "text", "thinking", "msg_count", "created_at")
|
||||||
|
|
||||||
|
def __init__(self, id, dialog_id, topic_id, anchor_msg_id, text, thinking, msg_count, created_at):
|
||||||
|
self.id = int(id)
|
||||||
|
self.dialog_id = int(dialog_id)
|
||||||
|
self.topic_id = int(topic_id)
|
||||||
|
self.anchor_msg_id = int(anchor_msg_id)
|
||||||
|
self.text = text
|
||||||
|
self.thinking = thinking
|
||||||
|
self.msg_count = int(msg_count)
|
||||||
|
self.created_at = int(created_at)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"StoredSummary(id={self.id}, dialog_id={self.dialog_id}, topic_id={self.topic_id}, "
|
||||||
|
f"anchor_msg_id={self.anchor_msg_id}, msg_count={self.msg_count})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_db_path() -> str:
|
||||||
|
try:
|
||||||
|
from file_utils import get_files_dir
|
||||||
|
|
||||||
|
base = get_files_dir()
|
||||||
|
except Exception:
|
||||||
|
base = os.path.join(os.path.expanduser("~"), ".exteragram_chat_summaries")
|
||||||
|
target = os.path.join(base, DB_DIRNAME)
|
||||||
|
os.makedirs(target, exist_ok=True)
|
||||||
|
return os.path.join(target, DB_FILENAME)
|
||||||
|
|
||||||
|
|
||||||
|
class SummaryDb:
|
||||||
|
"""Thread-safe by way of short-lived connections; every call opens and closes."""
|
||||||
|
|
||||||
|
def __init__(self, db_path: Optional[str] = None) -> None:
|
||||||
|
self.db_path = db_path or _default_db_path()
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.executescript(_SCHEMA)
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(self.db_path, timeout=10.0)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def save(
|
||||||
|
self,
|
||||||
|
dialog_id: int,
|
||||||
|
topic_id: int,
|
||||||
|
anchor_msg_id: int,
|
||||||
|
text: str,
|
||||||
|
thinking: str = "",
|
||||||
|
msg_count: int = 0,
|
||||||
|
) -> int:
|
||||||
|
"""Inserts, or replaces the existing summary for the same anchor. Returns row id."""
|
||||||
|
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 UPDATE SET
|
||||||
|
text = excluded.text,
|
||||||
|
thinking = excluded.thinking,
|
||||||
|
msg_count = excluded.msg_count,
|
||||||
|
created_at = excluded.created_at
|
||||||
|
""",
|
||||||
|
(int(dialog_id), int(topic_id), int(anchor_msg_id), text, thinking, int(msg_count), now),
|
||||||
|
)
|
||||||
|
if cur.lastrowid:
|
||||||
|
return int(cur.lastrowid)
|
||||||
|
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"])
|
||||||
|
|
||||||
|
def get_for_scope(self, dialog_id: int, topic_id: int = 0) -> List[StoredSummary]:
|
||||||
|
"""All summaries for a chat/topic, oldest anchor first."""
|
||||||
|
with self._connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM summaries
|
||||||
|
WHERE dialog_id = ? AND topic_id = ?
|
||||||
|
ORDER BY anchor_msg_id ASC
|
||||||
|
""",
|
||||||
|
(int(dialog_id), int(topic_id)),
|
||||||
|
).fetchall()
|
||||||
|
return [_row_to_summary(r) for r in rows]
|
||||||
|
|
||||||
|
def get(self, summary_id: int) -> Optional[StoredSummary]:
|
||||||
|
with self._connect() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM summaries WHERE id = ?", (int(summary_id),)).fetchone()
|
||||||
|
return _row_to_summary(row) if row else None
|
||||||
|
|
||||||
|
def delete(self, summary_id: int) -> bool:
|
||||||
|
with self._connect() as conn:
|
||||||
|
cur = conn.execute("DELETE FROM summaries WHERE id = ?", (int(summary_id),))
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
def clear_scope(self, dialog_id: int, topic_id: int = 0) -> int:
|
||||||
|
with self._connect() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"DELETE FROM summaries WHERE dialog_id = ? AND topic_id = ?",
|
||||||
|
(int(dialog_id), int(topic_id)),
|
||||||
|
)
|
||||||
|
return cur.rowcount
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_summary(row: sqlite3.Row) -> StoredSummary:
|
||||||
|
return StoredSummary(
|
||||||
|
row["id"],
|
||||||
|
row["dialog_id"],
|
||||||
|
row["topic_id"],
|
||||||
|
row["anchor_msg_id"],
|
||||||
|
row["text"],
|
||||||
|
row["thinking"],
|
||||||
|
row["msg_count"],
|
||||||
|
row["created_at"],
|
||||||
|
)
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
"""Anonymized JSON transcript builder and mention de-anonymizer.
|
||||||
|
|
||||||
|
The model never sees real Telegram ids. Messages are numbered 1..N in the order
|
||||||
|
they are included, senders are numbered 1..M in order of first appearance, and
|
||||||
|
the mapping is kept locally so mentions in the reply can be turned back into
|
||||||
|
real deep links.
|
||||||
|
|
||||||
|
Wire format is compact JSON with no spacing, e.g.
|
||||||
|
|
||||||
|
{"messages":[{"id":1,"user":1,"text":"got new crocs","attachments":["a.png"]},
|
||||||
|
{"id":2,"user":2,"text":"cap","reply_to":1,"reply_highlight":"new"}]}
|
||||||
|
|
||||||
|
A message that replies to something outside the analyzed range is still useful
|
||||||
|
context, so the parent is injected with a negative id (-1, -2, ...) and is
|
||||||
|
understood to be outside the summarized window.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
# Placeholders the model emits, in the anonymized namespace:
|
||||||
|
# {user3} -> the 3rd distinct sender
|
||||||
|
# {msg12} -> the 12th included message
|
||||||
|
#
|
||||||
|
# Deliberately not Markdown links. A link is only valid once its closing
|
||||||
|
# parenthesis arrives, so a half-streamed `[x](msg:59` renders as broken
|
||||||
|
# literal text; a placeholder is a single token that is either complete or
|
||||||
|
# absent, and substitution decides the visible label locally.
|
||||||
|
_USER_PLACEHOLDER_RE = re.compile(r"\{user(-?\d+)\}")
|
||||||
|
_MSG_PLACEHOLDER_RE = re.compile(r"\{msg(-?\d+)\}")
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = (
|
||||||
|
"You summarize Telegram chat conversations.\n"
|
||||||
|
"\n"
|
||||||
|
"Input is JSON: {\"messages\":[{\"id\":N,\"user\":N,\"text\":\"...\"}]}. "
|
||||||
|
"`id` is the message index, `user` is an anonymized sender number, "
|
||||||
|
"`reply_to` is the id of the message being replied to, `reply_highlight` is "
|
||||||
|
"the quoted fragment, and `attachments` lists attached file names. "
|
||||||
|
"A negative `id` means the message is outside the summarized range and is "
|
||||||
|
"included only as context.\n"
|
||||||
|
"\n"
|
||||||
|
"Write the summary in Markdown. You may use headings, bold, italics, "
|
||||||
|
"bullet and numbered lists, block quotes, dividers, inline code and fenced "
|
||||||
|
"code blocks.\n"
|
||||||
|
"\n"
|
||||||
|
"Refer to a person as {userN} and to a message as {msgN}, using the "
|
||||||
|
"numbers from the input. These are placeholders: write them bare, never "
|
||||||
|
"inside a Markdown link, and never wrap them in brackets or backticks. "
|
||||||
|
"They are replaced with real names and links afterwards.\n"
|
||||||
|
"\n"
|
||||||
|
"Example: {user1} said the build was broken ({msg4}, {msg7}).\n"
|
||||||
|
"\n"
|
||||||
|
"Cite at most two message placeholders for a single claim, and only where "
|
||||||
|
"a reader would want the source. Never invent a number that is not in the "
|
||||||
|
"input, never write a bare number as a citation, and do not mention that "
|
||||||
|
"ids are anonymized or describe the JSON format in your answer."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TranscriptMapping:
|
||||||
|
"""Anonymized <-> real id mapping for one request.
|
||||||
|
|
||||||
|
Holds everything needed to turn the model's placeholders back into real
|
||||||
|
names and deep links: the real ids, the display names, and whether each
|
||||||
|
sender is a user or a chat/channel (they need different link forms).
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("user_by_index", "message_by_index", "name_by_index", "kind_by_index", "topic_id")
|
||||||
|
|
||||||
|
def __init__(self, topic_id: int = 0) -> None:
|
||||||
|
# anonymized number -> real Telegram id
|
||||||
|
self.user_by_index: Dict[int, int] = {}
|
||||||
|
self.message_by_index: Dict[int, int] = {}
|
||||||
|
# anonymized number -> display name, used as the visible link label.
|
||||||
|
self.name_by_index: Dict[int, str] = {}
|
||||||
|
# anonymized number -> "user" | "chat", since a group or channel
|
||||||
|
# sender cannot be opened with tg://user (that reports "user not
|
||||||
|
# found"); it needs the chat form instead.
|
||||||
|
self.kind_by_index: Dict[int, str] = {}
|
||||||
|
# Forum topic the analyzed messages belong to, needed so message links
|
||||||
|
# open inside the topic rather than the supergroup's global feed.
|
||||||
|
self.topic_id = int(topic_id or 0)
|
||||||
|
|
||||||
|
def real_user_id(self, index: int) -> Optional[int]:
|
||||||
|
return self.user_by_index.get(int(index))
|
||||||
|
|
||||||
|
def real_message_id(self, index: int) -> Optional[int]:
|
||||||
|
return self.message_by_index.get(int(index))
|
||||||
|
|
||||||
|
def user_name(self, index: int) -> str:
|
||||||
|
return self.name_by_index.get(int(index), "")
|
||||||
|
|
||||||
|
def sender_kind(self, index: int) -> str:
|
||||||
|
return self.kind_by_index.get(int(index), "user")
|
||||||
|
|
||||||
|
|
||||||
|
def describe_attachments(raw: Any) -> List[str]:
|
||||||
|
"""Names the media attached to a raw Telegram message.
|
||||||
|
|
||||||
|
Prefers a real file name when the media carries one, and otherwise falls
|
||||||
|
back to a stable synthetic name built from the media's own id, so the model
|
||||||
|
can refer to an attachment without being handed a Telegram file id.
|
||||||
|
"""
|
||||||
|
media = getattr(raw, "media", None)
|
||||||
|
if media is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
document = getattr(media, "document", None)
|
||||||
|
if document is not None:
|
||||||
|
for attribute in _java_list(getattr(document, "attributes", None)):
|
||||||
|
file_name = getattr(attribute, "file_name", None)
|
||||||
|
if file_name:
|
||||||
|
return [str(file_name)]
|
||||||
|
mime = str(getattr(document, "mime_type", "") or "")
|
||||||
|
extension = mime.rpartition("/")[2] or "bin"
|
||||||
|
return [f"{abs(int(getattr(document, 'id', 0) or 0))}.{extension}"]
|
||||||
|
|
||||||
|
photo = getattr(media, "photo", None)
|
||||||
|
if photo is not None:
|
||||||
|
return [f"{abs(int(getattr(photo, 'id', 0) or 0))}.jpg"]
|
||||||
|
|
||||||
|
webpage = getattr(media, "webpage", None)
|
||||||
|
if webpage is not None:
|
||||||
|
url = getattr(webpage, "url", None)
|
||||||
|
return [str(url)] if url else []
|
||||||
|
|
||||||
|
# Polls, contacts, geo, invoices: name the media class, without its
|
||||||
|
# TL_messageMedia prefix.
|
||||||
|
name = type(media).__name__ or "media"
|
||||||
|
return [name.replace("TL_messageMedia", "").lower() or "media"]
|
||||||
|
|
||||||
|
|
||||||
|
def _java_list(value: Any) -> List[Any]:
|
||||||
|
"""Chaquopy exposes java.util.ArrayList without the iterator protocol."""
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, list):
|
||||||
|
return list(value)
|
||||||
|
try:
|
||||||
|
return [value.get(i) for i in range(value.size())]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def build_transcript(
|
||||||
|
messages: List[Any],
|
||||||
|
include_attachments: bool = False,
|
||||||
|
extra_by_id: Optional[Dict[int, Any]] = None,
|
||||||
|
topic_id: int = 0,
|
||||||
|
) -> Tuple[str, TranscriptMapping]:
|
||||||
|
"""Builds the compact anonymized JSON transcript plus its id mapping.
|
||||||
|
|
||||||
|
`messages` are the analyzed messages, oldest first. `extra_by_id` holds
|
||||||
|
out-of-range reply parents keyed by real message id; those are emitted with
|
||||||
|
negative ids and are not part of the 1..N numbering. `topic_id` is carried
|
||||||
|
on the mapping so message links can open inside the forum topic.
|
||||||
|
|
||||||
|
Only `text` and `attachments` leave the device as-is; every identity is
|
||||||
|
replaced by an index.
|
||||||
|
"""
|
||||||
|
mapping = TranscriptMapping(topic_id=topic_id)
|
||||||
|
extra_by_id = extra_by_id or {}
|
||||||
|
|
||||||
|
index_by_real_message: Dict[int, int] = {}
|
||||||
|
index_by_real_user: Dict[int, int] = {}
|
||||||
|
|
||||||
|
def user_index(message: Any) -> int:
|
||||||
|
real_id = int(getattr(message, "sender_id", 0) or 0)
|
||||||
|
# Senders with no resolvable id are grouped by display name so the same
|
||||||
|
# anonymous poster keeps one number.
|
||||||
|
key = real_id if real_id else -abs(hash(getattr(message, "sender_name", "") or "?")) or -1
|
||||||
|
existing = index_by_real_user.get(key)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
assigned = len(index_by_real_user) + 1
|
||||||
|
index_by_real_user[key] = assigned
|
||||||
|
if real_id:
|
||||||
|
mapping.user_by_index[assigned] = real_id
|
||||||
|
mapping.name_by_index[assigned] = getattr(message, "sender_name", "") or ""
|
||||||
|
mapping.kind_by_index[assigned] = getattr(message, "sender_kind", "user") or "user"
|
||||||
|
return assigned
|
||||||
|
|
||||||
|
# Number the analyzed messages first, so reply_to can resolve forward.
|
||||||
|
for position, message in enumerate(messages, start=1):
|
||||||
|
real_id = int(getattr(message, "id", 0) or 0)
|
||||||
|
index_by_real_message[real_id] = position
|
||||||
|
mapping.message_by_index[position] = real_id
|
||||||
|
|
||||||
|
entries: List[Dict[str, Any]] = []
|
||||||
|
next_context_index = -1
|
||||||
|
|
||||||
|
def entry_for(message: Any, index: int) -> Dict[str, Any]:
|
||||||
|
entry: Dict[str, Any] = {
|
||||||
|
"id": index,
|
||||||
|
"user": user_index(message),
|
||||||
|
"text": getattr(message, "text", "") or "",
|
||||||
|
}
|
||||||
|
reply_to_real = int(getattr(message, "reply_to_id", 0) or 0)
|
||||||
|
if reply_to_real:
|
||||||
|
reply_index = index_by_real_message.get(reply_to_real)
|
||||||
|
if reply_index is not None:
|
||||||
|
entry["reply_to"] = reply_index
|
||||||
|
highlight = getattr(message, "reply_highlight", "") or ""
|
||||||
|
if highlight:
|
||||||
|
entry["reply_highlight"] = highlight
|
||||||
|
if include_attachments:
|
||||||
|
attachments = list(getattr(message, "attachments", None) or [])
|
||||||
|
if attachments:
|
||||||
|
entry["attachments"] = attachments
|
||||||
|
return entry
|
||||||
|
|
||||||
|
for position, message in enumerate(messages, start=1):
|
||||||
|
# An out-of-range reply parent is context the summary needs; emit it
|
||||||
|
# immediately before its child with a negative id.
|
||||||
|
reply_to_real = int(getattr(message, "reply_to_id", 0) or 0)
|
||||||
|
if reply_to_real and reply_to_real not in index_by_real_message:
|
||||||
|
parent = extra_by_id.get(reply_to_real)
|
||||||
|
if parent is not None:
|
||||||
|
index_by_real_message[reply_to_real] = next_context_index
|
||||||
|
mapping.message_by_index[next_context_index] = reply_to_real
|
||||||
|
entries.append(entry_for(parent, next_context_index))
|
||||||
|
next_context_index -= 1
|
||||||
|
entries.append(entry_for(message, position))
|
||||||
|
|
||||||
|
payload = json.dumps({"messages": entries}, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
return payload, mapping
|
||||||
|
|
||||||
|
|
||||||
|
def deanonymize(markdown: str, mapping: TranscriptMapping, dialog_id: int) -> str:
|
||||||
|
"""Substitutes anonymized placeholders with real names and deep links.
|
||||||
|
|
||||||
|
`{user3}` becomes a profile link and `{msg12}` a numbered link that jumps
|
||||||
|
to the message. Both forms are resolved by exteraGram's
|
||||||
|
LaunchActivity.handleIntent.
|
||||||
|
|
||||||
|
Substitution is safe to run on partial text: a placeholder is one token, so
|
||||||
|
a half-streamed `{msg1` simply stays literal for one frame and resolves on
|
||||||
|
the next, instead of rendering as a broken Markdown link.
|
||||||
|
|
||||||
|
A placeholder whose number was never issued degrades to readable text
|
||||||
|
rather than a dead link.
|
||||||
|
"""
|
||||||
|
if not markdown:
|
||||||
|
return markdown
|
||||||
|
|
||||||
|
def replace_user(match: "re.Match") -> str:
|
||||||
|
index = int(match.group(1))
|
||||||
|
name = mapping.user_name(index)
|
||||||
|
real_id = mapping.real_user_id(index)
|
||||||
|
if not real_id:
|
||||||
|
return name or "someone"
|
||||||
|
return f"[{name or 'user'}]({sender_link(real_id, mapping.sender_kind(index))})"
|
||||||
|
|
||||||
|
def replace_message(match: "re.Match") -> str:
|
||||||
|
index = int(match.group(1))
|
||||||
|
real_id = mapping.real_message_id(index)
|
||||||
|
if not real_id:
|
||||||
|
return ""
|
||||||
|
# Numbered citation: short enough to read inline, and the number is
|
||||||
|
# the model's own index so repeated citations stay distinguishable.
|
||||||
|
label = f"№{abs(index)}"
|
||||||
|
return f"[{label}]({message_link(dialog_id, real_id, mapping.topic_id)})"
|
||||||
|
|
||||||
|
result = _USER_PLACEHOLDER_RE.sub(replace_user, markdown)
|
||||||
|
return _MSG_PLACEHOLDER_RE.sub(replace_message, result)
|
||||||
|
|
||||||
|
|
||||||
|
def sender_link(sender_id: int, kind: str = "user") -> str:
|
||||||
|
"""Deep link that opens a sender's profile.
|
||||||
|
|
||||||
|
A group or channel is not a user: tg://user?id= on one reports "user not
|
||||||
|
found", so chat senders get the openmessage form, which resolves to the
|
||||||
|
chat itself.
|
||||||
|
"""
|
||||||
|
sender_id = int(sender_id)
|
||||||
|
if kind == "chat":
|
||||||
|
return f"tg://openmessage?chat_id={_internal_chat_id(sender_id)}"
|
||||||
|
return f"tg://user?id={sender_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _internal_chat_id(raw_id: int) -> int:
|
||||||
|
"""Positive internal id Telegram uses in links for a chat or channel."""
|
||||||
|
value = abs(int(raw_id))
|
||||||
|
if value > 1000000000000:
|
||||||
|
value -= 1000000000000
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
_PARTIAL_TAIL_RE = re.compile(r"\{(?:u(?:s(?:e(?:r-?\d*)?)?)?|m(?:s(?:g-?\d*)?)?)?$")
|
||||||
|
|
||||||
|
|
||||||
|
def strip_partial_placeholder(markdown: str) -> str:
|
||||||
|
"""Drops a placeholder that is still being streamed.
|
||||||
|
|
||||||
|
Without this the last frame of a stream can show a literal `{msg1` until
|
||||||
|
the closing brace arrives. Only a trailing fragment is removed, so complete
|
||||||
|
placeholders earlier in the text are untouched.
|
||||||
|
"""
|
||||||
|
if not markdown:
|
||||||
|
return markdown
|
||||||
|
return _PARTIAL_TAIL_RE.sub("", markdown)
|
||||||
|
|
||||||
|
|
||||||
|
def message_link(dialog_id: int, message_id: int, topic_id: int = 0) -> str:
|
||||||
|
"""Deep link that opens a specific message, inside its topic when relevant.
|
||||||
|
|
||||||
|
`tg://openmessage` carries no thread parameter, so in a forum it lands in
|
||||||
|
the supergroup's global feed rather than the topic. The app's own link
|
||||||
|
parser accepts `t.me/c/<internal_id>/<topic_id>/<message_id>` (regex
|
||||||
|
verified in the APK), which is the only form that opens the right topic, so
|
||||||
|
that is used whenever a topic is known.
|
||||||
|
"""
|
||||||
|
dialog_id = int(dialog_id)
|
||||||
|
message_id = int(message_id)
|
||||||
|
topic_id = int(topic_id or 0)
|
||||||
|
|
||||||
|
if dialog_id < 0 and topic_id:
|
||||||
|
return f"https://t.me/c/{_internal_chat_id(dialog_id)}/{topic_id}/{message_id}"
|
||||||
|
if dialog_id > 0:
|
||||||
|
return f"tg://openmessage?user_id={dialog_id}&message_id={message_id}"
|
||||||
|
return f"tg://openmessage?chat_id={_internal_chat_id(dialog_id)}&message_id={message_id}"
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
def normalize(name: str) -> str:
|
|
||||||
return " ".join(w[:1].upper() + w[1:] for w in name.split())
|
|
||||||
Whitespace-only changes.
@@ -0,0 +1,129 @@
|
|||||||
|
"""Bottom-line UI: prompts for message count/offset/prompt, runs the
|
||||||
|
summarization, and shows the result. Deliberately plain — no bottom sheets,
|
||||||
|
sliders, or styling copied from any other plugin.
|
||||||
|
"""
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from android.widget import LinearLayout, TextView
|
||||||
|
from org.telegram.messenger import AndroidUtilities
|
||||||
|
from org.telegram.ui.Components import EditTextBoldCursor
|
||||||
|
|
||||||
|
import diagnostics
|
||||||
|
from ui.alert import AlertDialogBuilder
|
||||||
|
from ui.bulletin import BulletinHelper
|
||||||
|
from ui.theme import DialogColors
|
||||||
|
from config import (
|
||||||
|
DEFAULT_MSG_COUNT,
|
||||||
|
DEFAULT_MSG_OFFSET,
|
||||||
|
MAX_MSG_COUNT,
|
||||||
|
MIN_MSG_COUNT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _dp(value: int) -> int:
|
||||||
|
return AndroidUtilities.dp(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _match_wrap_params() -> Any:
|
||||||
|
"""LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).
|
||||||
|
|
||||||
|
Uses plain ints (-1 / -2, the real values behind
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT/WRAP_CONTENT) instead of
|
||||||
|
org.telegram.ui.Components.LayoutHelper: on this device/SDK build,
|
||||||
|
Chaquopy raises AttributeError resolving LayoutHelper.MATCH_PARENT even
|
||||||
|
though the Java field exists, while WRAP_CONTENT alone was not enough
|
||||||
|
to prove the whole class is reachable. Plain LinearLayout.LayoutParams
|
||||||
|
sidesteps that static-field lookup entirely.
|
||||||
|
"""
|
||||||
|
return LinearLayout.LayoutParams(-1, -2)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_label(activity, text: str, colors: DialogColors) -> TextView:
|
||||||
|
label = TextView(activity)
|
||||||
|
label.setText(text)
|
||||||
|
label.setTextSize(14)
|
||||||
|
label.setPadding(0, _dp(12), 0, _dp(4))
|
||||||
|
label.setTextColor(colors.secondary_text)
|
||||||
|
return label
|
||||||
|
|
||||||
|
|
||||||
|
def _make_edit_text(activity, hint: str, initial: str, colors: DialogColors) -> Any:
|
||||||
|
field = EditTextBoldCursor(activity)
|
||||||
|
field.setHint(hint)
|
||||||
|
field.setText(initial)
|
||||||
|
field.setSingleLine(True)
|
||||||
|
field.setTextColor(colors.input_text)
|
||||||
|
field.setHintTextColor(colors.hint_text)
|
||||||
|
field.setLineColors(colors.input_line, colors.input_line_active, colors.input_line_active)
|
||||||
|
field.setCursorColor(colors.cursor)
|
||||||
|
return field
|
||||||
|
|
||||||
|
|
||||||
|
def show_summarize_dialog(
|
||||||
|
activity: Any,
|
||||||
|
fragment: Any,
|
||||||
|
default_count: int,
|
||||||
|
default_offset: int,
|
||||||
|
default_prompt: str,
|
||||||
|
on_submit: Callable[[int, int, str], None],
|
||||||
|
) -> None:
|
||||||
|
"""Shows a dialog collecting message count, offset, and prompt, then
|
||||||
|
invokes on_submit(count, offset, prompt) if the user confirms.
|
||||||
|
"""
|
||||||
|
colors = DialogColors()
|
||||||
|
root = LinearLayout(activity)
|
||||||
|
root.setOrientation(LinearLayout.VERTICAL)
|
||||||
|
root.setPadding(_dp(24), _dp(8), _dp(24), _dp(8))
|
||||||
|
root.setBackgroundColor(colors.background)
|
||||||
|
|
||||||
|
count_label = _make_label(activity, "Messages to summarize (1-1000)", colors)
|
||||||
|
root.addView(count_label)
|
||||||
|
count_field = _make_edit_text(
|
||||||
|
activity, "e.g. 100", str(max(MIN_MSG_COUNT, min(MAX_MSG_COUNT, default_count))), colors
|
||||||
|
)
|
||||||
|
root.addView(count_field, _match_wrap_params())
|
||||||
|
|
||||||
|
offset_label = _make_label(activity, "Offset from newest message", colors)
|
||||||
|
root.addView(offset_label)
|
||||||
|
offset_field = _make_edit_text(activity, "e.g. 0", str(max(0, default_offset)), colors)
|
||||||
|
root.addView(offset_field, _match_wrap_params())
|
||||||
|
|
||||||
|
prompt_label = _make_label(activity, "Prompt (saved for this chat/topic)", colors)
|
||||||
|
root.addView(prompt_label)
|
||||||
|
prompt_field = _make_edit_text(activity, "Summarize this conversation…", default_prompt, colors)
|
||||||
|
prompt_field.setSingleLine(False)
|
||||||
|
prompt_field.setMinLines(2)
|
||||||
|
prompt_field.setMaxLines(5)
|
||||||
|
root.addView(prompt_field, _match_wrap_params())
|
||||||
|
|
||||||
|
builder = AlertDialogBuilder(activity)
|
||||||
|
builder.set_title("Summarize Chat")
|
||||||
|
builder.set_view(root)
|
||||||
|
|
||||||
|
def on_positive(bld, which):
|
||||||
|
try:
|
||||||
|
count = int(str(count_field.getText()).strip() or DEFAULT_MSG_COUNT)
|
||||||
|
except ValueError:
|
||||||
|
count = DEFAULT_MSG_COUNT
|
||||||
|
try:
|
||||||
|
offset = int(str(offset_field.getText()).strip() or DEFAULT_MSG_OFFSET)
|
||||||
|
except ValueError:
|
||||||
|
offset = DEFAULT_MSG_OFFSET
|
||||||
|
count = max(MIN_MSG_COUNT, min(MAX_MSG_COUNT, count))
|
||||||
|
offset = max(0, offset)
|
||||||
|
prompt = str(prompt_field.getText()).strip()
|
||||||
|
bld.dismiss()
|
||||||
|
try:
|
||||||
|
on_submit(count, offset, prompt)
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error("summarize_dialog.on_submit", exc)
|
||||||
|
BulletinHelper.show_error(f"Summarize failed to start: {exc}", fragment)
|
||||||
|
|
||||||
|
def on_negative(bld, which):
|
||||||
|
bld.dismiss()
|
||||||
|
|
||||||
|
builder.set_positive_button("Summarize", on_positive)
|
||||||
|
builder.set_negative_button("Cancel", on_negative)
|
||||||
|
builder.show()
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Theme color lookup, so plugin dialogs follow the client's active theme
|
||||||
|
(light/dark/custom) instead of hardcoded colors.
|
||||||
|
"""
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def color(key: str, fallback: int) -> int:
|
||||||
|
"""Resolves a Theme.key_* color by name, falling back to a hardcoded
|
||||||
|
ARGB int if the key doesn't exist on this SDK build or theme lookup
|
||||||
|
fails for any reason.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from org.telegram.ui.ActionBar import Theme
|
||||||
|
|
||||||
|
return Theme.getColor(getattr(Theme, key))
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostics.record_error(f"theme.color:{key}", exc)
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
class DialogColors:
|
||||||
|
"""Snapshot of the colors relevant to a plain dialog body, resolved once
|
||||||
|
per dialog build. Fallbacks are Telegram's default dark-theme values.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.background = color("key_dialogBackground", -15395563)
|
||||||
|
self.primary_text = color("key_dialogTextBlack", -14606047)
|
||||||
|
self.secondary_text = color("key_dialogTextGray3", -7829368)
|
||||||
|
self.hint_text = color("key_windowBackgroundWhiteHintText", self.secondary_text)
|
||||||
|
self.input_text = color("key_windowBackgroundWhiteBlackText", self.primary_text)
|
||||||
|
self.input_line = color("key_dialogInputField", self.secondary_text)
|
||||||
|
self.input_line_active = color(
|
||||||
|
"key_dialogInputFieldActivated",
|
||||||
|
color("key_windowBackgroundWhiteBlueText", -14575885),
|
||||||
|
)
|
||||||
|
self.cursor = self.input_line_active
|
||||||
Executable
+169
@@ -0,0 +1,169 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Publishes a built .plugin file to a Gitea release.
|
||||||
|
|
||||||
|
Creates the release for the given tag if it does not exist, replaces any
|
||||||
|
same-named asset already attached, then uploads the artifact. Re-running is
|
||||||
|
safe: the release is reused and the asset is replaced rather than duplicated.
|
||||||
|
|
||||||
|
Stdlib only, so it runs on any runner image without a pip install step.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
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).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message: str) -> "None":
|
||||||
|
print(f"error: {message}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def request(
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
token: str,
|
||||||
|
body: Optional[bytes] = None,
|
||||||
|
content_type: Optional[str] = None,
|
||||||
|
) -> Tuple[int, bytes]:
|
||||||
|
"""Performs one API call, returning (status, body) instead of raising."""
|
||||||
|
req = urllib.request.Request(url, data=body, method=method)
|
||||||
|
req.add_header("Authorization", f"token {token}")
|
||||||
|
req.add_header("Accept", "application/json")
|
||||||
|
if content_type:
|
||||||
|
req.add_header("Content-Type", content_type)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as response:
|
||||||
|
return response.status, response.read()
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
return exc.code, exc.read()
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
fail(f"{method} {url} failed: {exc.reason}")
|
||||||
|
raise # unreachable, keeps type checkers quiet
|
||||||
|
|
||||||
|
|
||||||
|
def find_release(api: str, tag: str, token: str) -> Optional[dict]:
|
||||||
|
status, payload = request("GET", f"{api}/releases/tags/{tag}", token)
|
||||||
|
if status == 404:
|
||||||
|
return None
|
||||||
|
if status != 200:
|
||||||
|
fail(f"looking up release {tag} returned HTTP {status}: {payload[:400]!r}")
|
||||||
|
return json.loads(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def create_release(api: str, tag: str, token: str, commitish: str, notes: str) -> dict:
|
||||||
|
body = json.dumps(
|
||||||
|
{
|
||||||
|
"tag_name": tag,
|
||||||
|
"target_commitish": commitish,
|
||||||
|
"name": tag,
|
||||||
|
"body": notes,
|
||||||
|
"draft": False,
|
||||||
|
"prerelease": False,
|
||||||
|
}
|
||||||
|
).encode()
|
||||||
|
status, payload = request("POST", f"{api}/releases", token, body, "application/json")
|
||||||
|
if status != 201:
|
||||||
|
fail(f"creating release {tag} returned HTTP {status}: {payload[:400]!r}")
|
||||||
|
return json.loads(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_existing_assets(api: str, release_id: int, name: str, token: str) -> int:
|
||||||
|
"""Removes same-named assets, since Gitea keeps duplicates side by side."""
|
||||||
|
status, payload = request("GET", f"{api}/releases/{release_id}/assets", token)
|
||||||
|
if status != 200:
|
||||||
|
fail(f"listing assets returned HTTP {status}: {payload[:400]!r}")
|
||||||
|
removed = 0
|
||||||
|
for asset in json.loads(payload) or []:
|
||||||
|
if asset.get("name") != name:
|
||||||
|
continue
|
||||||
|
status, payload = request(
|
||||||
|
"DELETE", f"{api}/releases/{release_id}/assets/{asset['id']}", token
|
||||||
|
)
|
||||||
|
if status not in (204, 404):
|
||||||
|
fail(f"deleting asset {asset['id']} returned HTTP {status}: {payload[:400]!r}")
|
||||||
|
removed += 1
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
def upload_asset(api: str, release_id: int, path: Path, name: str, token: str) -> dict:
|
||||||
|
"""Uploads the artifact as multipart/form-data."""
|
||||||
|
boundary = uuid.uuid4().hex
|
||||||
|
mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
|
||||||
|
payload = b"".join(
|
||||||
|
[
|
||||||
|
f"--{boundary}\r\n".encode(),
|
||||||
|
f'Content-Disposition: form-data; name="attachment"; filename="{name}"\r\n'.encode(),
|
||||||
|
f"Content-Type: {mime}\r\n\r\n".encode(),
|
||||||
|
path.read_bytes(),
|
||||||
|
f"\r\n--{boundary}--\r\n".encode(),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
url = f"{api}/releases/{release_id}/assets?name={urllib.parse.quote(name)}"
|
||||||
|
status, response = request(
|
||||||
|
"POST", url, token, payload, f"multipart/form-data; boundary={boundary}"
|
||||||
|
)
|
||||||
|
if status != 201:
|
||||||
|
fail(f"uploading {name} returned HTTP {status}: {response[:400]!r}")
|
||||||
|
return json.loads(response)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Optional[list] = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--api", required=True, help="repo API base, e.g. https://host/api/v1/repos/owner/repo")
|
||||||
|
parser.add_argument("--tag", required=True, help="release tag")
|
||||||
|
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("--asset-name", default="", help="attachment name; defaults to the file name")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
token = os.environ.get("GITEA_TOKEN") or os.environ.get("GITHUB_TOKEN") or ""
|
||||||
|
if not token:
|
||||||
|
fail("GITEA_TOKEN is not set")
|
||||||
|
|
||||||
|
artifact = Path(args.artifact)
|
||||||
|
if not artifact.is_file() or artifact.stat().st_size == 0:
|
||||||
|
fail(f"artifact {artifact} is missing or empty")
|
||||||
|
asset_name = args.asset_name or artifact.name
|
||||||
|
|
||||||
|
api = args.api.rstrip("/")
|
||||||
|
version = json.loads((REPO_ROOT / "plugin.json").read_text(encoding="utf-8"))["version"]
|
||||||
|
|
||||||
|
release = find_release(api, args.tag, token)
|
||||||
|
if release is None:
|
||||||
|
notes = (
|
||||||
|
f"Automated build of version {version}.\n\n"
|
||||||
|
"Install by opening the attached `.plugin` file in exteraGram."
|
||||||
|
)
|
||||||
|
release = create_release(api, args.tag, token, args.commitish, notes)
|
||||||
|
print(f"created release {args.tag}")
|
||||||
|
else:
|
||||||
|
print(f"reusing existing release {args.tag}")
|
||||||
|
|
||||||
|
release_id = int(release["id"])
|
||||||
|
removed = delete_existing_assets(api, release_id, asset_name, token)
|
||||||
|
if removed:
|
||||||
|
print(f"removed {removed} previous copy/copies of {asset_name}")
|
||||||
|
|
||||||
|
asset = upload_asset(api, release_id, artifact, asset_name, token)
|
||||||
|
print(f"uploaded {asset_name} ({artifact.stat().st_size} bytes) -> {asset.get('browser_download_url', '')}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+1631
-61
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user