generated from exteraGramPlugins/pluginBase
Implement Chat Summaries plugin
Replace the sample plugin with an AI chat summarizer that renders its
output as a native RichMessage injected into the chat.
Summarization pipeline:
- Fetch chat/topic history via paginated TL requests.
- Send a system prompt, the user's instructions and an anonymized JSON
transcript as three separate roles. Only message text and attachment
names leave the device; senders and message ids are replaced by
indices, and out-of-range reply parents are included with negative ids
so replies keep their context.
- Stream the response over SSE, reading reasoning from reasoning_content,
reasoning or inline <think> tags.
- Substitute the model's {userN}/{msgN} placeholders back into real names
and deep links. Placeholders are used instead of Markdown links because
a half-streamed link renders as broken literal text.
Rendering:
- Compile Markdown into TL_iv page blocks (headings, lists, quotes, code,
dividers, inline styles) since the app's own converter works off
Android Spanned spans rather than Markdown source.
- Show progress stages immediately on click, animate reasoning while it
streams, then collapse it into a tappable details block.
- Attribute the card to a client-side mock sender and insert it directly
after the newest analyzed message, scrolling it into view.
Persistence:
- Store summaries in SQLite keyed by (dialog, topic, anchor) and
re-inject them when the chat is reopened.
Also add tools/release.py and a Gitea workflow that builds, verifies and
publishes dist/<id>.plugin to the Releases tab, and extend
tools/verify_bundle.py to 104 offline checks covering the transcript,
streaming, rendering, injection and storage behaviour.
This commit is contained in:
1 parent
a3b0b264c1
commit
196da742de
27 files changed
+5261
-121
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user