Implement Chat Summaries plugin

Replace the sample plugin with an AI chat summarizer that renders its
output as a native RichMessage injected into the chat.

Summarization pipeline:
- Fetch chat/topic history via paginated TL requests.
- Send a system prompt, the user's instructions and an anonymized JSON
  transcript as three separate roles. Only message text and attachment
  names leave the device; senders and message ids are replaced by
  indices, and out-of-range reply parents are included with negative ids
  so replies keep their context.
- Stream the response over SSE, reading reasoning from reasoning_content,
  reasoning or inline <think> tags.
- Substitute the model's {userN}/{msgN} placeholders back into real names
  and deep links. Placeholders are used instead of Markdown links because
  a half-streamed link renders as broken literal text.

Rendering:
- Compile Markdown into TL_iv page blocks (headings, lists, quotes, code,
  dividers, inline styles) since the app's own converter works off
  Android Spanned spans rather than Markdown source.
- Show progress stages immediately on click, animate reasoning while it
  streams, then collapse it into a tappable details block.
- Attribute the card to a client-side mock sender and insert it directly
  after the newest analyzed message, scrolling it into view.

Persistence:
- Store summaries in SQLite keyed by (dialog, topic, anchor) and
  re-inject them when the chat is reopened.

Also add tools/release.py and a Gitea workflow that builds, verifies and
publishes dist/<id>.plugin to the Releases tab, and extend
tools/verify_bundle.py to 104 offline checks covering the transcript,
streaming, rendering, injection and storage behaviour.
This commit is contained in:
lyuksovannyy committed 2026-09-12 23:52:15 +02:00
1 parent a3b0b264c1
commit 196da742de
27 files changed
+5261 -121

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
name: Publish plugin
# Builds dist/<id>.plugin and publishes it to the Releases tab.
#
# Triggers:
# push a v* tag -> release named after that tag
# manual dispatch -> release named v<version from plugin.json>
#
# dist/ is gitignored, so the artifact is always built here rather than
# committed. The build uses --release because --dev embeds absolute paths from
# whichever machine produced it.
on:
push:
tags:
- "v*"
workflow_dispatch:
permissions:
contents: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Check out sources
uses: actions/checkout@v4
- name: Validate plugin metadata and sources
run: python3 tools/bundle.py --check
- name: Build release bundle
id: build
run: |
set -eu
python3 tools/bundle.py --release
plugin_id=$(python3 -c "import json; print(json.load(open('plugin.json'))['id'])")
version=$(python3 -c "import json; print(json.load(open('plugin.json'))['version'])")
artifact="dist/${plugin_id}.plugin"
test -s "$artifact"
echo "artifact=${artifact}" >> "$GITHUB_OUTPUT"
echo "asset_name=${plugin_id}.plugin" >> "$GITHUB_OUTPUT"
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "built ${artifact} ($(wc -c < "$artifact") bytes)"
- name: Verify the built bundle
# Executes the bundle against faked SDK modules and asserts the
# plugin's observable behaviour. A build that fails this is not
# publishable, so this gates the upload.
run: python3 tools/verify_bundle.py "${{ steps.build.outputs.artifact }}"
- name: Resolve release tag
id: tag
run: |
set -eu
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
tag="${GITHUB_REF_NAME}"
else
tag="v${{ steps.build.outputs.version }}"
fi
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
echo "publishing as ${tag}"
- name: Publish release and upload plugin
env:
# Org-level secret holding a user token with write:repository scope.
# Not using a `||` fallback to GITEA_TOKEN: Gitea's expression engine
# supports only always(), so the operator would not evaluate.
GITEA_TOKEN: ${{ secrets.API_TOKEN }}
run: |
python3 tools/release.py \
--api "${{ github.server_url }}/api/v1/repos/${{ github.repository }}" \
--tag "${{ steps.tag.outputs.tag }}" \
--artifact "${{ steps.build.outputs.artifact }}" \
--asset-name "${{ steps.build.outputs.asset_name }}" \
--commitish "${{ github.sha }}"
+38 -2
View File
@@ -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/<id>.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.).
+5 -5
View File
@@ -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"]
}
+48
View File
@@ -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
+83
View File
@@ -0,0 +1,83 @@
"""Bounded in-memory diagnostics, since this device's logcat does not surface
Chaquopy stdout/stderr or android_utils.log output for this app. Every
exception this plugin would otherwise swallow is recorded here so it is
inspectable from the plugin's settings page ("Copy diagnostics") or, when the
UI is inconvenient, from the mirrored file under the app's files directory.
"""
import traceback
from typing import List
_MAX_ENTRIES = 200
_ENTRIES: List[str] = []
_LOG_PATH = None
# Diagnostics mirror is a debugging aid, not a record to keep: restart the
# file once it passes this size rather than growing it forever.
_MAX_LOG_BYTES = 256 * 1024
def _append(line: str) -> None:
if len(_ENTRIES) >= _MAX_ENTRIES:
_ENTRIES.pop(0)
_ENTRIES.append(line)
try:
from android_utils import log as android_log
android_log(line)
except Exception:
pass
_mirror_to_file(line)
def _mirror_to_file(line: str) -> None:
"""Mirrors one entry to a file under the app's files dir.
This device's logcat does not surface Chaquopy output and the clipboard is
not readable over adb, so a file is the only way to inspect diagnostics
without driving the UI. Truncated once it exceeds _MAX_LOG_BYTES so it
cannot grow without bound.
"""
global _LOG_PATH
if _LOG_PATH is None:
try:
import os
from file_utils import get_files_dir
directory = os.path.join(get_files_dir(), "chat_summaries")
os.makedirs(directory, exist_ok=True)
_LOG_PATH = os.path.join(directory, "diagnostics.log")
except Exception:
_LOG_PATH = ""
if not _LOG_PATH:
return
try:
import os
mode = "a"
if os.path.exists(_LOG_PATH) and os.path.getsize(_LOG_PATH) > _MAX_LOG_BYTES:
mode = "w"
with open(_LOG_PATH, mode, encoding="utf-8") as handle:
handle.write(line + "\n")
except Exception:
pass
def note(message: str) -> None:
"""Records a fact (successful step, resolved value, etc.)."""
_append(f"[fact] {message}")
def record_error(location: str, exc: BaseException) -> None:
"""Records an exception with its full traceback."""
tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
_append(f"[error] {location}: {exc}\n{tb}")
def get_report() -> str:
if not _ENTRIES:
return "No diagnostics recorded yet."
return "\n".join(_ENTRIES)
def clear() -> None:
_ENTRIES.clear()
-1
View File
@@ -1 +0,0 @@
from .template import render, TemplateError
-13
View File
@@ -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}")
View File
Whitespace-only changes.
+157
View File
@@ -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)")
+365
View File
@@ -0,0 +1,365 @@
"""Hooks two independent entry points so holding either opens the summarize
dialog, pre-filled with the chat's actual unread count/offset:
1. ChatActivity.onPageDownClicked() -- the floating "jump to newest" button.
This build exposes no long-click listener on that button; its only
stable hookable entry point is this method. To distinguish a hold from a
tap, touch DOWN/UP timing is captured globally at
LaunchActivity.dispatchTouchEvent and consulted here.
2. ChatUnreadCell -- the inline "N unread messages" divider shown in the
message list. Its constructor is hooked so a click/long-click listener
can be attached directly to each instantiated cell.
Reflection notes (grounded on-device): hook_utils.find_class(...) returns a
Chaquopy class wrapper, not a java.lang.Class -- only .getClass() reaches
getDeclaredMethods()/getDeclaredConstructors(). Methods are looked up by
enumerating getDeclaredMethods() and matching by name rather than by exact
getDeclaredMethod(name, *paramTypes) signature, since resolving the correct
Java parameter-type Class objects from Python is brittle and unnecessary
when the method name alone is unambiguous.
"""
import time
from typing import Any, List, Optional
import diagnostics
from config import LONG_PRESS_SECONDS, MAX_MSG_COUNT
_GESTURE = {"down_at": 0.0, "released_at": 0.0, "consumed": False}
_STALE_GESTURE_SECONDS = 0.75
def _held_duration() -> float:
down_at = _GESTURE["down_at"]
if not down_at:
return 0.0
released_at = _GESTURE["released_at"]
end = released_at or time.monotonic()
if released_at and (time.monotonic() - released_at) > _STALE_GESTURE_SECONDS:
return 0.0
return max(0.0, end - down_at)
def _class_candidates(cls: Any) -> List[Any]:
"""Returns every object that might expose real reflection members, best first."""
candidates: List[Any] = []
if cls is None:
return candidates
try:
inner = cls.getClass()
if inner is not None:
candidates.append(inner)
except Exception:
pass
if not any(cls is c for c in candidates):
candidates.append(cls)
return candidates
def _find_methods_by_name(cls: Any, name: str) -> List[Any]:
"""Enumerates declared/public methods matching `name` across candidates."""
for source in _class_candidates(cls):
for accessor in ("getDeclaredMethods", "getMethods"):
if not hasattr(source, accessor):
continue
try:
methods = getattr(source, accessor)()
except Exception as exc:
diagnostics.record_error(f"_find_methods_by_name:{accessor}", exc)
continue
found = [m for m in methods if _method_name(m) == name]
if found:
return found
return []
def _method_name(method: Any) -> Optional[str]:
try:
return method.getName()
except Exception:
return None
def _find_constructors(cls: Any) -> List[Any]:
for source in _class_candidates(cls):
if not hasattr(source, "getDeclaredConstructors"):
continue
try:
ctors = list(source.getDeclaredConstructors())
except Exception as exc:
diagnostics.record_error("_find_constructors", exc)
continue
if ctors:
return ctors
return []
class UnreadHook:
"""Installs the touch-timing tracker plus both pagedown-button and
unread-divider-cell interceptors.
"""
def __init__(self, plugin):
self.plugin = plugin
self._unhooks: List[Any] = []
self._hooked_view_ids: set = set()
def install(self) -> None:
try:
from base_plugin import MethodHook
from hook_utils import find_class
except Exception as exc:
diagnostics.record_error("unread_hook.install:imports", exc)
return
self._install_touch_tracker(find_class, MethodHook)
self._install_pagedown_hook(find_class, MethodHook)
self._install_unread_cell_hook(find_class, MethodHook)
def _install_touch_tracker(self, find_class, MethodHook) -> None:
gesture_ref = _GESTURE
launch_activity_class = find_class("org.telegram.ui.LaunchActivity")
diagnostics.note(f"unread_hook.install: LaunchActivity found={bool(launch_activity_class)}")
if not launch_activity_class:
return
methods = _find_methods_by_name(launch_activity_class, "dispatchTouchEvent")
diagnostics.note(f"unread_hook.install: dispatchTouchEvent methods found={len(methods)}")
if not methods:
return
class _TouchTrackHook(MethodHook):
def before_hooked_method(self, param):
try:
event = param.args[0]
action = event.getActionMasked()
except Exception:
return
now = time.monotonic()
if action == 0: # ACTION_DOWN
gesture_ref["down_at"] = now
gesture_ref["released_at"] = 0.0
gesture_ref["consumed"] = False
elif action == 1: # ACTION_UP
gesture_ref["released_at"] = now
elif action == 3: # ACTION_CANCEL
gesture_ref["down_at"] = 0.0
gesture_ref["released_at"] = 0.0
gesture_ref["consumed"] = False
for method in methods:
try:
ref = self.plugin.hook_method(method, _TouchTrackHook())
diagnostics.note(f"unread_hook.install: dispatchTouchEvent hook ref={ref!r}")
if ref:
self._unhooks.append(ref)
except Exception as exc:
diagnostics.record_error("unread_hook.install:dispatchTouchEvent", exc)
def _install_pagedown_hook(self, find_class, MethodHook) -> None:
chat_activity_class = find_class("org.telegram.ui.ChatActivity")
diagnostics.note(f"unread_hook.install: ChatActivity found={bool(chat_activity_class)}")
if not chat_activity_class:
diagnostics.record_error("unread_hook.install", RuntimeError("ChatActivity class not found"))
return
methods = _find_methods_by_name(chat_activity_class, "onPageDownClicked")
diagnostics.note(f"unread_hook.install: onPageDownClicked methods found={len(methods)}")
if not methods:
diagnostics.record_error(
"unread_hook.install", RuntimeError("onPageDownClicked method not found")
)
return
gesture_ref = _GESTURE
outer = self
class _PageDownHook(MethodHook):
def before_hooked_method(self, param):
try:
duration = _held_duration()
diagnostics.note(f"unread_hook: onPageDownClicked duration={duration:.3f}")
if duration < LONG_PRESS_SECONDS or gesture_ref["consumed"]:
return
chat_activity = param.thisObject
if chat_activity is None:
return
gesture_ref["consumed"] = True
param.setResult(None) # abort the normal scroll-to-newest
outer._trigger(chat_activity)
except Exception as exc:
diagnostics.record_error("unread_hook:onPageDownClicked", exc)
for method in methods:
try:
ref = self.plugin.hook_method(method, _PageDownHook())
diagnostics.note(f"unread_hook.install: onPageDownClicked hook ref={ref!r}")
if ref:
self._unhooks.append(ref)
except Exception as exc:
diagnostics.record_error("unread_hook.install:onPageDownClicked", exc)
def _install_unread_cell_hook(self, find_class, MethodHook) -> None:
"""Hooks ChatUnreadCell's constructors to attach a click/long-click
listener to the inline "N unread messages" divider cell.
"""
unread_cell_class = find_class("org.telegram.ui.Cells.ChatUnreadCell")
diagnostics.note(f"unread_hook.install: ChatUnreadCell found={bool(unread_cell_class)}")
if not unread_cell_class:
return
ctors = _find_constructors(unread_cell_class)
diagnostics.note(f"unread_hook.install: ChatUnreadCell constructors found={len(ctors)}")
if not ctors:
return
outer = self
class _UnreadCellHook(MethodHook):
def after_hooked_method(self, param):
try:
cell = param.thisObject
if cell is not None:
outer._setup_unread_cell(cell)
except Exception as exc:
diagnostics.record_error("unread_hook:ChatUnreadCell.after", exc)
cell_hook = _UnreadCellHook()
attached = 0
for ctor in ctors:
try:
ref = self.plugin.hook_method(ctor, cell_hook)
if ref:
self._unhooks.append(ref)
attached += 1
except Exception as exc:
diagnostics.record_error("unread_hook.install:ChatUnreadCell_ctor", exc)
diagnostics.note(f"unread_hook.install: ChatUnreadCell constructors hooked={attached}")
def _mark_hooked(self, view: Any) -> bool:
try:
key = id(view)
except Exception:
return True
if key in self._hooked_view_ids:
return False
if len(self._hooked_view_ids) > 64:
self._hooked_view_ids.clear()
self._hooked_view_ids.add(key)
return True
def _setup_unread_cell(self, unread_cell: Any) -> None:
"""Attaches click and long-click listeners to a ChatUnreadCell instance."""
try:
from android_utils import OnClickListener, OnLongClickListener, run_on_ui_thread
except Exception as exc:
diagnostics.record_error("unread_hook.cell:imports", exc)
return
outer = self
def _init():
try:
if not outer._mark_hooked(unread_cell):
return
unread_cell.setClickable(True)
unread_cell.setFocusable(True)
unread_cell.setLongClickable(True)
def on_action(*_args):
outer._trigger(None)
return True
unread_cell.setOnClickListener(OnClickListener(lambda v=None: on_action()))
unread_cell.setOnLongClickListener(OnLongClickListener(lambda v=None: on_action()))
diagnostics.note("unread_hook: ChatUnreadCell listeners attached")
except Exception as exc:
diagnostics.record_error("unread_hook.cell:_init", exc)
try:
run_on_ui_thread(_init)
except Exception as exc:
diagnostics.record_error("unread_hook.cell:run_on_ui_thread", exc)
def uninstall(self) -> None:
try:
for ref in self._unhooks:
self.plugin.unhook_method(ref)
except Exception as exc:
diagnostics.record_error("unread_hook.uninstall", exc)
self._unhooks = []
self._hooked_view_ids.clear()
def _trigger(self, chat_activity: Optional[Any]) -> None:
"""Resolves the chat's unread count/offset and opens the summarize
dialog. `chat_activity` may be None (e.g. triggered from the unread
cell rather than the pagedown button), in which case the current
fragment is used to resolve the chat instead.
"""
diagnostics.note("unread_hook: _trigger fired")
try:
from client_utils import get_last_fragment, get_messages_controller
fragment = chat_activity or get_last_fragment()
if fragment is None:
diagnostics.record_error("unread_hook._trigger", RuntimeError("no fragment available"))
return
dialog_id = self._resolve_dialog_id(fragment)
diagnostics.note(f"unread_hook: resolved dialog_id={dialog_id}")
if not dialog_id:
diagnostics.record_error("unread_hook._trigger", RuntimeError("could not resolve dialog_id"))
return
raw_unread = 0
try:
controller = get_messages_controller()
dialog = controller.dialogs_dict.get(dialog_id) if controller else None
if dialog is not None:
raw_unread = int(getattr(dialog, "unread_count", 0) or 0)
except Exception:
raw_unread = 0
if raw_unread > 0:
count = min(MAX_MSG_COUNT, max(1, raw_unread))
offset = max(0, raw_unread - count)
else:
count = None # let caller fall back to configured defaults
offset = 0
topic_id = self._resolve_topic_id(fragment)
self.plugin.open_summarize_dialog(
fragment, dialog_id, topic_id, prefill_count=count, prefill_offset=offset
)
except Exception as exc:
diagnostics.record_error("unread_hook._trigger", exc)
@staticmethod
def _resolve_dialog_id(fragment: Any) -> Optional[int]:
try:
if hasattr(fragment, "getDialogId"):
return int(fragment.getDialogId())
except Exception:
pass
try:
from hook_utils import get_private_field
dialog_id = get_private_field(fragment, "dialog_id")
if dialog_id:
return int(dialog_id)
except Exception:
pass
return None
@staticmethod
def _resolve_topic_id(fragment: Any) -> int:
try:
if hasattr(fragment, "getTopicId"):
return int(fragment.getTopicId() or 0)
except Exception:
pass
try:
from hook_utils import get_private_field
topic_id = get_private_field(fragment, "topicId")
if topic_id:
return int(topic_id)
except Exception:
pass
return 0
+487 -33
View File
@@ -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://<host>: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 <name>"
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)
View File
Whitespace-only changes.
+62
View File
@@ -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}")
+69
View File
@@ -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()
+210
View File
@@ -0,0 +1,210 @@
"""Server-sent-events streaming for OpenAI-compatible chat completions.
Yields incremental ("thinking", text) / ("answer", text) deltas so the caller
can render reasoning while it arrives and the answer once it starts.
Reasoning deltas are read from whichever field the endpoint uses --
`reasoning_content` (DeepSeek), `reasoning` (OpenRouter), or `<think>` tags
inlined in normal content (many local models) -- because there is no single
standard for it.
"""
import json
from typing import Callable, Iterator, List, Optional, Tuple
from providers.base import ProviderError, build_chat_messages
from services.message_fetcher import FormattedMessage
THINK_OPEN = "<think>"
THINK_CLOSE = "</think>"
# ("thinking" | "answer", delta_text)
Delta = Tuple[str, str]
def _extract_delta(chunk: dict) -> Delta:
"""Maps one SSE data object to a (kind, text) delta; ("", "") when empty."""
try:
delta = chunk["choices"][0].get("delta") or {}
except (KeyError, IndexError, TypeError):
return ("", "")
for key in ("reasoning_content", "reasoning"):
value = delta.get(key)
if isinstance(value, str) and value:
return ("thinking", value)
content = delta.get("content")
if isinstance(content, str) and content:
return ("answer", content)
return ("", "")
class _ThinkTagSplitter:
"""Reclassifies inline <think>...</think> content as thinking deltas.
Kept as a state machine because tags can be split across SSE chunks.
"""
def __init__(self) -> None:
self._in_think = False
self._buf = ""
def feed(self, text: str) -> List[Delta]:
out: List[Delta] = []
self._buf += text
while self._buf:
if self._in_think:
idx = self._buf.find(THINK_CLOSE)
if idx < 0:
# Hold back a possible partial closing tag.
keep = _partial_tail(self._buf, THINK_CLOSE)
emit, self._buf = self._buf[: len(self._buf) - keep], self._buf[len(self._buf) - keep :]
if emit:
out.append(("thinking", emit))
return out
if idx:
out.append(("thinking", self._buf[:idx]))
self._buf = self._buf[idx + len(THINK_CLOSE) :]
self._in_think = False
continue
idx = self._buf.find(THINK_OPEN)
if idx < 0:
keep = _partial_tail(self._buf, THINK_OPEN)
emit, self._buf = self._buf[: len(self._buf) - keep], self._buf[len(self._buf) - keep :]
if emit:
out.append(("answer", emit))
return out
if idx:
out.append(("answer", self._buf[:idx]))
self._buf = self._buf[idx + len(THINK_OPEN) :]
self._in_think = True
return out
def flush(self) -> List[Delta]:
if not self._buf:
return []
kind = "thinking" if self._in_think else "answer"
out = [(kind, self._buf)]
self._buf = ""
return out
def _partial_tail(buf: str, tag: str) -> int:
"""Length of the trailing substring of `buf` that could start `tag`."""
for n in range(min(len(tag) - 1, len(buf)), 0, -1):
if tag.startswith(buf[-n:]):
return n
return 0
def iter_sse_objects(lines: Iterator[bytes]) -> Iterator[dict]:
"""Parses an SSE byte-line stream into JSON objects, stopping at [DONE]."""
for raw in lines:
if not raw:
continue
line = raw.decode("utf-8", "replace").strip() if isinstance(raw, (bytes, bytearray)) else raw.strip()
if not line or not line.startswith("data:"):
continue
payload = line[5:].strip()
if payload == "[DONE]":
return
try:
yield json.loads(payload)
except ValueError:
continue
def stream_summary(
messages: List[FormattedMessage],
instructions: Optional[str],
base_url: str,
api_key: str,
model: str,
on_delta: Callable[[str, str], None],
timeout: float = 300.0,
include_attachments: bool = False,
extra_by_id: Optional[dict] = None,
on_mapping: Optional[Callable[[object], None]] = None,
topic_id: int = 0,
) -> Tuple[str, str]:
"""Streams a completion, invoking on_delta(kind, text) per chunk.
Returns the accumulated (answer, thinking) markdown.
"""
if not base_url:
raise ProviderError("no base URL is configured (Settings > AI Endpoint)")
try:
import requests
except ImportError:
raise ProviderError("the 'requests' package is not installed; add it to plugin.json requirements")
chat_messages, mapping = build_chat_messages(
messages,
instructions,
include_attachments=include_attachments,
extra_by_id=extra_by_id,
topic_id=topic_id,
)
# Handed over before the request so the caller can de-anonymize deltas as
# they stream in, not just the final answer.
if on_mapping is not None:
on_mapping(mapping)
url = base_url.rstrip("/") + "/chat/completions"
headers = {"Content-Type": "application/json", "Accept": "text/event-stream"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
payload = {"model": model, "messages": chat_messages, "stream": True}
try:
response = requests.post(url, headers=headers, json=payload, timeout=timeout, stream=True)
except Exception as exc:
raise ProviderError(f"request failed: {exc}")
if response.status_code >= 400:
body = ""
try:
body = response.text[:500]
except Exception:
pass
raise ProviderError(f"HTTP {response.status_code}: {body}")
answer_parts: List[str] = []
thinking_parts: List[str] = []
splitter = _ThinkTagSplitter()
def emit(kind: str, text: str) -> None:
if kind == "thinking":
thinking_parts.append(text)
else:
answer_parts.append(text)
on_delta(kind, text)
try:
for chunk in iter_sse_objects(response.iter_lines()):
kind, text = _extract_delta(chunk)
if not text:
continue
if kind == "answer":
for k, t in splitter.feed(text):
emit(k, t)
else:
emit(kind, text)
for k, t in splitter.flush():
emit(k, t)
except ProviderError:
raise
except Exception as exc:
raise ProviderError(f"stream failed: {exc}")
finally:
try:
response.close()
except Exception:
pass
answer = "".join(answer_parts).strip()
thinking = "".join(thinking_parts).strip()
if not answer and not thinking:
raise ProviderError("endpoint returned an empty stream")
return answer, thinking
View File
Whitespace-only changes.
+351
View File
@@ -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
+567
View File
@@ -0,0 +1,567 @@
"""Client-side injection of summary messages rendered as RichMessage.
A summary is not a real Telegram message: it is a locally built
`TLRPC.TL_message` carrying `rich_message`, attributed to a mock sender that
exists only in this client's `MessagesController` cache. It is inserted into the
open `ChatActivity`'s message list right after the newest analyzed message.
Verified against exteraGram 12.5.x (dex inspection):
- `Message.rich_message` + `flags2 |= 1 << 12` makes `MessageObject.setType`
return 36, which is the gate for `RichMessageLayout`.
- `RichMessage.part` makes the layout fade its bottom edge and hide the
timestamp, i.e. the app's own "still streaming" presentation.
- The mock sender id MUST be positive: negative ids are chat/channel space, so
`getUser()` can never resolve one and the cell falls back to "DELETED".
"""
from typing import Any, Optional
import diagnostics
from services.rich_markdown import build_rich_message
# Above Telegram's issued id range, so this user exists only locally.
MOCK_SENDER_ID = 8000000000000001
MOCK_SENDER_NAME = "AI Summary"
MOCK_SENDER_USERNAME = "ai_summary"
FLAGS2_RICH_MESSAGE = 1 << 12
# Telegram's animated robot custom emoji, used as the mock sender's status.
ROBOT_EMOJI_DOCUMENT_ID = 5397634513026043777
def _tlrpc():
from org.telegram.tgnet import TLRPC
return TLRPC
def virtual_id_for(summary_id: int, anchor_msg_id: int) -> int:
"""Deterministic negative id, so re-injection is idempotent and detectable."""
return -abs(int(anchor_msg_id) * 1000 + (int(summary_id) % 1000)) or -1
def register_mock_sender(account: int) -> Optional[Any]:
"""Puts the mock sender into the local user cache so the cell renders a name."""
try:
TLRPC = _tlrpc()
from org.telegram.messenger import MessagesController
user = TLRPC.TL_user()
user.id = MOCK_SENDER_ID
user.first_name = MOCK_SENDER_NAME
user.last_name = ""
user.username = MOCK_SENDER_USERNAME
user.bot = True
user.verified = True
user.deleted = False
user.access_hash = 0
_attach_robot_status(user, account, TLRPC)
MessagesController.getInstance(account).putUser(user, False)
return user
except Exception as exc:
diagnostics.record_error("injector.register_mock_sender", exc)
return None
def _attach_robot_status(user: Any, account: int, TLRPC: Any) -> None:
try:
from org.telegram.messenger import AnimatedEmojiDrawable
if AnimatedEmojiDrawable.findDocument(account, ROBOT_EMOJI_DOCUMENT_ID) is None:
return
status = TLRPC.TL_emojiStatus()
status.document_id = ROBOT_EMOJI_DOCUMENT_ID
user.emoji_status = status
except Exception:
# Purely cosmetic; a missing emoji pack must not break injection.
pass
def peer_for_dialog(dialog_id: int) -> Any:
"""Builds the Peer subtype matching the dialog kind.
The subtype matters: `MessageObject.isOutOwner` inspects it, and a
`TL_peerUser` in a group makes the summary render as an outgoing message.
"""
TLRPC = _tlrpc()
dialog_id = int(dialog_id)
if dialog_id > 0:
peer = TLRPC.TL_peerUser()
peer.user_id = dialog_id
return peer
if dialog_id <= -1000000000000:
peer = TLRPC.TL_peerChannel()
peer.channel_id = -dialog_id - 1000000000000
return peer
peer = TLRPC.TL_peerChat()
peer.chat_id = -dialog_id
return peer
def build_summary_message(
account: int,
dialog_id: int,
topic_id: int,
anchor_msg_id: int,
virtual_id: int,
answer_markdown: str,
thinking: str = "",
streaming: bool = False,
status: str = "",
) -> Optional[Any]:
"""Builds the local MessageObject for a summary, or None on failure."""
try:
import time
TLRPC = _tlrpc()
from org.telegram.messenger import MessageObject
msg = TLRPC.TL_message()
msg.id = int(virtual_id)
msg.dialog_id = int(dialog_id)
msg.date = int(time.time())
msg.out = False
msg.unread = False
msg.post = False
msg.flags = 0
msg.message = ""
msg.peer_id = peer_for_dialog(dialog_id)
sender = TLRPC.TL_peerUser()
sender.user_id = MOCK_SENDER_ID
msg.from_id = sender
# Reply header points at the newest analyzed message, so the summary
# visually attaches to the range it covers and taps navigate there.
if anchor_msg_id:
try:
header = TLRPC.TL_messageReplyHeader()
header.reply_to_msg_id = int(anchor_msg_id)
if topic_id:
header.reply_to_top_id = int(topic_id)
msg.reply_to = header
except Exception as exc:
diagnostics.record_error("injector.reply_header", exc)
msg.rich_message = build_rich_message(answer_markdown, thinking, streaming, status)
msg.flags2 = int(getattr(msg, "flags2", 0) or 0) | FLAGS2_RICH_MESSAGE
message_object = MessageObject(account, msg, True, True)
# Pin attribution rather than let it be derived: in Saved Messages
# isOutOwner() returns true for anything without forward info.
message_object.isOutOwnerCached = False
return message_object
except Exception as exc:
diagnostics.record_error("injector.build_summary_message", exc)
return None
def get_messages_list(chat_activity: Any) -> Optional[Any]:
"""Returns the ChatActivity's live ArrayList<MessageObject>, or None."""
try:
from hook_utils import get_private_field
candidate = getattr(chat_activity, "messages", None) or get_private_field(chat_activity, "messages")
if candidate is not None and hasattr(candidate, "size"):
return candidate
diagnostics.note(
f"injector.get_messages_list: unusable candidate={type(candidate).__name__ if candidate is not None else None}"
)
except Exception as exc:
diagnostics.record_error("injector.get_messages_list", exc)
return None
def get_chat_adapter(chat_activity: Any) -> Optional[Any]:
try:
from hook_utils import get_private_field
return getattr(chat_activity, "chatAdapter", None) or get_private_field(chat_activity, "chatAdapter")
except Exception:
return None
def find_index_for_anchor(messages_list: Any, anchor_msg_id: int) -> int:
"""Index at which to insert so the summary sits directly after the anchor.
ChatActivity.messages is newest-first, so "directly after the anchor"
chronologically means "immediately before it" in list order.
The anchor is the newest message that was actually analyzed, which is not
the newest message in the chat whenever a non-zero offset was used. If the
anchor itself is not loaded (paged out, or never fetched into this view),
fall back to ordering by id rather than to index 0: index 0 is the bottom
of the chat and would place the card after messages the summary never saw.
"""
anchor_msg_id = int(anchor_msg_id)
try:
size = messages_list.size()
for i in range(size):
owner = getattr(messages_list.get(i), "messageOwner", None)
if owner is not None and int(getattr(owner, "id", 0) or 0) == anchor_msg_id:
return i
# Anchor absent: insert before the first real message whose id is at
# most the anchor's, i.e. the newest message not newer than the
# analyzed range. Injected cards carry negative ids and are skipped so
# they cannot drag the position around.
for i in range(size):
owner = getattr(messages_list.get(i), "messageOwner", None)
msg_id = int(getattr(owner, "id", 0) or 0) if owner is not None else 0
if 0 < msg_id <= anchor_msg_id:
return i
# Every loaded message is newer than the anchor, so the analyzed range
# ends above everything here: the card belongs at the very top.
return size
except Exception as exc:
diagnostics.record_error("injector.find_index_for_anchor", exc)
return 0
def find_existing(messages_list: Any, virtual_id: int) -> int:
"""Index of an already-injected message with this virtual id, else -1."""
try:
size = messages_list.size()
for i in range(size):
owner = getattr(messages_list.get(i), "messageOwner", None)
if owner is not None and int(getattr(owner, "id", 0) or 0) == int(virtual_id):
return i
except Exception:
pass
return -1
def notify_adapter(chat_activity: Any) -> None:
adapter = get_chat_adapter(chat_activity)
if adapter is None:
return
def run():
try:
adapter.notifyDataSetChanged()
except Exception as exc:
diagnostics.record_error("injector.notifyDataSetChanged", exc)
try:
from android_utils import run_on_ui_thread
run_on_ui_thread(run)
except Exception:
run()
class SummaryInjection:
"""A single injected summary message, updatable in place while streaming."""
def __init__(self, chat_activity: Any, account: int, dialog_id: int, topic_id: int, anchor_msg_id: int, virtual_id: int):
self.chat_activity = chat_activity
self.account = int(account)
self.dialog_id = int(dialog_id)
self.topic_id = int(topic_id)
self.anchor_msg_id = int(anchor_msg_id)
self.virtual_id = int(virtual_id)
self.message_object: Optional[Any] = None
# Every id this injection has ever placed in the list. reanchor()
# changes the id mid-flight, so an update must be able to find and
# remove the card under its previous id instead of adding a second one.
self._used_ids = {int(virtual_id)}
def insert(
self,
answer_markdown: str = "",
thinking: str = "",
streaming: bool = True,
status: str = "",
) -> bool:
messages_list = get_messages_list(self.chat_activity)
if messages_list is None:
diagnostics.note("injector: ChatActivity messages list not found")
return False
register_mock_sender(self.account)
message_object = build_summary_message(
self.account,
self.dialog_id,
self.topic_id,
self.anchor_msg_id,
self.virtual_id,
answer_markdown,
thinking,
streaming,
status,
)
if message_object is None:
return False
self.message_object = message_object
existing = find_existing(messages_list, self.virtual_id)
index = existing if existing >= 0 else find_index_for_anchor(messages_list, self.anchor_msg_id)
def run():
try:
if existing >= 0:
messages_list.set(index, message_object)
else:
messages_list.add(index, message_object)
adapter = get_chat_adapter(self.chat_activity)
if adapter is not None:
adapter.notifyDataSetChanged()
# The analyzed range can end far up the history, so the card
# may be injected off-screen. Bring it into view once, on the
# first insert only -- scrolling on every streaming update
# would fight the user for control of the list.
if existing < 0:
self.scroll_into_view()
except Exception as exc:
diagnostics.record_error("injector.insert", exc)
_on_ui(run)
return True
def scroll_into_view(self) -> bool:
"""Scrolls the chat so the summary card is visible.
Targets the anchor, not the card: the anchor is a real message the app
can resolve and load, and the card sits directly above it, so both end
up on screen. ChatActivity.scrollToMessageId(id, fromMessageId,
select, loadIndex, forceScroll, unreadCount) -- arity verified in the
APK.
"""
if not self.anchor_msg_id:
return False
try:
self.chat_activity.scrollToMessageId(
int(self.anchor_msg_id), 0, False, 0, True, 0
)
return True
except Exception as exc:
diagnostics.record_error("injector.scroll_into_view", exc)
return False
def update(
self,
answer_markdown: str = "",
thinking: str = "",
streaming: bool = True,
status: str = "",
) -> None:
"""Re-renders the card in place, without rebuilding the whole list.
Mutating rich_message alone is not enough: MessageObject caches
messageText, type, and its measured layout, and ChatMessageCell keeps
its own cached sizes, so a bare swap renders as an empty bubble.
The app's own live-update path (ChatActivity.applyFullRichMessageLocally)
does exactly three things: set rich_message, null richLayout, then call
chatMessageCellDelegate.forceUpdate(cell, true, true). That repaints the
single affected cell and is what keeps a streaming card visually stable.
notifyDataSetChanged() rebinds every row instead, which is what made the
card flicker and lose scroll position on each delta; it is only used as
a fallback when the live cell cannot be found.
"""
if self.message_object is None:
self.insert(answer_markdown, thinking, streaming, status)
return
replacement = build_summary_message(
self.account,
self.dialog_id,
self.topic_id,
self.anchor_msg_id,
self.virtual_id,
answer_markdown,
thinking,
streaming,
status,
)
if replacement is None:
return
self.message_object = replacement
def run():
try:
messages_list = get_messages_list(self.chat_activity)
inserted = False
if messages_list is not None:
# Replace at the current id; drop any card left behind
# under an id this injection used earlier, so a reanchor
# mid-stream cannot leave two cards on screen.
index = find_existing(messages_list, self.virtual_id)
if index >= 0:
messages_list.set(index, replacement)
else:
index = find_index_for_anchor(messages_list, self.anchor_msg_id)
messages_list.add(index, replacement)
inserted = True
for stale_id in self._used_ids:
if stale_id == self.virtual_id:
continue
stale = find_existing(messages_list, stale_id)
if stale >= 0:
messages_list.remove(stale)
inserted = True
# A row was added or removed, so the adapter's item count
# changed and only a full rebind is correct.
if inserted or not self._force_update_cell(replacement):
adapter = get_chat_adapter(self.chat_activity)
if adapter is not None:
adapter.notifyDataSetChanged()
except Exception as exc:
diagnostics.record_error("injector.update", exc)
_on_ui(run)
def _force_update_cell(self, message_object: Any) -> bool:
"""Repaints just this card's cell. Returns False if it is not on screen.
Mirrors ChatActivity.applyFullRichMessageLocally: locate the live
ChatMessageCell bound to this message and hand it to
chatMessageCellDelegate.forceUpdate, which remeasures and redraws that
one row instead of rebinding the whole list.
"""
try:
from hook_utils import get_private_field
delegate = getattr(self.chat_activity, "chatMessageCellDelegate", None) or get_private_field(
self.chat_activity, "chatMessageCellDelegate"
)
list_view = getattr(self.chat_activity, "chatListView", None) or get_private_field(
self.chat_activity, "chatListView"
)
if delegate is None or list_view is None:
return False
for i in range(list_view.getChildCount()):
child = list_view.getChildAt(i)
if child is None or not hasattr(child, "getMessageObject"):
continue
bound = child.getMessageObject()
owner = getattr(bound, "messageOwner", None) if bound is not None else None
if owner is None or int(getattr(owner, "id", 0) or 0) != self.virtual_id:
continue
# setMessageObject(messageObject, groupedMessages, pinnedBottom,
# pinnedTop, firstInChat) -- verified arity against the APK.
child.setMessageObject(message_object, None, False, False, False)
delegate.forceUpdate(child, False, True)
child.invalidate()
return True
except Exception as exc:
diagnostics.record_error("injector._force_update_cell", exc)
return False
def reanchor(self, anchor_msg_id: int, virtual_id: int) -> None:
"""Re-targets an already-visible card once the analyzed range is known.
The card is injected on click, before any messages are fetched, so both
the anchor and the DB row id only become known later. Moving it keeps
the promise that a summary sits directly after its newest analyzed
message.
"""
anchor_msg_id = int(anchor_msg_id)
virtual_id = int(virtual_id)
if anchor_msg_id == self.anchor_msg_id and virtual_id == self.virtual_id:
return
message_object = self.message_object
old_virtual_id = self.virtual_id
self.anchor_msg_id = anchor_msg_id
self.virtual_id = virtual_id
self._used_ids.add(virtual_id)
if message_object is None:
return
def run():
try:
owner = message_object.messageOwner
messages_list = get_messages_list(self.chat_activity)
# Locate the card by its OLD id before rewriting it, otherwise
# the lookup misses and the card is never moved.
current = find_existing(messages_list, old_virtual_id) if messages_list is not None else -1
owner.id = virtual_id
try:
header = _tlrpc().TL_messageReplyHeader()
header.reply_to_msg_id = anchor_msg_id
if self.topic_id:
header.reply_to_top_id = int(self.topic_id)
owner.reply_to = header
except Exception as exc:
diagnostics.record_error("injector.reanchor.reply_header", exc)
if messages_list is not None and current >= 0:
messages_list.remove(current)
# A card may already sit under the new id -- e.g. the
# restore hook re-injected this summary's saved row. Claim
# that id rather than ending up with two cards.
duplicate = find_existing(messages_list, virtual_id)
if duplicate >= 0:
messages_list.remove(duplicate)
target = find_index_for_anchor(messages_list, anchor_msg_id)
messages_list.add(target, message_object)
adapter = get_chat_adapter(self.chat_activity)
if adapter is not None:
adapter.notifyDataSetChanged()
# The card just moved to the real end of the analyzed range,
# which may be somewhere else entirely, so follow it there.
self.scroll_into_view()
except Exception as exc:
diagnostics.record_error("injector.reanchor", exc)
_on_ui(run)
def _on_ui(fn) -> None:
try:
from android_utils import run_on_ui_thread
run_on_ui_thread(fn)
except Exception:
fn()
def restore_saved_summaries(chat_activity: Any, account: int, dialog_id: int, topic_id: int, summaries) -> int:
"""Re-injects persisted summaries into a freshly opened chat. Returns count."""
messages_list = get_messages_list(chat_activity)
if messages_list is None or not summaries:
return 0
register_mock_sender(account)
pending = []
for summary in summaries:
virtual_id = virtual_id_for(summary.id, summary.anchor_msg_id)
if find_existing(messages_list, virtual_id) >= 0:
continue
message_object = build_summary_message(
account,
dialog_id,
topic_id,
summary.anchor_msg_id,
virtual_id,
summary.text,
summary.thinking,
False,
)
if message_object is not None:
pending.append((summary.anchor_msg_id, message_object))
if not pending:
return 0
# Newest anchor first: each insert shifts everything below it, so placing
# the newest card first keeps the remaining lookups pointing at unmoved
# rows. The index is recomputed per card against the live list anyway.
pending.sort(key=lambda item: item[0], reverse=True)
def run():
try:
for anchor_msg_id, message_object in pending:
messages_list.add(find_index_for_anchor(messages_list, anchor_msg_id), message_object)
adapter = get_chat_adapter(chat_activity)
if adapter is not None:
adapter.notifyDataSetChanged()
except Exception as exc:
diagnostics.record_error("injector.restore", exc)
_on_ui(run)
return len(pending)
+298
View File
@@ -0,0 +1,298 @@
"""Markdown -> RichMessage block serializer.
The app's own `RichMessageConvert.blocksFromCharSequence` works off Android
`Spanned` spans (CodeHighlighting$Span / QuoteSpan), not markdown source, so it
cannot parse markdown text for us. This module converts markdown directly into
`TL_iv` page blocks, which is what `RichMessageLayout` renders.
Block mapping:
# / ## / ###+ -> pageBlockHeading1 / Heading2 / Heading3
```lang ... ``` -> pageBlockPreformatted (language carried through)
> quote -> pageBlockBlockquote
- / * / + item -> pageBlockList (ordered=False)
1. item -> pageBlockOrderedList
--- -> pageBlockDivider
paragraph -> pageBlockParagraph
Inline: **bold**, *italic*/_italic_, `code`, ~~strike~~, [text](url).
"""
import re
from typing import Any, List, Optional
# Title of the collapsed reasoning container; tapping it reveals or hides the
# model's reasoning once the answer has started arriving.
THINKING_TITLE = "Reasoning"
_FENCE_RE = re.compile(r"^\s*```+\s*([A-Za-z0-9_+#.-]*)\s*$")
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$")
_QUOTE_RE = re.compile(r"^\s{0,3}>\s?(.*)$")
_BULLET_RE = re.compile(r"^\s{0,3}[-*+]\s+(.*)$")
_ORDERED_RE = re.compile(r"^\s{0,3}(\d{1,9})[.)]\s+(.*)$")
_RULE_RE = re.compile(r"^\s{0,3}(?:-\s*-\s*-[-\s]*|\*\s*\*\s*\*[*\s]*|_\s*_\s*_[_\s]*)$")
# Inline markers, longest-first so ** wins over *.
_INLINE_RE = re.compile(
r"(?P<code>`+)(?P<code_body>.+?)(?P=code)"
r"|\[(?P<link_text>[^\]]+)\]\((?P<link_url>[^)\s]+)\)"
r"|(?P<bold>\*\*|__)(?P<bold_body>.+?)(?P=bold)"
r"|(?P<strike>~~)(?P<strike_body>.+?)(?P=strike)"
r"|(?P<em>[*_])(?P<em_body>[^*_]+?)(?P=em)",
re.DOTALL,
)
def _tl_iv():
from org.telegram.tgnet.tl import TL_iv
return TL_iv
def _plain(TL_iv, text: str) -> Any:
node = TL_iv.textPlain()
node.text = text
return node
def _wrap(TL_iv, kind: str, inner: Any) -> Any:
node = getattr(TL_iv, kind)()
node.text = inner
return node
def _concat(TL_iv, parts: List[Any]) -> Any:
"""textConcat for >1 part; the single part itself otherwise."""
if not parts:
return TL_iv.textEmpty()
if len(parts) == 1:
return parts[0]
node = TL_iv.textConcat()
for p in parts:
node.texts.add(p)
return node
def build_rich_text(markdown: str, TL_iv: Optional[Any] = None) -> Any:
"""Converts one paragraph of inline markdown into a RichText tree."""
TL_iv = TL_iv or _tl_iv()
parts: List[Any] = []
pos = 0
for m in _INLINE_RE.finditer(markdown):
if m.start() > pos:
parts.append(_plain(TL_iv, markdown[pos:m.start()]))
if m.group("code") is not None:
parts.append(_wrap(TL_iv, "textFixed", _plain(TL_iv, m.group("code_body"))))
elif m.group("link_text") is not None:
node = TL_iv.textUrl()
node.text = build_rich_text(m.group("link_text"), TL_iv)
node.url = m.group("link_url")
node.webpage_id = 0
parts.append(node)
elif m.group("bold") is not None:
parts.append(_wrap(TL_iv, "textBold", build_rich_text(m.group("bold_body"), TL_iv)))
elif m.group("strike") is not None:
parts.append(_wrap(TL_iv, "textStrike", build_rich_text(m.group("strike_body"), TL_iv)))
elif m.group("em") is not None:
parts.append(_wrap(TL_iv, "textItalic", build_rich_text(m.group("em_body"), TL_iv)))
pos = m.end()
if pos < len(markdown):
parts.append(_plain(TL_iv, markdown[pos:]))
return _concat(TL_iv, parts)
def _block(TL_iv, kind: str, text: Optional[Any] = None, **fields) -> Any:
node = getattr(TL_iv, kind)()
if text is not None:
node.text = text
for k, v in fields.items():
setattr(node, k, v)
return node
def _list_item(TL_iv, markdown: str) -> Any:
item = TL_iv.TL_pageListItemText()
item.text = build_rich_text(markdown, TL_iv)
return item
def _ordered_item(TL_iv, markdown: str, num: int) -> Any:
item = TL_iv.TL_pageListOrderedItemText()
item.text = build_rich_text(markdown, TL_iv)
item.num = str(num)
return item
def _flush_paragraph(TL_iv, buf: List[str], out: List[Any]) -> None:
if not buf:
return
text = "\n".join(buf).strip()
buf.clear()
if text:
out.append(_block(TL_iv, "pageBlockParagraph", build_rich_text(text, TL_iv)))
def markdown_to_blocks(markdown: str, TL_iv: Optional[Any] = None) -> List[Any]:
"""Converts markdown into a list of TL_iv page blocks."""
TL_iv = TL_iv or _tl_iv()
out: List[Any] = []
para: List[str] = []
lines = (markdown or "").replace("\r\n", "\n").replace("\r", "\n").split("\n")
i = 0
while i < len(lines):
line = lines[i]
fence = _FENCE_RE.match(line)
if fence:
lang = fence.group(1) or ""
i += 1
body: List[str] = []
while i < len(lines) and not _FENCE_RE.match(lines[i]):
body.append(lines[i])
i += 1
i += 1 # closing fence (or EOF, which closes an unterminated block)
_flush_paragraph(TL_iv, para, out)
block = _block(TL_iv, "pageBlockPreformatted", _plain(TL_iv, "\n".join(body)))
block.language = lang
out.append(block)
continue
if _RULE_RE.match(line):
_flush_paragraph(TL_iv, para, out)
out.append(_block(TL_iv, "pageBlockDivider"))
i += 1
continue
heading = _HEADING_RE.match(line)
if heading:
_flush_paragraph(TL_iv, para, out)
level = len(heading.group(1))
kind = "pageBlockHeading1" if level == 1 else ("pageBlockHeading2" if level == 2 else "pageBlockHeading3")
out.append(_block(TL_iv, kind, build_rich_text(heading.group(2).strip(), TL_iv)))
i += 1
continue
if _QUOTE_RE.match(line):
_flush_paragraph(TL_iv, para, out)
quoted: List[str] = []
while i < len(lines):
qm = _QUOTE_RE.match(lines[i])
if not qm:
break
quoted.append(qm.group(1))
i += 1
block = _block(TL_iv, "pageBlockBlockquote", build_rich_text("\n".join(quoted).strip(), TL_iv))
block.caption = TL_iv.textEmpty()
out.append(block)
continue
if _BULLET_RE.match(line):
_flush_paragraph(TL_iv, para, out)
block = _block(TL_iv, "pageBlockList")
block.ordered = False
while i < len(lines):
bm = _BULLET_RE.match(lines[i])
if not bm:
break
block.items.add(_list_item(TL_iv, bm.group(1).strip()))
i += 1
out.append(block)
continue
ordered = _ORDERED_RE.match(line)
if ordered:
_flush_paragraph(TL_iv, para, out)
block = _block(TL_iv, "pageBlockOrderedList")
start = int(ordered.group(1))
block.start = start
block.reversed = False
block.type = ""
block.flags = 0
num = start
while i < len(lines):
om = _ORDERED_RE.match(lines[i])
if not om:
break
block.items.add(_ordered_item(TL_iv, om.group(2).strip(), num))
num += 1
i += 1
out.append(block)
continue
if not line.strip():
_flush_paragraph(TL_iv, para, out)
i += 1
continue
para.append(line)
i += 1
_flush_paragraph(TL_iv, para, out)
if not out:
out.append(_block(TL_iv, "pageBlockParagraph", TL_iv.textEmpty()))
return out
def build_rich_message(
answer_markdown: str,
thinking: str = "",
streaming: bool = False,
status: str = "",
TL_iv: Optional[Any] = None,
) -> Any:
"""Assembles a RichMessage: status/reasoning, then the answer blocks.
`status` is the current pipeline stage ("Fetching messages…" etc.) and is
rendered as a thinking block so it gets the shimmer treatment; it is
dropped as soon as real reasoning or answer text arrives.
While reasoning is still streaming it stays a `pageBlockThinking`, which
animates. Once the answer starts arriving the reasoning is finished, so it
moves into a collapsed `pageBlockDetails` -- the app's own expandable
container (RichDetailsBlock, with toggle()/isOpen()) -- and can be revealed
or hidden by tapping its title.
`RichMessage.part` is deliberately always False. Setting it makes
RichMessageLayout.draw take its partial-content branch, which fades the
bottom edge AND draws a "Show more" button wired to the app's
loadFullRichMessage server fetch. That button is meaningless for a
locally generated summary -- there is nothing more to load -- and the two
effects share one branch, so the only way to drop the collapse is to leave
`part` unset.
"""
TL_iv = TL_iv or _tl_iv()
rm = TL_iv.RichMessage()
thinking = (thinking or "").strip()
answer_markdown = (answer_markdown or "").strip()
status = (status or "").strip()
if thinking and answer_markdown:
rm.blocks.add(_thinking_details(TL_iv, thinking))
elif thinking:
rm.blocks.add(_block(TL_iv, "pageBlockThinking", build_rich_text(thinking, TL_iv)))
elif status and not answer_markdown:
rm.blocks.add(_block(TL_iv, "pageBlockThinking", build_rich_text(status, TL_iv)))
if answer_markdown:
for b in markdown_to_blocks(answer_markdown, TL_iv):
rm.blocks.add(b)
if not rm.blocks:
rm.blocks.add(_block(TL_iv, "pageBlockParagraph", TL_iv.textEmpty()))
rm.part = False
rm.rtl = False
return rm
def _thinking_details(TL_iv: Any, thinking: str) -> Any:
"""Reasoning wrapped in a collapsed, tappable details block."""
details = TL_iv.pageBlockDetails()
details.title = build_rich_text(THINKING_TITLE, TL_iv)
details.open = False
details.flags = 0
for paragraph in thinking.split("\n\n"):
paragraph = paragraph.strip()
if paragraph:
details.blocks.add(_block(TL_iv, "pageBlockParagraph", build_rich_text(paragraph, TL_iv)))
if not details.blocks:
details.blocks.add(_block(TL_iv, "pageBlockParagraph", build_rich_text(thinking, TL_iv)))
return details
+152
View File
@@ -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"],
)
+321
View File
@@ -0,0 +1,321 @@
"""Anonymized JSON transcript builder and mention de-anonymizer.
The model never sees real Telegram ids. Messages are numbered 1..N in the order
they are included, senders are numbered 1..M in order of first appearance, and
the mapping is kept locally so mentions in the reply can be turned back into
real deep links.
Wire format is compact JSON with no spacing, e.g.
{"messages":[{"id":1,"user":1,"text":"got new crocs","attachments":["a.png"]},
{"id":2,"user":2,"text":"cap","reply_to":1,"reply_highlight":"new"}]}
A message that replies to something outside the analyzed range is still useful
context, so the parent is injected with a negative id (-1, -2, ...) and is
understood to be outside the summarized window.
"""
import json
import re
from typing import Any, Dict, List, Optional, Tuple
# Placeholders the model emits, in the anonymized namespace:
# {user3} -> the 3rd distinct sender
# {msg12} -> the 12th included message
#
# Deliberately not Markdown links. A link is only valid once its closing
# parenthesis arrives, so a half-streamed `[x](msg:59` renders as broken
# literal text; a placeholder is a single token that is either complete or
# absent, and substitution decides the visible label locally.
_USER_PLACEHOLDER_RE = re.compile(r"\{user(-?\d+)\}")
_MSG_PLACEHOLDER_RE = re.compile(r"\{msg(-?\d+)\}")
SYSTEM_PROMPT = (
"You summarize Telegram chat conversations.\n"
"\n"
"Input is JSON: {\"messages\":[{\"id\":N,\"user\":N,\"text\":\"...\"}]}. "
"`id` is the message index, `user` is an anonymized sender number, "
"`reply_to` is the id of the message being replied to, `reply_highlight` is "
"the quoted fragment, and `attachments` lists attached file names. "
"A negative `id` means the message is outside the summarized range and is "
"included only as context.\n"
"\n"
"Write the summary in Markdown. You may use headings, bold, italics, "
"bullet and numbered lists, block quotes, dividers, inline code and fenced "
"code blocks.\n"
"\n"
"Refer to a person as {userN} and to a message as {msgN}, using the "
"numbers from the input. These are placeholders: write them bare, never "
"inside a Markdown link, and never wrap them in brackets or backticks. "
"They are replaced with real names and links afterwards.\n"
"\n"
"Example: {user1} said the build was broken ({msg4}, {msg7}).\n"
"\n"
"Cite at most two message placeholders for a single claim, and only where "
"a reader would want the source. Never invent a number that is not in the "
"input, never write a bare number as a citation, and do not mention that "
"ids are anonymized or describe the JSON format in your answer."
)
class TranscriptMapping:
"""Anonymized <-> real id mapping for one request.
Holds everything needed to turn the model's placeholders back into real
names and deep links: the real ids, the display names, and whether each
sender is a user or a chat/channel (they need different link forms).
"""
__slots__ = ("user_by_index", "message_by_index", "name_by_index", "kind_by_index", "topic_id")
def __init__(self, topic_id: int = 0) -> None:
# anonymized number -> real Telegram id
self.user_by_index: Dict[int, int] = {}
self.message_by_index: Dict[int, int] = {}
# anonymized number -> display name, used as the visible link label.
self.name_by_index: Dict[int, str] = {}
# anonymized number -> "user" | "chat", since a group or channel
# sender cannot be opened with tg://user (that reports "user not
# found"); it needs the chat form instead.
self.kind_by_index: Dict[int, str] = {}
# Forum topic the analyzed messages belong to, needed so message links
# open inside the topic rather than the supergroup's global feed.
self.topic_id = int(topic_id or 0)
def real_user_id(self, index: int) -> Optional[int]:
return self.user_by_index.get(int(index))
def real_message_id(self, index: int) -> Optional[int]:
return self.message_by_index.get(int(index))
def user_name(self, index: int) -> str:
return self.name_by_index.get(int(index), "")
def sender_kind(self, index: int) -> str:
return self.kind_by_index.get(int(index), "user")
def describe_attachments(raw: Any) -> List[str]:
"""Names the media attached to a raw Telegram message.
Prefers a real file name when the media carries one, and otherwise falls
back to a stable synthetic name built from the media's own id, so the model
can refer to an attachment without being handed a Telegram file id.
"""
media = getattr(raw, "media", None)
if media is None:
return []
document = getattr(media, "document", None)
if document is not None:
for attribute in _java_list(getattr(document, "attributes", None)):
file_name = getattr(attribute, "file_name", None)
if file_name:
return [str(file_name)]
mime = str(getattr(document, "mime_type", "") or "")
extension = mime.rpartition("/")[2] or "bin"
return [f"{abs(int(getattr(document, 'id', 0) or 0))}.{extension}"]
photo = getattr(media, "photo", None)
if photo is not None:
return [f"{abs(int(getattr(photo, 'id', 0) or 0))}.jpg"]
webpage = getattr(media, "webpage", None)
if webpage is not None:
url = getattr(webpage, "url", None)
return [str(url)] if url else []
# Polls, contacts, geo, invoices: name the media class, without its
# TL_messageMedia prefix.
name = type(media).__name__ or "media"
return [name.replace("TL_messageMedia", "").lower() or "media"]
def _java_list(value: Any) -> List[Any]:
"""Chaquopy exposes java.util.ArrayList without the iterator protocol."""
if value is None:
return []
if isinstance(value, list):
return list(value)
try:
return [value.get(i) for i in range(value.size())]
except Exception:
return []
def build_transcript(
messages: List[Any],
include_attachments: bool = False,
extra_by_id: Optional[Dict[int, Any]] = None,
topic_id: int = 0,
) -> Tuple[str, TranscriptMapping]:
"""Builds the compact anonymized JSON transcript plus its id mapping.
`messages` are the analyzed messages, oldest first. `extra_by_id` holds
out-of-range reply parents keyed by real message id; those are emitted with
negative ids and are not part of the 1..N numbering. `topic_id` is carried
on the mapping so message links can open inside the forum topic.
Only `text` and `attachments` leave the device as-is; every identity is
replaced by an index.
"""
mapping = TranscriptMapping(topic_id=topic_id)
extra_by_id = extra_by_id or {}
index_by_real_message: Dict[int, int] = {}
index_by_real_user: Dict[int, int] = {}
def user_index(message: Any) -> int:
real_id = int(getattr(message, "sender_id", 0) or 0)
# Senders with no resolvable id are grouped by display name so the same
# anonymous poster keeps one number.
key = real_id if real_id else -abs(hash(getattr(message, "sender_name", "") or "?")) or -1
existing = index_by_real_user.get(key)
if existing is not None:
return existing
assigned = len(index_by_real_user) + 1
index_by_real_user[key] = assigned
if real_id:
mapping.user_by_index[assigned] = real_id
mapping.name_by_index[assigned] = getattr(message, "sender_name", "") or ""
mapping.kind_by_index[assigned] = getattr(message, "sender_kind", "user") or "user"
return assigned
# Number the analyzed messages first, so reply_to can resolve forward.
for position, message in enumerate(messages, start=1):
real_id = int(getattr(message, "id", 0) or 0)
index_by_real_message[real_id] = position
mapping.message_by_index[position] = real_id
entries: List[Dict[str, Any]] = []
next_context_index = -1
def entry_for(message: Any, index: int) -> Dict[str, Any]:
entry: Dict[str, Any] = {
"id": index,
"user": user_index(message),
"text": getattr(message, "text", "") or "",
}
reply_to_real = int(getattr(message, "reply_to_id", 0) or 0)
if reply_to_real:
reply_index = index_by_real_message.get(reply_to_real)
if reply_index is not None:
entry["reply_to"] = reply_index
highlight = getattr(message, "reply_highlight", "") or ""
if highlight:
entry["reply_highlight"] = highlight
if include_attachments:
attachments = list(getattr(message, "attachments", None) or [])
if attachments:
entry["attachments"] = attachments
return entry
for position, message in enumerate(messages, start=1):
# An out-of-range reply parent is context the summary needs; emit it
# immediately before its child with a negative id.
reply_to_real = int(getattr(message, "reply_to_id", 0) or 0)
if reply_to_real and reply_to_real not in index_by_real_message:
parent = extra_by_id.get(reply_to_real)
if parent is not None:
index_by_real_message[reply_to_real] = next_context_index
mapping.message_by_index[next_context_index] = reply_to_real
entries.append(entry_for(parent, next_context_index))
next_context_index -= 1
entries.append(entry_for(message, position))
payload = json.dumps({"messages": entries}, ensure_ascii=False, separators=(",", ":"))
return payload, mapping
def deanonymize(markdown: str, mapping: TranscriptMapping, dialog_id: int) -> str:
"""Substitutes anonymized placeholders with real names and deep links.
`{user3}` becomes a profile link and `{msg12}` a numbered link that jumps
to the message. Both forms are resolved by exteraGram's
LaunchActivity.handleIntent.
Substitution is safe to run on partial text: a placeholder is one token, so
a half-streamed `{msg1` simply stays literal for one frame and resolves on
the next, instead of rendering as a broken Markdown link.
A placeholder whose number was never issued degrades to readable text
rather than a dead link.
"""
if not markdown:
return markdown
def replace_user(match: "re.Match") -> str:
index = int(match.group(1))
name = mapping.user_name(index)
real_id = mapping.real_user_id(index)
if not real_id:
return name or "someone"
return f"[{name or 'user'}]({sender_link(real_id, mapping.sender_kind(index))})"
def replace_message(match: "re.Match") -> str:
index = int(match.group(1))
real_id = mapping.real_message_id(index)
if not real_id:
return ""
# Numbered citation: short enough to read inline, and the number is
# the model's own index so repeated citations stay distinguishable.
label = f"№{abs(index)}"
return f"[{label}]({message_link(dialog_id, real_id, mapping.topic_id)})"
result = _USER_PLACEHOLDER_RE.sub(replace_user, markdown)
return _MSG_PLACEHOLDER_RE.sub(replace_message, result)
def sender_link(sender_id: int, kind: str = "user") -> str:
"""Deep link that opens a sender's profile.
A group or channel is not a user: tg://user?id= on one reports "user not
found", so chat senders get the openmessage form, which resolves to the
chat itself.
"""
sender_id = int(sender_id)
if kind == "chat":
return f"tg://openmessage?chat_id={_internal_chat_id(sender_id)}"
return f"tg://user?id={sender_id}"
def _internal_chat_id(raw_id: int) -> int:
"""Positive internal id Telegram uses in links for a chat or channel."""
value = abs(int(raw_id))
if value > 1000000000000:
value -= 1000000000000
return value
_PARTIAL_TAIL_RE = re.compile(r"\{(?:u(?:s(?:e(?:r-?\d*)?)?)?|m(?:s(?:g-?\d*)?)?)?$")
def strip_partial_placeholder(markdown: str) -> str:
"""Drops a placeholder that is still being streamed.
Without this the last frame of a stream can show a literal `{msg1` until
the closing brace arrives. Only a trailing fragment is removed, so complete
placeholders earlier in the text are untouched.
"""
if not markdown:
return markdown
return _PARTIAL_TAIL_RE.sub("", markdown)
def message_link(dialog_id: int, message_id: int, topic_id: int = 0) -> str:
"""Deep link that opens a specific message, inside its topic when relevant.
`tg://openmessage` carries no thread parameter, so in a forum it lands in
the supergroup's global feed rather than the topic. The app's own link
parser accepts `t.me/c/<internal_id>/<topic_id>/<message_id>` (regex
verified in the APK), which is the only form that opens the right topic, so
that is used whenever a topic is known.
"""
dialog_id = int(dialog_id)
message_id = int(message_id)
topic_id = int(topic_id or 0)
if dialog_id < 0 and topic_id:
return f"https://t.me/c/{_internal_chat_id(dialog_id)}/{topic_id}/{message_id}"
if dialog_id > 0:
return f"tg://openmessage?user_id={dialog_id}&message_id={message_id}"
return f"tg://openmessage?chat_id={_internal_chat_id(dialog_id)}&message_id={message_id}"
-2
View File
@@ -1,2 +0,0 @@
def normalize(name: str) -> str:
return " ".join(w[:1].upper() + w[1:] for w in name.split())
View File
Whitespace-only changes.
+129
View File
@@ -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()
+39
View File
@@ -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
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""Publishes a built .plugin file to a Gitea release.
Creates the release for the given tag if it does not exist, replaces any
same-named asset already attached, then uploads the artifact. Re-running is
safe: the release is reused and the asset is replaced rather than duplicated.
Stdlib only, so it runs on any runner image without a pip install step.
Usage:
tools/release.py --api <repo-api-url> --tag v1.0.0 --artifact dist/x.plugin
The token is read from GITEA_TOKEN (or GITHUB_TOKEN as a fallback).
"""
from __future__ import annotations
import argparse
import json
import mimetypes
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
import uuid
from pathlib import Path
from typing import Optional, Tuple
REPO_ROOT = Path(__file__).resolve().parent.parent
def fail(message: str) -> "None":
print(f"error: {message}", file=sys.stderr)
raise SystemExit(1)
def request(
method: str,
url: str,
token: str,
body: Optional[bytes] = None,
content_type: Optional[str] = None,
) -> Tuple[int, bytes]:
"""Performs one API call, returning (status, body) instead of raising."""
req = urllib.request.Request(url, data=body, method=method)
req.add_header("Authorization", f"token {token}")
req.add_header("Accept", "application/json")
if content_type:
req.add_header("Content-Type", content_type)
try:
with urllib.request.urlopen(req) as response:
return response.status, response.read()
except urllib.error.HTTPError as exc:
return exc.code, exc.read()
except urllib.error.URLError as exc:
fail(f"{method} {url} failed: {exc.reason}")
raise # unreachable, keeps type checkers quiet
def find_release(api: str, tag: str, token: str) -> Optional[dict]:
status, payload = request("GET", f"{api}/releases/tags/{tag}", token)
if status == 404:
return None
if status != 200:
fail(f"looking up release {tag} returned HTTP {status}: {payload[:400]!r}")
return json.loads(payload)
def create_release(api: str, tag: str, token: str, commitish: str, notes: str) -> dict:
body = json.dumps(
{
"tag_name": tag,
"target_commitish": commitish,
"name": tag,
"body": notes,
"draft": False,
"prerelease": False,
}
).encode()
status, payload = request("POST", f"{api}/releases", token, body, "application/json")
if status != 201:
fail(f"creating release {tag} returned HTTP {status}: {payload[:400]!r}")
return json.loads(payload)
def delete_existing_assets(api: str, release_id: int, name: str, token: str) -> int:
"""Removes same-named assets, since Gitea keeps duplicates side by side."""
status, payload = request("GET", f"{api}/releases/{release_id}/assets", token)
if status != 200:
fail(f"listing assets returned HTTP {status}: {payload[:400]!r}")
removed = 0
for asset in json.loads(payload) or []:
if asset.get("name") != name:
continue
status, payload = request(
"DELETE", f"{api}/releases/{release_id}/assets/{asset['id']}", token
)
if status not in (204, 404):
fail(f"deleting asset {asset['id']} returned HTTP {status}: {payload[:400]!r}")
removed += 1
return removed
def upload_asset(api: str, release_id: int, path: Path, name: str, token: str) -> dict:
"""Uploads the artifact as multipart/form-data."""
boundary = uuid.uuid4().hex
mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
payload = b"".join(
[
f"--{boundary}\r\n".encode(),
f'Content-Disposition: form-data; name="attachment"; filename="{name}"\r\n'.encode(),
f"Content-Type: {mime}\r\n\r\n".encode(),
path.read_bytes(),
f"\r\n--{boundary}--\r\n".encode(),
]
)
url = f"{api}/releases/{release_id}/assets?name={urllib.parse.quote(name)}"
status, response = request(
"POST", url, token, payload, f"multipart/form-data; boundary={boundary}"
)
if status != 201:
fail(f"uploading {name} returned HTTP {status}: {response[:400]!r}")
return json.loads(response)
def main(argv: Optional[list] = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--api", required=True, help="repo API base, e.g. https://host/api/v1/repos/owner/repo")
parser.add_argument("--tag", required=True, help="release tag")
parser.add_argument("--artifact", required=True, help="file to attach")
parser.add_argument("--commitish", default="", help="target commit for a new tag")
parser.add_argument("--asset-name", default="", help="attachment name; defaults to the file name")
args = parser.parse_args(argv)
token = os.environ.get("GITEA_TOKEN") or os.environ.get("GITHUB_TOKEN") or ""
if not token:
fail("GITEA_TOKEN is not set")
artifact = Path(args.artifact)
if not artifact.is_file() or artifact.stat().st_size == 0:
fail(f"artifact {artifact} is missing or empty")
asset_name = args.asset_name or artifact.name
api = args.api.rstrip("/")
version = json.loads((REPO_ROOT / "plugin.json").read_text(encoding="utf-8"))["version"]
release = find_release(api, args.tag, token)
if release is None:
notes = (
f"Automated build of version {version}.\n\n"
"Install by opening the attached `.plugin` file in exteraGram."
)
release = create_release(api, args.tag, token, args.commitish, notes)
print(f"created release {args.tag}")
else:
print(f"reusing existing release {args.tag}")
release_id = int(release["id"])
removed = delete_existing_assets(api, release_id, asset_name, token)
if removed:
print(f"removed {removed} previous copy/copies of {asset_name}")
asset = upload_asset(api, release_id, artifact, asset_name, token)
print(f"uploaded {asset_name} ({artifact.stat().st_size} bytes) -> {asset.get('browser_download_url', '')}")
return 0
if __name__ == "__main__":
sys.exit(main())
+1635 -65
View File
File diff suppressed because it is too large. Load diff