diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..dc3e046 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,76 @@ +name: Publish plugin + +# Builds dist/.plugin and publishes it to the Releases tab. +# +# Triggers: +# push a v* tag -> release named after that tag +# manual dispatch -> release named v +# +# 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 }}" diff --git a/README.md b/README.md index 1418811..8bff6be 100644 --- a/README.md +++ b/README.md @@ -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 src/ the plugin, written as an ordinary Python package main.py entry module — exactly one BasePlugin subclass, absolute imports only - greeting/ sample package (proves relative + cross-module imports survive bundling) - textutil.py sample top-level sibling module + config.py setting keys, defaults and tunables + 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/ bundle.py compiles plugin.json + src/ into dist/.plugin 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 + 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) 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/` 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 1. Edit `plugin.json` (id, name, description, etc.). diff --git a/plugin.json b/plugin.json index b1fc013..2e45f47 100644 --- a/plugin.json +++ b/plugin.json @@ -1,11 +1,11 @@ { - "id": "hello_bundle", - "name": "Hello Bundle", - "description": "Multi-file exteraGram plugin built by tools/bundle.py", - "author": "@yourhandle", + "id": "chat_summaries", + "name": "Chat Summaries", + "description": "Summarize the last N messages (with offset) in any chat, group, channel, or topic using AI.", + "author": "@lyuksovannyy", "version": "1.0.0", "icon": "exteraPlugins/1", "app_version": ">=12.5.1", "sdk_version": ">=1.4.4.3", - "requirements": [] + "requirements": ["requests", "debugpy"] } diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..02665f4 --- /dev/null +++ b/src/config.py @@ -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 diff --git a/src/diagnostics.py b/src/diagnostics.py new file mode 100644 index 0000000..3c962e7 --- /dev/null +++ b/src/diagnostics.py @@ -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() diff --git a/src/greeting/__init__.py b/src/greeting/__init__.py deleted file mode 100644 index f0d6578..0000000 --- a/src/greeting/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .template import render, TemplateError diff --git a/src/greeting/template.py b/src/greeting/template.py deleted file mode 100644 index 5ccc0e9..0000000 --- a/src/greeting/template.py +++ /dev/null @@ -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}") diff --git a/src/hooks/__init__.py b/src/hooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/hooks/restore_hook.py b/src/hooks/restore_hook.py new file mode 100644 index 0000000..9c7c07f --- /dev/null +++ b/src/hooks/restore_hook.py @@ -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)") diff --git a/src/hooks/unread_hook.py b/src/hooks/unread_hook.py new file mode 100644 index 0000000..a45a672 --- /dev/null +++ b/src/hooks/unread_hook.py @@ -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 diff --git a/src/main.py b/src/main.py index f24e0fa..381b0d5 100644 --- a/src/main.py +++ b/src/main.py @@ -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 ui.settings import Header, Input, Text -from greeting import render, TemplateError +from client_utils import get_last_fragment +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): - def on_plugin_load(self): - log("hello_bundle loaded") - self.add_on_send_message_hook() +class ChatSummariesPlugin(BasePlugin): + def __init__(self): + super().__init__() + 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): - log("hello_bundle unloaded") + def _get_summary_db(self): + """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): return [ - Header(text="Hello Bundle"), + Header(text="AI Endpoint"), Input( - key="template", - text="Greeting template", - default=DEFAULT_TEMPLATE, - subtext="Use {name} as placeholder.", + key=SETTING_CUSTOM_BASE_URL, + text="Base URL", + default="", + subtext="Any OpenAI-compatible API, e.g. https://api.openai.com/v1, https://openrouter.ai/api/v1, or http://:11434/v1 for Ollama.", 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="Example", - subtext=".hello alice smith -> Hello, Alice Smith!", + text="About", + 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", ), ] - def on_send_message_hook(self, account, params): - message = getattr(params, "message", 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 " - else: + def _on_copy_diagnostics(self, context: Dict[str, Any]) -> None: + fragment = context.get("fragment") if isinstance(context, dict) else None + try: + from android_utils import copy_to_clipboard + + copy_to_clipboard(diagnostics.get_report()) + 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: - params.message = render(self.get_setting("template", DEFAULT_TEMPLATE), name) - except TemplateError as exc: - params.message = "Bad template: " + str(exc) - return HookResult(strategy=HookStrategy.MODIFY, params=params) + 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) diff --git a/src/providers/__init__.py b/src/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/providers/base.py b/src/providers/base.py new file mode 100644 index 0000000..b439967 --- /dev/null +++ b/src/providers/base.py @@ -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}") diff --git a/src/providers/dispatcher.py b/src/providers/dispatcher.py new file mode 100644 index 0000000..da39108 --- /dev/null +++ b/src/providers/dispatcher.py @@ -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() diff --git a/src/providers/streaming.py b/src/providers/streaming.py new file mode 100644 index 0000000..4f40bc0 --- /dev/null +++ b/src/providers/streaming.py @@ -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 `` 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_CLOSE = "" + +# ("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 ... 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 diff --git a/src/services/__init__.py b/src/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/message_fetcher.py b/src/services/message_fetcher.py new file mode 100644 index 0000000..5efa9d5 --- /dev/null +++ b/src/services/message_fetcher.py @@ -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 diff --git a/src/services/rich_injector.py b/src/services/rich_injector.py new file mode 100644 index 0000000..af78c3f --- /dev/null +++ b/src/services/rich_injector.py @@ -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, 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) diff --git a/src/services/rich_markdown.py b/src/services/rich_markdown.py new file mode 100644 index 0000000..46e2d00 --- /dev/null +++ b/src/services/rich_markdown.py @@ -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`+)(?P.+?)(?P=code)" + r"|\[(?P[^\]]+)\]\((?P[^)\s]+)\)" + r"|(?P\*\*|__)(?P.+?)(?P=bold)" + r"|(?P~~)(?P.+?)(?P=strike)" + r"|(?P[*_])(?P[^*_]+?)(?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 diff --git a/src/services/summary_db.py b/src/services/summary_db.py new file mode 100644 index 0000000..3e2ce59 --- /dev/null +++ b/src/services/summary_db.py @@ -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"], + ) diff --git a/src/services/transcript.py b/src/services/transcript.py new file mode 100644 index 0000000..1e82413 --- /dev/null +++ b/src/services/transcript.py @@ -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///` (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}" diff --git a/src/textutil.py b/src/textutil.py deleted file mode 100644 index 7c3107b..0000000 --- a/src/textutil.py +++ /dev/null @@ -1,2 +0,0 @@ -def normalize(name: str) -> str: - return " ".join(w[:1].upper() + w[1:] for w in name.split()) diff --git a/src/ui/__init__.py b/src/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/summarize_dialog.py b/src/ui/summarize_dialog.py new file mode 100644 index 0000000..c68d13a --- /dev/null +++ b/src/ui/summarize_dialog.py @@ -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() + + diff --git a/src/ui/theme.py b/src/ui/theme.py new file mode 100644 index 0000000..73a9e08 --- /dev/null +++ b/src/ui/theme.py @@ -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 diff --git a/tools/release.py b/tools/release.py new file mode 100755 index 0000000..98df88f --- /dev/null +++ b/tools/release.py @@ -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 --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()) diff --git a/tools/verify_bundle.py b/tools/verify_bundle.py index 2dbc171..392ff61 100755 --- a/tools/verify_bundle.py +++ b/tools/verify_bundle.py @@ -8,11 +8,10 @@ from __future__ import annotations import ast import json import sys -import traceback +import tempfile import types -from dataclasses import dataclass -from enum import Enum, auto from pathlib import Path +from typing import Any, Callable, Dict, List, Optional REPO_ROOT = Path(__file__).resolve().parent.parent PLUGIN_JSON = REPO_ROOT / "plugin.json" @@ -31,33 +30,58 @@ def check(name: str, condition: bool, detail: str = "") -> None: _FAILURES.append(name) -class HookStrategy(Enum): - DEFAULT = auto() - MODIFY = auto() - CANCEL = auto() - MODIFY_FINAL = auto() +LOG_LINES: List[str] = [] +MENU_ITEMS: List[Any] = [] +BULLETINS: List[tuple] = [] +HOOKS_INSTALLED: List[Any] = [] +LAST_REQUEST: Dict[str, Any] = {} -@dataclass -class HookResult: - strategy: HookStrategy = HookStrategy.DEFAULT - params: object = None +class MenuItemType: + MESSAGE_CONTEXT_MENU = "MESSAGE_CONTEXT_MENU" + DRAWER_MENU = "DRAWER_MENU" + MAIN_MENU = "MAIN_MENU" + CHAT_ACTION_MENU = "CHAT_ACTION_MENU" + PROFILE_ACTION_MENU = "PROFILE_ACTION_MENU" -LOG_LINES: list[str] = [] +class MenuItemData: + def __init__(self, menu_type=None, text="", subtext="", icon=None, on_click=None, **kwargs): + self.menu_type = menu_type + self.text = text + self.subtext = subtext + self.icon = icon + self.on_click = on_click + self.__dict__.update(kwargs) + + +class MethodHook: + def before_hooked_method(self, param): + pass + + def after_hooked_method(self, param): + pass class BasePlugin: def __init__(self): - self._settings = {} + self._settings: Dict[str, Any] = {} def get_setting(self, key, default=None): return self._settings.get(key, default) - def set_setting(self, key, value): + def set_setting(self, key, value, reload_settings=False): self._settings[key] = value - def add_on_send_message_hook(self): + def add_menu_item(self, item): + MENU_ITEMS.append(item) + return f"menu_item_{len(MENU_ITEMS)}" + + def hook_method(self, method, hook_instance, priority=0): + HOOKS_INSTALLED.append((method, hook_instance)) + return f"hook_ref_{len(HOOKS_INSTALLED)}" + + def unhook_method(self, ref): pass def on_plugin_load(self): @@ -79,31 +103,585 @@ class _SettingsRow: self.__dict__.update(kwargs) +class _JavaList(list): + """Python list that also answers the Java ArrayList calls our code makes.""" + + def add(self, *args): + if len(args) == 2: + self.insert(int(args[0]), args[1]) + else: + self.append(args[0]) + return True + + def size(self): + return len(self) + + def get(self, index): + return self[index] + + def set(self, index, value): + previous = self[index] + self[index] = value + return previous + + def remove(self, index): + return self.pop(int(index)) + + +class _JavaArrayList: + """A Java ArrayList as Chaquopy exposes it: size()/get() but NOT iterable. + + list()/for-in over one raises TypeError on-device, so the fake withholds + the iterator protocol to keep that mistake failing here. + """ + + def __init__(self, items=()): + self._items = list(items) + + def size(self): + return len(self._items) + + def get(self, index): + return self._items[index] + + def add(self, item): + self._items.append(item) + return True + + +class _TLObj: + """Duck-typed stand-in for a TLRPC/TL_iv object: any attribute is settable. + + `blocks`/`items`/`texts` start as list-likes because the production code + calls `.add()` on them without constructing them first, exactly as the Java + classes pre-initialize their ArrayList fields. + """ + + def __init__(self, tl_name): + self.tl_name = tl_name + self.blocks = _JavaList() + self.items = _JavaList() + self.texts = _JavaList() + self.flags = 0 + self.flags2 = 0 + + def __repr__(self): + return f"<{self.tl_name}>" + + +def _tl_iv_factory(name): + return lambda: _TLObj(name) + + +class _FakeMessageObject: + """Mirrors the MessageObject fields/behaviour the injector relies on.""" + + TYPE_RICH_MESSAGE = 36 + + def __init__(self, account, message, generate_layout=False, check_media=False): + self.currentAccount = account + self.messageOwner = message + self.richLayout = None + self.isOutOwnerCached = None + self.type = None + self.messageText = "" + self.updateMessageText() + self.setType() + + def updateMessageText(self): + rich = getattr(self.messageOwner, "rich_message", None) + self.messageText = "" if rich is not None else getattr(self.messageOwner, "message", "") + + def setType(self): + rich = getattr(self.messageOwner, "rich_message", None) + self.type = self.TYPE_RICH_MESSAGE if rich is not None else 0 + + def isOutOwner(self): + if self.isOutOwnerCached is not None: + return self.isOutOwnerCached + return bool(getattr(self.messageOwner, "out", False)) + + def getId(self): + return getattr(self.messageOwner, "id", 0) + + +class _FakeMessagesController: + """Per-account user cache; rejects non-positive ids the way getUser does.""" + + _instances = {} + + def __init__(self): + self.users = {} + + @classmethod + def getInstance(cls, account): + return cls._instances.setdefault(account, cls()) + + def putUser(self, user, from_cache): + self.users[int(user.id)] = user + + def getUser(self, user_id): + user_id = int(user_id) + if user_id <= 0: + return None + return self.users.get(user_id) + + +class _FakeRequestCallback: + """Mirrors client_utils.RequestCallback: the Java-listener wrapper. + On-device, send_request hands its second argument to Java. A bare Python + function is accepted but never invoked, so the response is silently + dropped; only a RequestCallback-wrapped callable runs. The fake therefore + refuses anything else, so that mistake fails here instead of on the phone. + """ + + def __init__(self, fn): + self.fn = fn + + def run(self, response, error): + return self.fn(response, error) + + +def _fake_send_request(request, callback, *, account=None): + if not isinstance(callback, _FakeRequestCallback): + raise TypeError( + "send_request callback must be wrapped in RequestCallback(...); " + f"got {type(callback).__name__}" + ) + callback.run(None, None) + return 1 + + +_KNOWN_METHODS = { + "org.telegram.ui.LaunchActivity": ["dispatchTouchEvent"], + "org.telegram.ui.ChatActivity": [ + "onPageDownClicked", + "updateMessagesVisiblePart", + "didReceivedNotification_messagesDidLoad", + ], +} +_KNOWN_CONSTRUCTOR_COUNTS = { + "org.telegram.ui.Cells.ChatUnreadCell": 1, +} + + +class _FakeJavaMethod: + """Simulates a resolved java.lang.reflect.Method for a known method name.""" + + def __init__(self, class_name, method_name): + self.class_name = class_name + self.method_name = method_name + + def getName(self): + return self.method_name + + +class _FakeJavaConstructor: + def __init__(self, class_name): + self.class_name = class_name + + +class _FakeJavaReflectClass: + """Simulates the real java.lang.Class object returned by .getClass().""" + + def __init__(self, name): + self._name = name + + def getDeclaredMethods(self): + return [_FakeJavaMethod(self._name, m) for m in _KNOWN_METHODS.get(self._name, [])] + + def getMethods(self): + return self.getDeclaredMethods() + + def getDeclaredConstructors(self): + count = _KNOWN_CONSTRUCTOR_COUNTS.get(self._name, 0) + return [_FakeJavaConstructor(self._name) for _ in range(count)] + + +class _FakeChaquopyClassWrapper: + """Simulates what hook_utils.find_class(...) actually returns on-device: + a Chaquopy Python-side class wrapper, NOT a java.lang.Class. It exposes + no getDeclaredMethod(s) directly -- only .getClass() reaches the real + reflection object. A fake that skipped this distinction (returning None, + or returning something with getDeclaredMethods directly) would have + hidden the real on-device bug where code called getDeclaredMethod(...) + straight on find_class(...)'s result. + """ + + def __init__(self, name): + self._name = name + + def getClass(self): + return _FakeJavaReflectClass(self._name) + + +def fake_find_class(name: str): + return _FakeChaquopyClassWrapper(name) + + +def fake_get_private_field(obj, field_name): + return None + + +class FakeBulletinHelper: + @staticmethod + def show_info(message, fragment=None): + BULLETINS.append(("info", message)) + + @staticmethod + def show_error(message, fragment=None): + BULLETINS.append(("error", message)) + + @staticmethod + def show_success(message, fragment=None): + BULLETINS.append(("success", message)) + + +class FakeAlertDialogBuilder: + ALERT_TYPE_MESSAGE = 0 + ALERT_TYPE_SPINNER = 1 + BUTTON_POSITIVE = 1 + BUTTON_NEGATIVE = 2 + + def __init__(self, activity, progress_style=0, resources_provider=None): + self.activity = activity + self._view = None + + def set_title(self, *a, **k): + return self + + def set_message(self, *a, **k): + return self + + def set_view(self, view, height=-2): + self._view = view + return self + + def set_positive_button(self, text, listener=None): + self._positive = listener + return self + + def set_negative_button(self, text, listener=None): + self._negative = listener + return self + + def set_cancelable(self, *a, **k): + return self + + def create(self): + return self + + def show(self): + return self + + def dismiss(self): + pass + + +def _java_widget_stub(name): + def __init__(self, *args, **kwargs): + self._children = [] + self._text = "" + + def setOrientation(self, *a, **k): + pass + + def setGravity(self, *a, **k): + pass + + def setPadding(self, *a, **k): + pass + + def addView(self, view, *a, **k): + self._children.append(view) + + def setText(self, text): + self._text = text + + def getText(self): + return self._text + + def setHint(self, *a, **k): + pass + + def setSingleLine(self, *a, **k): + pass + + def setMinLines(self, *a, **k): + pass + + def setMaxLines(self, *a, **k): + pass + + def setTextSize(self, *a, **k): + pass + + def setTextColor(self, *a, **k): + pass + + def setHintTextColor(self, *a, **k): + pass + + def setLineColors(self, *a, **k): + pass + + def setCursorColor(self, *a, **k): + pass + + def setBackgroundColor(self, *a, **k): + pass + + return type( + name, + (object,), + { + "__init__": __init__, + "setOrientation": setOrientation, + "setGravity": setGravity, + "setPadding": setPadding, + "addView": addView, + "setText": setText, + "getText": getText, + "setHint": setHint, + "setSingleLine": setSingleLine, + "setMinLines": setMinLines, + "setMaxLines": setMaxLines, + "setTextSize": setTextSize, + "setTextColor": setTextColor, + "setHintTextColor": setHintTextColor, + "setLineColors": setLineColors, + "setCursorColor": setCursorColor, + "setBackgroundColor": setBackgroundColor, + }, + ) + + def install_fakes() -> None: base_plugin_mod = types.ModuleType("base_plugin") base_plugin_mod.BasePlugin = BasePlugin - base_plugin_mod.HookResult = HookResult - base_plugin_mod.HookStrategy = HookStrategy + base_plugin_mod.MenuItemData = MenuItemData + base_plugin_mod.MenuItemType = MenuItemType + base_plugin_mod.MethodHook = MethodHook sys.modules["base_plugin"] = base_plugin_mod android_utils_mod = types.ModuleType("android_utils") android_utils_mod.log = fake_log + android_utils_mod.run_on_ui_thread = lambda fn, delay=0: fn() + android_utils_mod.copy_to_clipboard = lambda text: None + android_utils_mod.OnClickListener = lambda fn: fn + android_utils_mod.OnLongClickListener = lambda fn: fn sys.modules["android_utils"] = android_utils_mod + hook_utils_mod = types.ModuleType("hook_utils") + hook_utils_mod.find_class = fake_find_class + hook_utils_mod.get_private_field = fake_get_private_field + hook_utils_mod.set_private_field = lambda obj, field, value: False + sys.modules["hook_utils"] = hook_utils_mod + + client_utils_mod = types.ModuleType("client_utils") + client_utils_mod.PLUGINS_QUEUE = "pluginsQueue" + client_utils_mod.run_on_queue = lambda fn, queue=None, delay=0: fn() + client_utils_mod.get_last_fragment = lambda: None + client_utils_mod.get_messages_controller = lambda: None + client_utils_mod.get_selected_account = lambda: 0 + client_utils_mod.RequestCallback = _FakeRequestCallback + client_utils_mod.send_request = _fake_send_request + sys.modules["client_utils"] = client_utils_mod + + # summary_db writes a real SQLite file; point it at a scratch dir so the + # harness exercises the actual schema and queries. + file_utils_mod = types.ModuleType("file_utils") + file_utils_mod.get_files_dir = lambda: tempfile.mkdtemp(prefix="chat_summaries_verify_") + sys.modules["file_utils"] = file_utils_mod + ui_mod = types.ModuleType("ui") ui_mod.__path__ = [] sys.modules["ui"] = ui_mod ui_settings_mod = types.ModuleType("ui.settings") - for cls_name in ("Header", "Input", "Text", "Switch"): + for cls_name in ("Header", "Divider", "Switch", "Selector", "Input", "Text", "EditText", "Custom"): setattr(ui_settings_mod, cls_name, type(cls_name, (_SettingsRow,), {})) sys.modules["ui.settings"] = ui_settings_mod ui_mod.settings = ui_settings_mod + ui_alert_mod = types.ModuleType("ui.alert") + ui_alert_mod.AlertDialogBuilder = FakeAlertDialogBuilder + sys.modules["ui.alert"] = ui_alert_mod + ui_mod.alert = ui_alert_mod -class SimpleParams: - def __init__(self, message): - self.message = message + ui_bulletin_mod = types.ModuleType("ui.bulletin") + ui_bulletin_mod.BulletinHelper = FakeBulletinHelper + sys.modules["ui.bulletin"] = ui_bulletin_mod + ui_mod.bulletin = ui_bulletin_mod + + android_mod = types.ModuleType("android") + android_mod.__path__ = [] + sys.modules["android"] = android_mod + + android_view_mod = types.ModuleType("android.view") + android_view_mod.Gravity = type("Gravity", (), {"CENTER": 17}) + android_view_mod.View = type("View", (), {}) + sys.modules["android.view"] = android_view_mod + android_mod.view = android_view_mod + + class _FakeLayoutParams: + def __init__(self, *a, **k): + pass + + android_widget_mod = types.ModuleType("android.widget") + for widget_name in ("LinearLayout", "TextView", "ScrollView", "FrameLayout", "Button"): + cls = _java_widget_stub(widget_name) + cls.VERTICAL = 1 + cls.HORIZONTAL = 0 + cls.LayoutParams = _FakeLayoutParams + setattr(android_widget_mod, widget_name, cls) + sys.modules["android.widget"] = android_widget_mod + android_mod.widget = android_widget_mod + + org_mod = types.ModuleType("org") + org_mod.__path__ = [] + sys.modules["org"] = org_mod + org_telegram_mod = types.ModuleType("org.telegram") + org_telegram_mod.__path__ = [] + sys.modules["org.telegram"] = org_telegram_mod + + tgnet_mod = types.ModuleType("org.telegram.tgnet") + + class _TLRequestStub: + def __init__(self): + self.peer = None + self.offset_id = 0 + self.offset_date = 0 + self.add_offset = 0 + self.limit = 0 + self.max_id = 0 + self.min_id = 0 + self.hash = 0 + self.msg_id = 0 + + tgnet_mod.TLRPC = type( + "TLRPC", + (), + { + "TL_messages_getHistory": staticmethod(_TLRequestStub), + "TL_messages_getReplies": staticmethod(_TLRequestStub), + "TL_message": staticmethod(lambda: _TLObj("TL_message")), + "TL_user": staticmethod(lambda: _TLObj("TL_user")), + "TL_peerUser": staticmethod(lambda: _TLObj("TL_peerUser")), + "TL_peerChat": staticmethod(lambda: _TLObj("TL_peerChat")), + "TL_peerChannel": staticmethod(lambda: _TLObj("TL_peerChannel")), + "TL_messageReplyHeader": staticmethod(lambda: _TLObj("TL_messageReplyHeader")), + "TL_emojiStatus": staticmethod(lambda: _TLObj("TL_emojiStatus")), + }, + ) + sys.modules["org.telegram.tgnet"] = tgnet_mod + org_telegram_mod.tgnet = tgnet_mod + + # TL_iv: instant-view block/rich-text constructors used by rich_markdown. + # Every block type is the same duck-typed bag of attributes; `blocks`, + # `items`, and `texts` behave like Java ArrayList (.add()). + tgnet_tl_mod = types.ModuleType("org.telegram.tgnet.tl") + tgnet_tl_mod.__path__ = [] + + _TL_IV_NAMES = ( + "RichMessage textPlain textEmpty textConcat textBold textItalic textStrike " + "textFixed textUnderline textUrl pageBlockParagraph pageBlockHeading1 " + "pageBlockHeading2 pageBlockHeading3 pageBlockPreformatted pageBlockBlockquote " + "pageBlockDivider pageBlockThinking pageBlockList pageBlockOrderedList " + "pageBlockDetails TL_pageListItemText TL_pageListOrderedItemText" + ).split() + tgnet_tl_mod.TL_iv = type( + "TL_iv", + (), + {name: staticmethod(_tl_iv_factory(name)) for name in _TL_IV_NAMES}, + ) + sys.modules["org.telegram.tgnet.tl"] = tgnet_tl_mod + tgnet_mod.tl = tgnet_tl_mod + + messenger_mod = types.ModuleType("org.telegram.messenger") + messenger_mod.AndroidUtilities = type("AndroidUtilities", (), {"dp": staticmethod(lambda v: v)}) + messenger_mod.R = type("R", (), {}) + messenger_mod.MessageObject = _FakeMessageObject + messenger_mod.MessagesController = _FakeMessagesController + messenger_mod.AnimatedEmojiDrawable = type( + "AnimatedEmojiDrawable", (), {"findDocument": staticmethod(lambda account, doc_id: None)} + ) + sys.modules["org.telegram.messenger"] = messenger_mod + org_telegram_mod.messenger = messenger_mod + + ui_pkg_mod = types.ModuleType("org.telegram.ui") + ui_pkg_mod.__path__ = [] + sys.modules["org.telegram.ui"] = ui_pkg_mod + org_telegram_mod.ui = ui_pkg_mod + + components_mod = types.ModuleType("org.telegram.ui.Components") + edit_text_cls = _java_widget_stub("EditTextBoldCursor") + components_mod.EditTextBoldCursor = edit_text_cls + components_mod.LayoutHelper = type( + "LayoutHelper", + (), + { + "MATCH_PARENT": -1, + "WRAP_CONTENT": -2, + "createLinear": staticmethod(lambda *a, **k: None), + }, + ) + sys.modules["org.telegram.ui.Components"] = components_mod + ui_pkg_mod.Components = components_mod + + actionbar_mod = types.ModuleType("org.telegram.ui.ActionBar") + + class _FakeTheme: + # A handful of real key_* names used by src/ui/theme.py; getattr on + # any key_* name returns the name itself (getColor below just + # echoes a stable fake int derived from it). + key_dialogBackground = "key_dialogBackground" + key_dialogTextBlack = "key_dialogTextBlack" + key_dialogTextGray3 = "key_dialogTextGray3" + key_windowBackgroundWhiteHintText = "key_windowBackgroundWhiteHintText" + key_windowBackgroundWhiteBlackText = "key_windowBackgroundWhiteBlackText" + key_dialogInputField = "key_dialogInputField" + key_dialogInputFieldActivated = "key_dialogInputFieldActivated" + key_windowBackgroundWhiteBlueText = "key_windowBackgroundWhiteBlueText" + + @staticmethod + def getColor(key): + return -1 # any deterministic int; value is never asserted on + + actionbar_mod.Theme = _FakeTheme + sys.modules["org.telegram.ui.ActionBar"] = actionbar_mod + ui_pkg_mod.ActionBar = actionbar_mod + + requests_mod = types.ModuleType("requests") + + # Captures the last request body so checks can assert what was actually + # sent (system prompt, JSON transcript) rather than trusting the caller. + LAST_REQUEST.clear() + + class _FakeResponse: + status_code = 200 + text = "{}" + + def iter_lines(self): + for line in ( + b'data: {"choices":[{"delta":{"reasoning_content":"Thinking."}}]}', + b"", + b'data: {"choices":[{"delta":{"content":"Fake summary."}}]}', + b"data: [DONE]", + ): + yield line + + def close(self): + pass + + def fake_post(url, headers=None, json=None, timeout=None, stream=False): + LAST_REQUEST["url"] = url + LAST_REQUEST["headers"] = headers or {} + LAST_REQUEST["json"] = json or {} + return _FakeResponse() + + requests_mod.post = fake_post + sys.modules["requests"] = requests_mod KEY_TO_DUNDER = { @@ -119,7 +697,7 @@ KEY_TO_DUNDER = { } -def expected_dunders(meta: dict) -> dict: +def expected_dunders(meta: dict) -> Dict[str, Any]: result = {} for key, dunder in KEY_TO_DUNDER.items(): if key not in meta: @@ -147,7 +725,7 @@ def main(argv=None) -> int: source = bundle_path.read_text(encoding="utf-8") modules_before = set(sys.modules) - ns = {"__name__": "hello_bundle"} + ns = {"__name__": "chat_summaries"} exec(compile(source, str(bundle_path), "exec"), ns) new_modules = set(sys.modules) - modules_before @@ -184,8 +762,8 @@ def main(argv=None) -> int: len(plugin_classes) == 1, f"found {len(plugin_classes)}", ) - plugin_cls = plugin_classes[0] if len(plugin_classes) == 1 else None + if plugin_cls is not None: check( "plugin class __module__ matches executed namespace", @@ -194,62 +772,1054 @@ def main(argv=None) -> int: ) plugin = plugin_cls() + MENU_ITEMS.clear() LOG_LINES.clear() plugin.on_plugin_load() + check( "on_plugin_load emits load log line", - any("hello_bundle loaded" in line for line in LOG_LINES), + any("chat_summaries loaded" in line for line in LOG_LINES), f"log lines: {LOG_LINES}", ) - - params = SimpleParams(".hello alice smith") - result = plugin.on_send_message_hook(account=0, params=params) check( - "'.hello alice smith' renders greeting", - params.message == "Hello, Alice Smith!", - f"got {params.message!r}", + "on_plugin_load registers Summarize and Copy diagnostics CHAT_ACTION_MENU items", + len(MENU_ITEMS) == 2 + and all(m.menu_type == MenuItemType.CHAT_ACTION_MENU for m in MENU_ITEMS) + and {m.text for m in MENU_ITEMS} == {"Summarize", "Copy diagnostics"}, + f"menu items: {[(getattr(m, 'text', None), getattr(m, 'menu_type', None)) for m in MENU_ITEMS]}", ) check( - "on_send_message_hook returns MODIFY strategy", - result.strategy == HookStrategy.MODIFY, - f"got {result.strategy}", + "unread_hook installs dispatchTouchEvent, onPageDownClicked, and ChatUnreadCell hooks", + len(plugin.unread_hook._unhooks) == 3, + f"installed hooks: {len(plugin.unread_hook._unhooks)} (expected 3)", ) - plugin.set_setting("template", "{nope}") - params2 = SimpleParams(".hello someone") - plugin.on_send_message_hook(account=0, params=params2) + settings_rows = plugin.create_settings() + settings_keys = {row.key for row in settings_rows if hasattr(row, "key")} + expected_keys = { + "custom_base_url", + "custom_api_key", + "custom_model", + "default_count", + "default_offset", + "global_prompt", + "include_attachments", + } check( - "bad template produces 'Bad template:' message", - params2.message.startswith("Bad template:"), - f"got {params2.message!r}", + "create_settings exposes endpoint and default settings keys", + expected_keys == settings_keys, + f"missing: {expected_keys - settings_keys}; unexpected: {settings_keys - expected_keys}", ) - # main.py imports `render`/`TemplateError` by name, so they live directly - # in the executed namespace — call render() directly to inspect the raised - # traceback (proves linecache registration for bundled, non-file modules). - render = ns.get("render") - template_error = ns.get("TemplateError") - traceback_text = "" - if render is not None and template_error is not None: - try: - render("{nope}", "x") - except template_error: - traceback_text = traceback.format_exc() - check( - "render() traceback names src/greeting/template.py", - "src/greeting/template.py" in traceback_text, - f"traceback:\n{traceback_text}", - ) - check( - "traceback includes the raising source line text", - "unsupported placeholder" in traceback_text, - f"traceback:\n{traceback_text}", - ) + # Per-chat/topic prompt override: falls back to global, then to a + # saved override once one is set for that specific (dialog, topic). + dialog_id, topic_id = -100123456, 7 + global_prompt_key = "global_prompt" + plugin.set_setting(global_prompt_key, "Global default prompt") + before_override = plugin._get_prompt_for(dialog_id, topic_id) + check( + "prompt falls back to global default with no override", + before_override == "Global default prompt", + f"got {before_override!r}", + ) + plugin._save_prompt_for(dialog_id, topic_id, "Chat-specific prompt") + after_override = plugin._get_prompt_for(dialog_id, topic_id) + check( + "per-chat/topic prompt override takes precedence once saved", + after_override == "Chat-specific prompt", + f"got {after_override!r}", + ) + other_topic_prompt = plugin._get_prompt_for(dialog_id, topic_id + 1) + check( + "prompt override is scoped per-topic, not just per-chat", + other_topic_prompt == "Global default prompt", + f"got {other_topic_prompt!r}", + ) + + # Dispatcher: OpenAI path against the faked requests module. FormattedMessage + # is a private bundled module (never in sys.modules), so duck-type instead + # of importing it -- the dispatcher only needs a message with these attrs. + class _FakeMessage: + def __init__(self, sender_name, text, id=0, sender_id=0, reply_to_id=0, + reply_highlight="", attachments=None): + self.sender_name = sender_name + self.text = text + self.id = id + self.date = 1700000000 + self.sender_id = sender_id + self.reply_to_id = reply_to_id + self.reply_highlight = reply_highlight + self.attachments = list(attachments or []) + + fake_messages = [_FakeMessage("Alice", "hello", id=10, sender_id=111)] + plugin.set_setting("custom_base_url", "https://example.test/v1") + plugin.set_setting("custom_api_key", "sk-test") + results = {} + + def on_done(answer, thinking, error): + results["answer"] = answer + results["error"] = error + + plugin.dispatcher.summarize_streaming(fake_messages, "Summarize", lambda k, t: None, on_done) + check( + "dispatcher streams a summary from a faked SSE response", + results.get("error") is None and results.get("answer") == "Fake summary.", + f"results: {results}", + ) + + # The request must carry three separate roles, not one concatenated + # blob: script-owned system prompt, the user's instructions, and the + # anonymized JSON transcript. + sent = LAST_REQUEST.get("json") or {} + roles = [m["role"] for m in sent.get("messages", [])] + contents = [m["content"] for m in sent.get("messages", [])] + check( + "request sends a system prompt, the user prompt, and the transcript", + roles == ["system", "user", "user"] + and "anonymized" in contents[0] + and contents[1] == "Summarize" + and contents[2].startswith('{"messages":['), + f"roles={roles} third={contents[2][:60] if len(contents) > 2 else None!r}", + ) + check( + "the system prompt documents the mention syntax the model must use", + "{userN}" in contents[0] and "{msgN}" in contents[0], + ) + check( + "streaming is requested so deltas arrive incrementally", + sent.get("stream") is True, + f"stream={sent.get('stream')}", + ) + + # Endpoint is mandatory now that there is no built-in provider: an + # unconfigured base URL must produce a clear error, not a crash. + plugin.set_setting("custom_base_url", "") + unconfigured = {} + + def on_unconfigured(answer, thinking, error): + unconfigured["answer"] = answer + unconfigured["error"] = error + + plugin.dispatcher.summarize_streaming( + fake_messages, "Summarize", lambda k, t: None, on_unconfigured + ) + check( + "dispatcher reports a clear error when no base URL is configured", + unconfigured.get("answer") is None + and "base URL" in (unconfigured.get("error") or ""), + f"results: {unconfigured}", + ) + plugin.set_setting("custom_base_url", "https://example.test/v1") + + # MessageFetcher: no controller available -> graceful error, not a crash. + fetch_results = {} + + def on_fetched(messages, error): + fetch_results["messages"] = messages + fetch_results["error"] = error + + plugin.fetcher.fetch_history(dialog_id=dialog_id, count=10, offset=0, callback=on_fetched) + check( + "fetch_history reports an error when no messages controller is available", + fetch_results.get("error") is not None, + f"fetch_results: {fetch_results}", + ) + + # Telegram returns java.util.ArrayList, which Chaquopy exposes without + # the iterator protocol: list()/for-in raise TypeError. The fetcher must + # read it through size()/get(i). + page_results = {} + + def on_page(messages, error): + page_results["messages"] = messages + page_results["error"] = error + + fake_response = _TLObj("TL_messages_messages") + raw_message = _TLObj("TL_message") + raw_message.id = 4242 + raw_message.date = 1700000000 + raw_message.message = "hello from java" + raw_message.from_id = None + fake_response.messages = _JavaArrayList([raw_message]) + fake_response.users = _JavaArrayList() + fake_response.chats = _JavaArrayList() + + plugin.fetcher._handle_response( + fake_response, + None, + [], + on_page, + dialog_id, + 10, + 0, + 0, + 100, + ) + check( + "fetcher reads a non-iterable Java ArrayList response via size()/get()", + page_results.get("error") is None + and len(page_results.get("messages") or []) == 1 + and page_results["messages"][0].text == "hello from java", + f"page_results={page_results}", + ) + + # Provisional anchor, chosen before the fetch returns: with an offset + # the analyzed range ends partway up the history, so the guess has to + # skip that many messages instead of grabbing the newest. + class _AnchorList(list): + def size(self): + return len(self) + + def get(self, index): + return self[index] + + class _AnchorActivity: + def __init__(self, ids): + self.messages = _AnchorList() + for mid in ids: + owner_obj = _TLObj("TL_message") + owner_obj.id = mid + holder = types.SimpleNamespace(messageOwner=owner_obj) + self.messages.append(holder) + + anchor_activity = _AnchorActivity([900, 800, 700, 600]) + check( + "with no offset the provisional anchor is the newest message", + plugin._newest_visible_message_id(anchor_activity) == 900, + f"got {plugin._newest_visible_message_id(anchor_activity)}", + ) + check( + "an offset moves the provisional anchor up the history", + plugin._newest_visible_message_id(anchor_activity, skip=2) == 700, + f"got {plugin._newest_visible_message_id(anchor_activity, skip=2)}", + ) + + # Injected cards carry negative ids and must never be chosen as the + # anchor, or a second summary would anchor to the first one's card. + mixed_anchor = _AnchorActivity([-700007, 900, 800]) + check( + "an existing injected card is never picked as the anchor", + plugin._newest_visible_message_id(mixed_anchor) == 900, + f"got {plugin._newest_visible_message_id(mixed_anchor)}", + ) + + # ------------------------------------------------------------------ + # Bundled-module checks: markdown serialization, SSE streaming, and the + # SQLite store. Reached through the bundle's private registry, since + # bundled modules deliberately never enter sys.modules. + # ------------------------------------------------------------------ + loaded = ns.get("_LOADED") or {} + bundle_import = ns.get("_bundle_load") + + def bundled(name): + if name in loaded: + return loaded[name] + if bundle_import is not None: + try: + return bundle_import(name) + except Exception: + return None + return None + + rich_markdown = bundled("services.rich_markdown") + if rich_markdown is None: + check("services.rich_markdown is importable from the bundle", False, "module not found") + else: + blocks = rich_markdown.markdown_to_blocks( + "# Title\n\n" + "Plain **bold** and `code`.\n\n" + "- one\n- two\n\n" + "1. first\n2. second\n\n" + "> quoted\n\n" + "---\n\n" + "```python\nprint(1)\n```\n" + ) + kinds = [b.tl_name for b in blocks] + check( + "markdown_to_blocks maps every markdown construct to its block type", + kinds + == [ + "pageBlockHeading1", + "pageBlockParagraph", + "pageBlockList", + "pageBlockOrderedList", + "pageBlockBlockquote", + "pageBlockDivider", + "pageBlockPreformatted", + ], + f"got {kinds}", + ) + + code_block = blocks[-1] + check( + "fenced code block keeps its language and body", + code_block.language == "python" and code_block.text.text == "print(1)", + f"language={code_block.language!r} text={getattr(code_block.text, 'text', None)!r}", + ) + + check( + "list blocks carry one item per bullet", + len(blocks[2].items) == 2 and len(blocks[3].items) == 2, + f"bullet items={len(blocks[2].items)} ordered items={len(blocks[3].items)}", + ) + + check( + "ordered list items are numbered from the markdown start value", + [i.num for i in blocks[3].items] == ["1", "2"], + f"nums={[i.num for i in blocks[3].items]}", + ) + + # Inline styling must nest into a RichText tree, not be dropped. + inline = rich_markdown.build_rich_text("a **b** c `d` [e](http://x)") + inline_kinds = [p.tl_name for p in inline.texts] + check( + "inline markdown becomes bold/fixed/url RichText nodes", + "textBold" in inline_kinds and "textFixed" in inline_kinds and "textUrl" in inline_kinds, + f"kinds={inline_kinds}", + ) + + # Reasoning still arriving (no answer yet) animates as a thinking + # block; once the answer starts it is finished, so it collapses into a + # tappable details container that can be revealed and hidden. + reasoning_only = rich_markdown.build_rich_message("", thinking="reasoning", streaming=True) + check( + "reasoning still streaming renders as an animated thinking block", + [b.tl_name for b in reasoning_only.blocks] == ["pageBlockThinking"], + f"blocks={[b.tl_name for b in reasoning_only.blocks]}", + ) + + streaming_rm = rich_markdown.build_rich_message("answer", thinking="reasoning", streaming=True) + details = streaming_rm.blocks[0] + check( + "finished reasoning collapses into a closed details block", + details.tl_name == "pageBlockDetails" + and details.open is False + and details.title.text == rich_markdown.THINKING_TITLE + and len(details.blocks) == 1, + f"first={details.tl_name} open={getattr(details, 'open', None)}", + ) + # `part` drives RichMessageLayout's partial branch, which also draws a + # server-backed "Show more" button; it must stay off for local summaries. + final_rm = rich_markdown.build_rich_message("answer", thinking="reasoning", streaming=False) + check( + "part stays unset so no Show more collapse is drawn", + streaming_rm.part is False and final_rm.part is False, + f"streaming={streaming_rm.part} final={final_rm.part}", + ) + + # Stage feedback: the card is injected on click, before any content + # exists, so a bare status must still render as a shimmer block. + status_rm = rich_markdown.build_rich_message("", status="Fetching messages…", streaming=True) + check( + "status-only RichMessage renders the stage as a thinking block", + len(status_rm.blocks) == 1 and status_rm.blocks[0].tl_name == "pageBlockThinking", + f"blocks={[b.tl_name for b in status_rm.blocks]}", + ) + superseded = rich_markdown.build_rich_message( + "", thinking="real reasoning", status="Fetching messages…", streaming=True + ) + check( + "real reasoning supersedes the status placeholder", + len(superseded.blocks) == 1 + and superseded.blocks[0].text.text == "real reasoning", + f"blocks={[b.tl_name for b in superseded.blocks]}", + ) + answered = rich_markdown.build_rich_message( + "# Answer", status="Summarizing…", streaming=True + ) + check( + "status is dropped once answer text arrives", + [b.tl_name for b in answered.blocks] == ["pageBlockHeading1"], + f"blocks={[b.tl_name for b in answered.blocks]}", + ) + empty_rm = rich_markdown.build_rich_message("", streaming=True) + check( + "an empty RichMessage still carries one block so the cell renders", + len(empty_rm.blocks) == 1, + f"blocks={len(empty_rm.blocks)}", + ) + + transcript_mod = bundled("services.transcript") + if transcript_mod is None: + check("services.transcript is importable from the bundle", False, "module not found") + else: + class _Msg: + def __init__(self, id, sender_id, sender_name, text, + reply_to_id=0, reply_highlight="", attachments=None, + sender_kind="user"): + self.id = id + self.date = 1700000000 + self.sender_id = sender_id + self.sender_name = sender_name + self.sender_kind = sender_kind + self.text = text + self.reply_to_id = reply_to_id + self.reply_highlight = reply_highlight + self.attachments = list(attachments or []) + + convo = [ + _Msg(5001, 111, "Alice", "got new crocs", attachments=["randhash.png"]), + _Msg(5002, 222, "Bob", "cap", reply_to_id=5001, reply_highlight="new"), + _Msg(5003, 111, "Alice", "fr", reply_to_id=4000), + _Msg(5004, 1001234567890, "Some Channel", "announcement", sender_kind="chat"), + ] + payload, mapping = transcript_mod.build_transcript(convo) + parsed = json.loads(payload) + + check( + "transcript numbers messages from 1 and senders from 1 by first appearance", + [(m["id"], m["user"]) for m in parsed["messages"]] == [(1, 1), (2, 2), (3, 1), (4, 3)], + f"got {[(m['id'], m['user']) for m in parsed['messages']]}", + ) + check( + "transcript is compact JSON with no spacing", + " " not in payload.replace("got new crocs", "").replace("randhash.png", ""), + f"payload={payload[:120]}", + ) + check( + "reply_to is remapped to the anonymized id and carries the highlight", + parsed["messages"][1].get("reply_to") == 1 + and parsed["messages"][1].get("reply_highlight") == "new", + f"entry={parsed['messages'][1]}", + ) + check( + "real Telegram ids never reach the transcript", + "5001" not in payload and "111" not in payload and "4000" not in payload, + f"payload={payload}", + ) + + # Attachments are opt-in: message text is always sent, file names only + # when the setting is enabled. + check( + "attachments are omitted unless explicitly included", + "attachments" not in payload, + f"payload={payload}", + ) + with_attachments, _ = transcript_mod.build_transcript(convo, include_attachments=True) + check( + "attachments are listed when included", + json.loads(with_attachments)["messages"][0].get("attachments") == ["randhash.png"], + f"entry={json.loads(with_attachments)['messages'][0]}", + ) + + # A reply whose parent was not analyzed loses meaning, so the parent is + # injected with a negative id marking it as outside the range. + parent = _Msg(4000, 333, "Carol", "original point") + ranged, ranged_map = transcript_mod.build_transcript(convo, extra_by_id={4000: parent}) + ranged_entries = json.loads(ranged)["messages"] + ids = [m["id"] for m in ranged_entries] + check( + "an out-of-range reply parent is injected with a negative id before its child", + ids == [1, 2, -1, 3, 4] and ranged_entries[2]["text"] == "original point", + f"ids={ids}", + ) + check( + "the injected parent is the reply target of its child", + ranged_entries[3].get("reply_to") == -1, + f"entry={ranged_entries[3]}", + ) + check( + "out-of-range parents stay resolvable back to their real id", + ranged_map.real_message_id(-1) == 4000, + f"mapped={ranged_map.real_message_id(-1)}", + ) + + # The model answers with placeholders; substitution supplies the real + # display name and turns citations into tappable deep links. + answer = "{user1} said it broke ({msg1}, {msg2}), {user2} agreed." + rendered = transcript_mod.deanonymize(answer, mapping, -1001234567890) + check( + "user placeholders resolve to the real display name and profile link", + "[Alice](tg://user?id=111)" in rendered + and "[Bob](tg://user?id=222)" in rendered, + f"rendered={rendered}", + ) + check( + "message placeholders become numbered links into this dialog", + "[№2](tg://openmessage?chat_id=1234567890&message_id=5002)" in rendered, + f"rendered={rendered}", + ) + check( + "no placeholder survives substitution", + "{user" not in rendered and "{msg" not in rendered, + f"rendered={rendered}", + ) + check( + "private-chat message links use user_id rather than chat_id", + "user_id=777" in transcript_mod.message_link(777, 42), + transcript_mod.message_link(777, 42), + ) + + # A group or channel sender is not a user: tg://user?id= on one reports + # "user not found", so chat senders need the chat link form. + check( + "a chat/channel sender links to the chat, not a user profile", + "[Some Channel](tg://openmessage?chat_id=1234567890)" + in transcript_mod.deanonymize("{user3} posted", mapping, -1), + transcript_mod.deanonymize("{user3} posted", mapping, -1), + ) + check( + "sender_link uses the user form only for real users", + transcript_mod.sender_link(111, "user") == "tg://user?id=111" + and transcript_mod.sender_link(1001234567890, "chat") + == "tg://openmessage?chat_id=1234567890", + f"user={transcript_mod.sender_link(111, 'user')} " + f"chat={transcript_mod.sender_link(1001234567890, 'chat')}", + ) + + # tg://openmessage carries no thread parameter, so inside a forum it + # lands in the supergroup's global feed. The t.me/c form is the only + # one that opens the right topic. + check( + "a topic message link targets the topic, not the supergroup feed", + transcript_mod.message_link(-1001234567890, 42, 7) + == "https://t.me/c/1234567890/7/42", + transcript_mod.message_link(-1001234567890, 42, 7), + ) + check( + "a group without a topic still uses the openmessage form", + transcript_mod.message_link(-1001234567890, 42, 0) + == "tg://openmessage?chat_id=1234567890&message_id=42", + transcript_mod.message_link(-1001234567890, 42, 0), + ) + topic_payload, topic_mapping = transcript_mod.build_transcript(convo, topic_id=9) + check( + "the topic travels on the mapping so citations open in-topic", + topic_mapping.topic_id == 9 + and "/c/1234567890/9/5002" + in transcript_mod.deanonymize("{msg2}", topic_mapping, -1001234567890), + transcript_mod.deanonymize("{msg2}", topic_mapping, -1001234567890), + ) + check( + "the topic id is not leaked into the transcript payload", + '"topic' not in topic_payload, + f"payload={topic_payload[:80]}", + ) + check( + "an unknown user placeholder degrades to readable text", + transcript_mod.deanonymize("{user99} spoke", mapping, -1) == "someone spoke", + transcript_mod.deanonymize("{user99} spoke", mapping, -1), + ) + check( + "an unknown message placeholder is dropped rather than left dangling", + transcript_mod.deanonymize("see {msg99}", mapping, -1) == "see ", + transcript_mod.deanonymize("see {msg99}", mapping, -1), + ) + + # Streaming safety: this is why placeholders replaced Markdown links. + # A half-received token must not render as literal text. + check( + "a placeholder still being streamed is suppressed, not shown raw", + transcript_mod.strip_partial_placeholder("build broke ({msg1") == "build broke (", + transcript_mod.strip_partial_placeholder("build broke ({msg1"), + ) + check( + "complete placeholders earlier in the text survive stripping", + transcript_mod.strip_partial_placeholder("a {msg1} b {us") == "a {msg1} b ", + transcript_mod.strip_partial_placeholder("a {msg1} b {us"), + ) + check( + "text with no trailing placeholder is left untouched", + transcript_mod.strip_partial_placeholder("all done.") == "all done.", + ) + + # Reasoning is model-authored text too, so it carries placeholders and + # must go through the same substitution -- leaving them raw would show + # the reader the anonymized namespace. + reasoning = "Looking at {user1} and {msg2} to decide." + rendered_reasoning = transcript_mod.deanonymize(reasoning, mapping, -1001234567890) + check( + "reasoning text is substituted, not left with raw placeholders", + "{user" not in rendered_reasoning + and "{msg" not in rendered_reasoning + and "Alice" in rendered_reasoning + and "tg://" in rendered_reasoning, + f"rendered={rendered_reasoning}", + ) + + # Privacy boundary: only text and attachments cross the wire verbatim. + # Names, ids and topic stay local, while the DB keeps the real thing. + check( + "sender names never reach the transcript payload", + "Alice" not in payload and "Bob" not in payload and "Some Channel" not in payload, + f"payload={payload}", + ) + check( + "the mapping retains real names and ids for local substitution", + mapping.user_name(1) == "Alice" + and mapping.real_user_id(1) == 111 + and mapping.real_message_id(2) == 5002 + and mapping.sender_kind(3) == "chat", + f"name={mapping.user_name(1)} id={mapping.real_user_id(1)} kind={mapping.sender_kind(3)}", + ) + check( + "message text and attachment names are the only verbatim fields", + "got new crocs" in payload + and "randhash.png" in json.loads(with_attachments)["messages"][0]["attachments"], + f"payload={payload[:100]}", + ) + + streaming_mod = bundled("providers.streaming") + if streaming_mod is None: + check("providers.streaming is importable from the bundle", False, "module not found") + else: + # Reasoning fields differ per vendor; all three must be recognised. + check( + "reasoning_content and reasoning deltas are classified as thinking", + streaming_mod._extract_delta({"choices": [{"delta": {"reasoning_content": "r"}}]}) == ("thinking", "r") + and streaming_mod._extract_delta({"choices": [{"delta": {"reasoning": "r"}}]}) == ("thinking", "r"), + ) + check( + "content deltas are classified as answer", + streaming_mod._extract_delta({"choices": [{"delta": {"content": "c"}}]}) == ("answer", "c"), + ) + + # A tag split across chunks must not leak into the answer. + splitter = streaming_mod._ThinkTagSplitter() + got = [] + for piece in ["beforerea", "soningafter"]: + got.extend(splitter.feed(piece)) + got.extend(splitter.flush()) + joined = [ + ("thinking", "".join(t for k, t in got if k == "thinking")), + ("answer", "".join(t for k, t in got if k == "answer")), + ] + check( + "think tags split across SSE chunks are reassembled correctly", + joined == [("thinking", "reasoning"), ("answer", "beforeafter")], + f"got {joined}", + ) + + sse_lines = [ + b'data: {"choices":[{"delta":{"content":"hi"}}]}', + b"", + b"data: [DONE]", + b'data: {"choices":[{"delta":{"content":"never"}}]}', + ] + objects = list(streaming_mod.iter_sse_objects(iter(sse_lines))) + check( + "SSE parsing stops at [DONE] and ignores blank lines", + len(objects) == 1, + f"parsed {len(objects)} objects", + ) + + summary_db_mod = bundled("services.summary_db") + if summary_db_mod is None: + check("services.summary_db is importable from the bundle", False, "module not found") + else: + db = summary_db_mod.SummaryDb(str(Path(tempfile.mkdtemp()) / "summaries.db")) + first = db.save(-100, 0, 555, "text one", "thinking one", 42) + rows = db.get_for_scope(-100, 0) + check( + "summary_db persists text, anchor id, and message count", + len(rows) == 1 + and rows[0].anchor_msg_id == 555 + and rows[0].text == "text one" + and rows[0].thinking == "thinking one" + and rows[0].msg_count == 42, + f"rows={rows}", + ) + + # Re-saving the same anchor must update in place: streaming reserves a + # row first and rewrites it when the stream finishes. + second = db.save(-100, 0, 555, "text two", "thinking two", 43) + rows = db.get_for_scope(-100, 0) + check( + "re-saving the same anchor updates the existing row instead of duplicating", + second == first and len(rows) == 1 and rows[0].text == "text two", + f"first={first} second={second} rows={rows}", + ) + + db.save(-100, 7, 555, "topic text", "", 1) + check( + "summaries are scoped per topic, not just per chat", + len(db.get_for_scope(-100, 0)) == 1 and len(db.get_for_scope(-100, 7)) == 1, + f"topic0={len(db.get_for_scope(-100, 0))} topic7={len(db.get_for_scope(-100, 7))}", + ) + + check("summary_db.delete removes a row", db.delete(first) and db.get(first) is None) + + injector = bundled("services.rich_injector") + if injector is None: + check("services.rich_injector is importable from the bundle", False, "module not found") + else: + check( + "mock sender id is positive so getUser can resolve it", + injector.MOCK_SENDER_ID > 0, + f"id={injector.MOCK_SENDER_ID}", + ) + user = injector.register_mock_sender(0) + from org.telegram.messenger import MessagesController # noqa: E402 + + resolved = MessagesController.getInstance(0).getUser(injector.MOCK_SENDER_ID) + check( + "mock sender resolves from the local user cache with a name", + resolved is not None and resolved.first_name == injector.MOCK_SENDER_NAME, + f"resolved={resolved}", + ) + + # Peer subtype drives isOutOwner; a wrong type renders as outgoing. + check( + "peer_for_dialog picks the peer subtype matching the dialog kind", + injector.peer_for_dialog(12345).tl_name == "TL_peerUser" + and injector.peer_for_dialog(-4242).tl_name == "TL_peerChat" + and injector.peer_for_dialog(-1001234567890).tl_name == "TL_peerChannel", + ) + check( + "channel peer id is converted out of dialog-id space", + injector.peer_for_dialog(-1001234567890).channel_id == 1234567890, + f"channel_id={injector.peer_for_dialog(-1001234567890).channel_id}", + ) + + message_object = injector.build_summary_message( + 0, -4242, 0, 555, injector.virtual_id_for(1, 555), "# Answer", "reasoning", True + ) + owner = message_object.messageOwner + check( + "summary message carries rich_message with the type-36 flag bit", + owner.rich_message is not None + and bool(owner.flags2 & injector.FLAGS2_RICH_MESSAGE) + and message_object.type == 36, + f"flags2={owner.flags2} type={message_object.type}", + ) + check( + "summary message is attributed to the mock sender and not outgoing", + owner.from_id.user_id == injector.MOCK_SENDER_ID + and owner.out is False + and message_object.isOutOwner() is False, + ) + check( + "summary message replies to the newest analyzed message", + owner.reply_to.reply_to_msg_id == 555, + f"reply_to={owner.reply_to}", + ) + check( + "virtual ids are negative and stable per (summary, anchor)", + injector.virtual_id_for(1, 555) < 0 + and injector.virtual_id_for(1, 555) == injector.virtual_id_for(1, 555) + and injector.virtual_id_for(2, 555) != injector.virtual_id_for(1, 555), + ) + + # Insertion order: ChatActivity.messages is newest-first, so the + # summary must land at the anchor's index to sit after it on screen. + class _FakeAdapter: + def __init__(self): + self.notified = 0 + + def notifyDataSetChanged(self): + self.notified += 1 + + class _FakeCell: + """Live ChatMessageCell: the row the targeted repaint should hit.""" + + def __init__(self, message_object): + self._message_object = message_object + self.rebound = 0 + self.invalidated = 0 + + def getMessageObject(self): + return self._message_object + + def setMessageObject(self, mo, grouped, pinned_bottom, pinned_top, first): + self._message_object = mo + self.rebound += 1 + + def invalidate(self): + self.invalidated += 1 + + class _FakeDelegate: + def __init__(self): + self.forced = 0 + + def forceUpdate(self, cell, a, b): + self.forced += 1 + + class _FakeListView: + def __init__(self): + self.cells = [] + + def getChildCount(self): + return len(self.cells) + + def getChildAt(self, index): + return self.cells[index] + + class _FakeChatActivity: + def __init__(self, ids): + self.messages = _JavaList() + for mid in ids: + owner_obj = _TLObj("TL_message") + owner_obj.id = mid + self.messages.add(_FakeMessageObject(0, owner_obj)) + self.chatAdapter = _FakeAdapter() + self.chatMessageCellDelegate = _FakeDelegate() + self.chatListView = _FakeListView() + self.scrolled_to = [] + + def getDialogId(self): + return -4242 + + def scrollToMessageId(self, msg_id, from_id, select, load_index, force, unread): + self.scrolled_to.append(msg_id) + + activity = _FakeChatActivity([600, 555, 500]) + check( + "insertion index places the summary directly after its anchor", + injector.find_index_for_anchor(activity.messages, 555) == 1, + f"index={injector.find_index_for_anchor(activity.messages, 555)}", + ) + + # The anchor is the newest ANALYZED message, which with a non-zero + # offset is not the newest message in the chat. The card must land + # there, never at the bottom. + offset_activity = _FakeChatActivity([900, 800, 700, 600, 500]) + check( + "with an offset the card lands at the analyzed range, not the chat bottom", + injector.find_index_for_anchor(offset_activity.messages, 700) == 2, + f"index={injector.find_index_for_anchor(offset_activity.messages, 700)}", + ) + + # Anchor not loaded in this view: falling back to index 0 would put the + # card below messages the summary never saw, which is the bug this + # ordering fallback exists to prevent. + check( + "an unloaded anchor is ordered by id instead of dropping to the bottom", + injector.find_index_for_anchor(offset_activity.messages, 750) == 2, + f"index={injector.find_index_for_anchor(offset_activity.messages, 750)}", + ) + check( + "an anchor older than everything loaded lands at the top of the list", + injector.find_index_for_anchor(offset_activity.messages, 100) + == offset_activity.messages.size(), + f"index={injector.find_index_for_anchor(offset_activity.messages, 100)}", + ) + check( + "an anchor newer than everything loaded lands at the bottom", + injector.find_index_for_anchor(offset_activity.messages, 1000) == 0, + f"index={injector.find_index_for_anchor(offset_activity.messages, 1000)}", + ) + + # The real invariant is positional, not a fixed index: every message + # newer than the anchor stays below the card, every older one above, + # regardless of injected cards already sitting in the list. + def neighbours(activity_obj, anchor): + index = injector.find_index_for_anchor(activity_obj.messages, anchor) + ids = [m.messageOwner.id for m in activity_obj.messages] + real = [i for i in ids if i > 0] + below = [i for i in ids[:index] if i > 0] + above = [i for i in ids[index:] if i > 0] + return below, above, real + + mixed = _FakeChatActivity([900, 800, 700]) + injected_owner = _TLObj("TL_message") + injected_owner.id = -800007 + mixed.messages.add(1, _FakeMessageObject(0, injected_owner)) + below, above, _ = neighbours(mixed, 750) + check( + "an injected card already in the list does not misplace the next one", + all(i > 750 for i in below) and all(i < 750 for i in above), + f"below={below} above={above}", + ) + + # Same invariant across the offset cases, which is what "injected only + # after the latest analyzed message" actually means. + for anchor in (700, 750, 900, 100, 1000): + below, above, _ = neighbours(offset_activity, anchor) + check( + f"no message newer than anchor {anchor} ends up below the card", + all(i > anchor for i in below) and all(i <= anchor for i in above), + f"anchor={anchor} below={below} above={above}", + ) + + virtual_id = injector.virtual_id_for(9, 555) + inj = injector.SummaryInjection(activity, 0, -4242, 0, 555, virtual_id) + inserted = inj.insert(status="Fetching messages…", streaming=True) + check( + "injection inserts one message at the anchor index and refreshes the adapter", + inserted + and activity.messages.size() == 4 + and activity.messages.get(1).messageOwner.id == virtual_id + and activity.chatAdapter.notified >= 1, + f"size={activity.messages.size()} notified={activity.chatAdapter.notified}", + ) + + # The analyzed range can end far up the history, so the card may be + # injected off-screen; the chat has to move to it. The anchor is the + # scroll target because it is a real message the app can resolve, and + # the card sits directly above it. + check( + "inserting the card scrolls the chat to the analyzed range", + activity.scrolled_to == [555], + f"scrolled_to={activity.scrolled_to}", + ) + + before = activity.messages.get(1) + inj.update(answer_markdown="final answer", thinking="reasoning", streaming=False) + after = activity.messages.get(1) + check( + "update swaps in a rebuilt MessageObject rather than mutating in place", + activity.messages.size() == 4 + and after is not before + and after.messageOwner.id == virtual_id + and after.type == 36 + and [b.tl_name for b in after.messageOwner.rich_message.blocks] + == ["pageBlockDetails", "pageBlockParagraph"], + f"size={activity.messages.size()} same={after is before} " + f"blocks={[b.tl_name for b in after.messageOwner.rich_message.blocks]}", + ) + check( + "a streaming update does not scroll again and fight the user", + activity.scrolled_to == [555], + f"scrolled_to={activity.scrolled_to}", + ) + + # Streaming repaints should hit the one visible cell, not rebind every + # row: notifyDataSetChanged() on each delta is what made the card + # flicker and lose scroll position. + activity.chatListView.cells = [_FakeCell(activity.messages.get(1))] + notified_before = activity.chatAdapter.notified + inj.update(answer_markdown="more text", thinking="reasoning", streaming=True) + check( + "a streaming repaint updates the live cell instead of the whole list", + activity.chatMessageCellDelegate.forced == 1 + and activity.chatListView.cells[0].rebound == 1 + and activity.chatAdapter.notified == notified_before, + f"forced={activity.chatMessageCellDelegate.forced} " + f"rebound={activity.chatListView.cells[0].rebound} " + f"notified={activity.chatAdapter.notified} (was {notified_before})", + ) + + # Off-screen card: no cell to repaint, so the adapter must be told. + activity.chatListView.cells = [] + notified_before = activity.chatAdapter.notified + inj.update(answer_markdown="offscreen", thinking="reasoning", streaming=True) + check( + "an off-screen card falls back to a full adapter refresh", + activity.chatAdapter.notified == notified_before + 1, + f"notified={activity.chatAdapter.notified} (was {notified_before})", + ) + + # The card is injected before the fetch, so its anchor starts + # provisional and must be corrected once the real range is known. + reanchor_activity = _FakeChatActivity([600, 555, 500]) + provisional_id = injector.virtual_id_for(0, 600) + reinj = injector.SummaryInjection(reanchor_activity, 0, -4242, 0, 600, provisional_id) + reinj.insert(status="Fetching messages…", streaming=True) + check( + "provisional card lands at the newest message before the fetch", + reanchor_activity.messages.get(0).messageOwner.id == provisional_id, + f"ids={[m.messageOwner.id for m in reanchor_activity.messages]}", + ) + + final_id = injector.virtual_id_for(11, 555) + reinj.reanchor(555, final_id) + ids = [m.messageOwner.id for m in reanchor_activity.messages] + check( + "reanchor moves the card to the real anchor and adopts its virtual id", + reanchor_activity.messages.size() == 4 + and ids.index(final_id) == 1 + and reanchor_activity.messages.get(1).messageOwner.reply_to.reply_to_msg_id == 555, + f"ids={ids}", + ) + check( + "reanchor follows the card to the real end of the analyzed range", + reanchor_activity.scrolled_to == [600, 555], + f"scrolled_to={reanchor_activity.scrolled_to}", + ) + + # Regression: a streaming update after a reanchor used to add a second + # card, because the lookup missed the old id and fell through to add(). + reinj.update(status="Summarizing 98 messages…", streaming=True) + ids = [m.messageOwner.id for m in reanchor_activity.messages] + check( + "updating after a reanchor leaves exactly one summary card", + reanchor_activity.messages.size() == 4 + and ids.count(final_id) == 1 + and provisional_id not in ids, + f"ids={ids}", + ) + + # Re-summarizing a range whose saved card the restore hook already + # re-injected must claim that card, not sit beside it. + collide_activity = _FakeChatActivity([600, 555, 500]) + saved_id = injector.virtual_id_for(12, 555) + injector.restore_saved_summaries( + collide_activity, + 0, + -4242, + 0, + [summary_db_mod.StoredSummary(12, -4242, 0, 555, "previous run", "", 5, 0)], + ) + collide_provisional = injector.virtual_id_for(0, 600) + collide = injector.SummaryInjection(collide_activity, 0, -4242, 0, 600, collide_provisional) + collide.insert(status="Fetching messages…", streaming=True) + collide.reanchor(555, saved_id) + collide.update(status="Summarizing…", streaming=True) + ids = [m.messageOwner.id for m in collide_activity.messages] + check( + "re-summarizing a restored range reuses its card instead of duplicating", + ids.count(saved_id) == 1 and collide_provisional not in ids, + f"ids={ids}", + ) + + # Re-inserting the same virtual id must replace, never duplicate. + inj2 = injector.SummaryInjection(activity, 0, -4242, 0, 555, virtual_id) + inj2.insert(answer_markdown="again", thinking="", streaming=False) + check( + "re-injecting an existing virtual id replaces instead of duplicating", + activity.messages.size() == 4, + f"size={activity.messages.size()}", + ) + + restore_activity = _FakeChatActivity([600, 555, 500]) + restored = injector.restore_saved_summaries( + restore_activity, + 0, + -4242, + 0, + [summary_db_mod.StoredSummary(3, -4242, 0, 555, "saved text", "", 5, 0)], + ) + check( + "stored summaries are re-injected when a chat is reopened", + restored == 1 and restore_activity.messages.size() == 4, + f"restored={restored} size={restore_activity.messages.size()}", + ) + + # Several stored summaries for one chat must each land after their own + # analyzed range, not pile up at one position. + multi_activity = _FakeChatActivity([900, 800, 700, 600, 500]) + injector.restore_saved_summaries( + multi_activity, + 0, + -4242, + 0, + [ + summary_db_mod.StoredSummary(11, -4242, 0, 600, "older range", "", 5, 0), + summary_db_mod.StoredSummary(12, -4242, 0, 800, "newer range", "", 5, 0), + ], + ) + ids = [m.messageOwner.id for m in multi_activity.messages] + positions = {} + for position, msg_id in enumerate(ids): + positions[msg_id] = position + older_card = injector.virtual_id_for(11, 600) + newer_card = injector.virtual_id_for(12, 800) + check( + "each restored summary sits directly above its own anchor", + positions[older_card] == positions[600] - 1 + and positions[newer_card] == positions[800] - 1, + f"ids={ids}", + ) + check( + "restoring several summaries keeps chat messages in order", + [i for i in ids if i > 0] == [900, 800, 700, 600, 500], + f"ids={ids}", + ) check( - "sys.modules gained no entry for bundled module names", - not ({"greeting", "textutil", "main"} & new_modules), - f"leaked: {({'greeting', 'textutil', 'main'} & new_modules)}", + "sys.modules gained no entry for bundled plugin module names", + not ({"main", "config", "services", "providers", "hooks", "ui.summarize_dialog"} & new_modules), + f"leaked: {({'main', 'config', 'services', 'providers', 'hooks', 'ui.summarize_dialog'} & new_modules)}", ) if _FAILURES: