Files
chatSummaries/tools/verify_bundle.py
T
lyuksovannyy 785618233b Keep the status card visible and the unread prefill stable
Two bugs visible in a device log.

"injector: anchor 0 not loaded, skipping insert" was a regression from the
previous commit. _newest_visible_message_id returns 0 when the offset reaches
past the loaded window, and the strict anchor check then refused to place the
card, so pressing Summarize appeared to do nothing at all. Before that commit
anchor 0 silently matched a date separator, which is why it went unnoticed.

A live card is the user's only feedback that the press registered, so it must
always appear: try the exact anchor, then id ordering, then the bottom of the
chat, and let reanchor() move it once the analyzed range is known. Restoring
saved summaries stays strict, since nothing corrects their position later.
_newest_visible_message_id also falls back to the oldest loaded message rather
than returning 0.

The unread button prefilled a smaller range on each press -- 1000, then 15,
then 75, with ~1100 unread. Viewing a chat calls
MessagesController.markDialogAsRead, so dialog.unread_count decays while the
chat is open and no longer describes the backlog the user wants summarized.

Resolve the count from the unread divider instead, which is what the app's own
button uses (onPageDownClicked branches on createUnreadMessageAfterId) and
which does not decay while the chat stays open, and keep the largest value
seen per chat so a later press never prefills less than an earlier one.

Both are covered by tests that fail against the previous behaviour: the strict
anchor path reports inserted=True while leaving no card in the list, and the
unread resolver reproduces exactly 1100, 15, 75.
2026-09-19 17:08:11 +02:00

2051 lines
80 KiB
Python
Executable File

#!/usr/bin/env python3
"""Offline verification harness for a built .plugin bundle. Runs without a phone.
Usage: python3 tools/verify_bundle.py <bundle.plugin>
"""
from __future__ import annotations
import ast
import json
import sys
import tempfile
import types
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
REPO_ROOT = Path(__file__).resolve().parent.parent
PLUGIN_JSON = REPO_ROOT / "plugin.json"
_FAILURES = []
def check(name: str, condition: bool, detail: str = "") -> None:
if condition:
print(f"PASS {name}")
else:
message = f"FAIL {name}"
if detail:
message += f": {detail}"
print(message, file=sys.stderr)
_FAILURES.append(name)
LOG_LINES: List[str] = []
MENU_ITEMS: List[Any] = []
BULLETINS: List[tuple] = []
HOOKS_INSTALLED: List[Any] = []
LAST_REQUEST: Dict[str, Any] = {}
class MenuItemType:
MESSAGE_CONTEXT_MENU = "MESSAGE_CONTEXT_MENU"
DRAWER_MENU = "DRAWER_MENU"
MAIN_MENU = "MAIN_MENU"
CHAT_ACTION_MENU = "CHAT_ACTION_MENU"
PROFILE_ACTION_MENU = "PROFILE_ACTION_MENU"
class MenuItemData:
def __init__(self, menu_type=None, text="", subtext="", icon=None, on_click=None, **kwargs):
self.menu_type = menu_type
self.text = text
self.subtext = subtext
self.icon = icon
self.on_click = on_click
self.__dict__.update(kwargs)
class MethodHook:
def before_hooked_method(self, param):
pass
def after_hooked_method(self, param):
pass
class BasePlugin:
def __init__(self):
self._settings: Dict[str, Any] = {}
def get_setting(self, key, default=None):
return self._settings.get(key, default)
def set_setting(self, key, value, reload_settings=False):
self._settings[key] = value
def add_menu_item(self, item):
MENU_ITEMS.append(item)
return f"menu_item_{len(MENU_ITEMS)}"
def hook_method(self, method, hook_instance, priority=0):
HOOKS_INSTALLED.append((method, hook_instance))
return f"hook_ref_{len(HOOKS_INSTALLED)}"
def unhook_method(self, ref):
pass
def on_plugin_load(self):
pass
def on_plugin_unload(self):
pass
def create_settings(self):
return []
def fake_log(*args, **kwargs):
LOG_LINES.append(" ".join(str(a) for a in args))
class _SettingsRow:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
class _JavaList(list):
"""Python list that also answers the Java ArrayList calls our code makes."""
def add(self, *args):
if len(args) == 2:
self.insert(int(args[0]), args[1])
else:
self.append(args[0])
return True
def size(self):
return len(self)
def get(self, index):
return self[index]
def set(self, index, value):
previous = self[index]
self[index] = value
return previous
def remove(self, index):
return self.pop(int(index))
class _JavaArrayList:
"""A Java ArrayList as Chaquopy exposes it: size()/get() but NOT iterable.
list()/for-in over one raises TypeError on-device, so the fake withholds
the iterator protocol to keep that mistake failing here.
"""
def __init__(self, items=()):
self._items = list(items)
def size(self):
return len(self._items)
def get(self, index):
return self._items[index]
def add(self, item):
self._items.append(item)
return True
class _TLObj:
"""Duck-typed stand-in for a TLRPC/TL_iv object: any attribute is settable.
`blocks`/`items`/`texts` start as list-likes because the production code
calls `.add()` on them without constructing them first, exactly as the Java
classes pre-initialize their ArrayList fields.
"""
def __init__(self, tl_name):
self.tl_name = tl_name
self.blocks = _JavaList()
self.items = _JavaList()
self.texts = _JavaList()
self.flags = 0
self.flags2 = 0
def __repr__(self):
return f"<{self.tl_name}>"
def _tl_iv_factory(name):
return lambda: _TLObj(name)
class _FakeMessageObject:
"""Mirrors the MessageObject fields/behaviour the injector relies on."""
TYPE_RICH_MESSAGE = 36
def __init__(self, account, message, generate_layout=False, check_media=False):
self.currentAccount = account
self.messageOwner = message
self.richLayout = None
self.isOutOwnerCached = None
self.type = None
self.messageText = ""
self.updateMessageText()
self.setType()
def updateMessageText(self):
rich = getattr(self.messageOwner, "rich_message", None)
self.messageText = "<rich>" if rich is not None else getattr(self.messageOwner, "message", "")
def setType(self):
rich = getattr(self.messageOwner, "rich_message", None)
self.type = self.TYPE_RICH_MESSAGE if rich is not None else 0
def isOutOwner(self):
if self.isOutOwnerCached is not None:
return self.isOutOwnerCached
return bool(getattr(self.messageOwner, "out", False))
def getId(self):
return getattr(self.messageOwner, "id", 0)
class _FakeMessagesController:
"""Per-account user cache; rejects non-positive ids the way getUser does."""
_instances = {}
def __init__(self):
self.users = {}
@classmethod
def getInstance(cls, account):
return cls._instances.setdefault(account, cls())
def putUser(self, user, from_cache):
self.users[int(user.id)] = user
def getUser(self, user_id):
user_id = int(user_id)
if user_id <= 0:
return None
return self.users.get(user_id)
class _FakeRequestCallback:
"""Mirrors client_utils.RequestCallback: the Java-listener wrapper.
On-device, send_request hands its second argument to Java. A bare Python
function is accepted but never invoked, so the response is silently
dropped; only a RequestCallback-wrapped callable runs. The fake therefore
refuses anything else, so that mistake fails here instead of on the phone.
"""
def __init__(self, fn):
self.fn = fn
def run(self, response, error):
return self.fn(response, error)
def _fake_send_request(request, callback, *, account=None):
if not isinstance(callback, _FakeRequestCallback):
raise TypeError(
"send_request callback must be wrapped in RequestCallback(...); "
f"got {type(callback).__name__}"
)
callback.run(None, None)
return 1
_KNOWN_METHODS = {
"org.telegram.ui.LaunchActivity": ["dispatchTouchEvent"],
"org.telegram.ui.ChatActivity": [
"onPageDownClicked",
"updateMessagesVisiblePart",
"didReceivedNotification_messagesDidLoad",
],
}
_KNOWN_CONSTRUCTOR_COUNTS = {
"org.telegram.ui.Cells.ChatUnreadCell": 1,
}
class _FakeJavaMethod:
"""Simulates a resolved java.lang.reflect.Method for a known method name."""
def __init__(self, class_name, method_name):
self.class_name = class_name
self.method_name = method_name
def getName(self):
return self.method_name
class _FakeJavaConstructor:
def __init__(self, class_name):
self.class_name = class_name
class _FakeJavaReflectClass:
"""Simulates the real java.lang.Class object returned by .getClass()."""
def __init__(self, name):
self._name = name
def getDeclaredMethods(self):
return [_FakeJavaMethod(self._name, m) for m in _KNOWN_METHODS.get(self._name, [])]
def getMethods(self):
return self.getDeclaredMethods()
def getDeclaredConstructors(self):
count = _KNOWN_CONSTRUCTOR_COUNTS.get(self._name, 0)
return [_FakeJavaConstructor(self._name) for _ in range(count)]
class _FakeChaquopyClassWrapper:
"""Simulates what hook_utils.find_class(...) actually returns on-device:
a Chaquopy Python-side class wrapper, NOT a java.lang.Class. It exposes
no getDeclaredMethod(s) directly -- only .getClass() reaches the real
reflection object. A fake that skipped this distinction (returning None,
or returning something with getDeclaredMethods directly) would have
hidden the real on-device bug where code called getDeclaredMethod(...)
straight on find_class(...)'s result.
"""
def __init__(self, name):
self._name = name
def getClass(self):
return _FakeJavaReflectClass(self._name)
def fake_find_class(name: str):
return _FakeChaquopyClassWrapper(name)
def fake_get_private_field(obj, field_name):
return None
class FakeBulletinHelper:
@staticmethod
def show_info(message, fragment=None):
BULLETINS.append(("info", message))
@staticmethod
def show_error(message, fragment=None):
BULLETINS.append(("error", message))
@staticmethod
def show_success(message, fragment=None):
BULLETINS.append(("success", message))
class FakeAlertDialogBuilder:
ALERT_TYPE_MESSAGE = 0
ALERT_TYPE_SPINNER = 1
BUTTON_POSITIVE = 1
BUTTON_NEGATIVE = 2
def __init__(self, activity, progress_style=0, resources_provider=None):
self.activity = activity
self._view = None
def set_title(self, *a, **k):
return self
def set_message(self, *a, **k):
return self
def set_view(self, view, height=-2):
self._view = view
return self
def set_positive_button(self, text, listener=None):
self._positive = listener
return self
def set_negative_button(self, text, listener=None):
self._negative = listener
return self
def set_cancelable(self, *a, **k):
return self
def create(self):
return self
def show(self):
return self
def dismiss(self):
pass
def _java_widget_stub(name):
def __init__(self, *args, **kwargs):
self._children = []
self._text = ""
def setOrientation(self, *a, **k):
pass
def setGravity(self, *a, **k):
pass
def setPadding(self, *a, **k):
pass
def addView(self, view, *a, **k):
self._children.append(view)
def setText(self, text):
self._text = text
def getText(self):
return self._text
def setHint(self, *a, **k):
pass
def setSingleLine(self, *a, **k):
pass
def setMinLines(self, *a, **k):
pass
def setMaxLines(self, *a, **k):
pass
def setTextSize(self, *a, **k):
pass
def setTextColor(self, *a, **k):
pass
def setHintTextColor(self, *a, **k):
pass
def setLineColors(self, *a, **k):
pass
def setCursorColor(self, *a, **k):
pass
def setBackgroundColor(self, *a, **k):
pass
return type(
name,
(object,),
{
"__init__": __init__,
"setOrientation": setOrientation,
"setGravity": setGravity,
"setPadding": setPadding,
"addView": addView,
"setText": setText,
"getText": getText,
"setHint": setHint,
"setSingleLine": setSingleLine,
"setMinLines": setMinLines,
"setMaxLines": setMaxLines,
"setTextSize": setTextSize,
"setTextColor": setTextColor,
"setHintTextColor": setHintTextColor,
"setLineColors": setLineColors,
"setCursorColor": setCursorColor,
"setBackgroundColor": setBackgroundColor,
},
)
def install_fakes() -> None:
base_plugin_mod = types.ModuleType("base_plugin")
base_plugin_mod.BasePlugin = BasePlugin
base_plugin_mod.MenuItemData = MenuItemData
base_plugin_mod.MenuItemType = MenuItemType
base_plugin_mod.MethodHook = MethodHook
sys.modules["base_plugin"] = base_plugin_mod
android_utils_mod = types.ModuleType("android_utils")
android_utils_mod.log = fake_log
android_utils_mod.run_on_ui_thread = lambda fn, delay=0: fn()
android_utils_mod.copy_to_clipboard = lambda text: None
android_utils_mod.OnClickListener = lambda fn: fn
android_utils_mod.OnLongClickListener = lambda fn: fn
sys.modules["android_utils"] = android_utils_mod
hook_utils_mod = types.ModuleType("hook_utils")
hook_utils_mod.find_class = fake_find_class
hook_utils_mod.get_private_field = fake_get_private_field
hook_utils_mod.set_private_field = lambda obj, field, value: False
sys.modules["hook_utils"] = hook_utils_mod
client_utils_mod = types.ModuleType("client_utils")
client_utils_mod.PLUGINS_QUEUE = "pluginsQueue"
client_utils_mod.run_on_queue = lambda fn, queue=None, delay=0: fn()
client_utils_mod.get_last_fragment = lambda: None
client_utils_mod.get_messages_controller = lambda: None
client_utils_mod.get_selected_account = lambda: 0
client_utils_mod.RequestCallback = _FakeRequestCallback
client_utils_mod.send_request = _fake_send_request
sys.modules["client_utils"] = client_utils_mod
# summary_db writes a real SQLite file; point it at a scratch dir so the
# harness exercises the actual schema and queries.
file_utils_mod = types.ModuleType("file_utils")
file_utils_mod.get_files_dir = lambda: tempfile.mkdtemp(prefix="chat_summaries_verify_")
sys.modules["file_utils"] = file_utils_mod
ui_mod = types.ModuleType("ui")
ui_mod.__path__ = []
sys.modules["ui"] = ui_mod
ui_settings_mod = types.ModuleType("ui.settings")
for cls_name in ("Header", "Divider", "Switch", "Selector", "Input", "Text", "EditText", "Custom"):
setattr(ui_settings_mod, cls_name, type(cls_name, (_SettingsRow,), {}))
sys.modules["ui.settings"] = ui_settings_mod
ui_mod.settings = ui_settings_mod
ui_alert_mod = types.ModuleType("ui.alert")
ui_alert_mod.AlertDialogBuilder = FakeAlertDialogBuilder
sys.modules["ui.alert"] = ui_alert_mod
ui_mod.alert = ui_alert_mod
ui_bulletin_mod = types.ModuleType("ui.bulletin")
ui_bulletin_mod.BulletinHelper = FakeBulletinHelper
sys.modules["ui.bulletin"] = ui_bulletin_mod
ui_mod.bulletin = ui_bulletin_mod
android_mod = types.ModuleType("android")
android_mod.__path__ = []
sys.modules["android"] = android_mod
android_view_mod = types.ModuleType("android.view")
android_view_mod.Gravity = type("Gravity", (), {"CENTER": 17})
android_view_mod.View = type("View", (), {})
sys.modules["android.view"] = android_view_mod
android_mod.view = android_view_mod
class _FakeLayoutParams:
def __init__(self, *a, **k):
pass
android_widget_mod = types.ModuleType("android.widget")
for widget_name in ("LinearLayout", "TextView", "ScrollView", "FrameLayout", "Button"):
cls = _java_widget_stub(widget_name)
cls.VERTICAL = 1
cls.HORIZONTAL = 0
cls.LayoutParams = _FakeLayoutParams
setattr(android_widget_mod, widget_name, cls)
sys.modules["android.widget"] = android_widget_mod
android_mod.widget = android_widget_mod
org_mod = types.ModuleType("org")
org_mod.__path__ = []
sys.modules["org"] = org_mod
org_telegram_mod = types.ModuleType("org.telegram")
org_telegram_mod.__path__ = []
sys.modules["org.telegram"] = org_telegram_mod
tgnet_mod = types.ModuleType("org.telegram.tgnet")
class _TLRequestStub:
def __init__(self):
self.peer = None
self.offset_id = 0
self.offset_date = 0
self.add_offset = 0
self.limit = 0
self.max_id = 0
self.min_id = 0
self.hash = 0
self.msg_id = 0
tgnet_mod.TLRPC = type(
"TLRPC",
(),
{
"TL_messages_getHistory": staticmethod(_TLRequestStub),
"TL_messages_getReplies": staticmethod(_TLRequestStub),
"TL_message": staticmethod(lambda: _TLObj("TL_message")),
"TL_user": staticmethod(lambda: _TLObj("TL_user")),
"TL_peerUser": staticmethod(lambda: _TLObj("TL_peerUser")),
"TL_peerChat": staticmethod(lambda: _TLObj("TL_peerChat")),
"TL_peerChannel": staticmethod(lambda: _TLObj("TL_peerChannel")),
"TL_messageReplyHeader": staticmethod(lambda: _TLObj("TL_messageReplyHeader")),
"TL_emojiStatus": staticmethod(lambda: _TLObj("TL_emojiStatus")),
},
)
sys.modules["org.telegram.tgnet"] = tgnet_mod
org_telegram_mod.tgnet = tgnet_mod
# TL_iv: instant-view block/rich-text constructors used by rich_markdown.
# Every block type is the same duck-typed bag of attributes; `blocks`,
# `items`, and `texts` behave like Java ArrayList (.add()).
tgnet_tl_mod = types.ModuleType("org.telegram.tgnet.tl")
tgnet_tl_mod.__path__ = []
_TL_IV_NAMES = (
"RichMessage textPlain textEmpty textConcat textBold textItalic textStrike "
"textFixed textUnderline textUrl pageBlockParagraph pageBlockHeading1 "
"pageBlockHeading2 pageBlockHeading3 pageBlockPreformatted pageBlockBlockquote "
"pageBlockDivider pageBlockThinking pageBlockList pageBlockOrderedList "
"pageBlockDetails TL_pageListItemText TL_pageListOrderedItemText"
).split()
tgnet_tl_mod.TL_iv = type(
"TL_iv",
(),
{name: staticmethod(_tl_iv_factory(name)) for name in _TL_IV_NAMES},
)
sys.modules["org.telegram.tgnet.tl"] = tgnet_tl_mod
tgnet_mod.tl = tgnet_tl_mod
messenger_mod = types.ModuleType("org.telegram.messenger")
messenger_mod.AndroidUtilities = type("AndroidUtilities", (), {"dp": staticmethod(lambda v: v)})
messenger_mod.R = type("R", (), {})
messenger_mod.MessageObject = _FakeMessageObject
messenger_mod.MessagesController = _FakeMessagesController
messenger_mod.AnimatedEmojiDrawable = type(
"AnimatedEmojiDrawable", (), {"findDocument": staticmethod(lambda account, doc_id: None)}
)
sys.modules["org.telegram.messenger"] = messenger_mod
org_telegram_mod.messenger = messenger_mod
ui_pkg_mod = types.ModuleType("org.telegram.ui")
ui_pkg_mod.__path__ = []
sys.modules["org.telegram.ui"] = ui_pkg_mod
org_telegram_mod.ui = ui_pkg_mod
components_mod = types.ModuleType("org.telegram.ui.Components")
edit_text_cls = _java_widget_stub("EditTextBoldCursor")
components_mod.EditTextBoldCursor = edit_text_cls
components_mod.LayoutHelper = type(
"LayoutHelper",
(),
{
"MATCH_PARENT": -1,
"WRAP_CONTENT": -2,
"createLinear": staticmethod(lambda *a, **k: None),
},
)
sys.modules["org.telegram.ui.Components"] = components_mod
ui_pkg_mod.Components = components_mod
actionbar_mod = types.ModuleType("org.telegram.ui.ActionBar")
class _FakeTheme:
# A handful of real key_* names used by src/ui/theme.py; getattr on
# any key_* name returns the name itself (getColor below just
# echoes a stable fake int derived from it).
key_dialogBackground = "key_dialogBackground"
key_dialogTextBlack = "key_dialogTextBlack"
key_dialogTextGray3 = "key_dialogTextGray3"
key_windowBackgroundWhiteHintText = "key_windowBackgroundWhiteHintText"
key_windowBackgroundWhiteBlackText = "key_windowBackgroundWhiteBlackText"
key_dialogInputField = "key_dialogInputField"
key_dialogInputFieldActivated = "key_dialogInputFieldActivated"
key_windowBackgroundWhiteBlueText = "key_windowBackgroundWhiteBlueText"
@staticmethod
def getColor(key):
return -1 # any deterministic int; value is never asserted on
actionbar_mod.Theme = _FakeTheme
sys.modules["org.telegram.ui.ActionBar"] = actionbar_mod
ui_pkg_mod.ActionBar = actionbar_mod
requests_mod = types.ModuleType("requests")
# Captures the last request body so checks can assert what was actually
# sent (system prompt, JSON transcript) rather than trusting the caller.
LAST_REQUEST.clear()
class _FakeResponse:
status_code = 200
text = "{}"
def iter_lines(self):
for line in (
b'data: {"choices":[{"delta":{"reasoning_content":"Thinking."}}]}',
b"",
b'data: {"choices":[{"delta":{"content":"Fake summary."}}]}',
b"data: [DONE]",
):
yield line
def close(self):
pass
def fake_post(url, headers=None, json=None, timeout=None, stream=False):
LAST_REQUEST["url"] = url
LAST_REQUEST["headers"] = headers or {}
LAST_REQUEST["json"] = json or {}
return _FakeResponse()
requests_mod.post = fake_post
sys.modules["requests"] = requests_mod
KEY_TO_DUNDER = {
"id": "__id__",
"name": "__name__",
"description": "__description__",
"author": "__author__",
"version": "__version__",
"icon": "__icon__",
"app_version": "__app_version__",
"sdk_version": "__sdk_version__",
"requirements": "__requirements__",
}
def expected_dunders(meta: dict) -> Dict[str, Any]:
result = {}
for key, dunder in KEY_TO_DUNDER.items():
if key not in meta:
continue
value = meta[key]
if key in ("description", "icon") and value == "":
continue
if key == "requirements" and value == []:
continue
result[dunder] = value
return result
def main(argv=None) -> int:
argv = sys.argv[1:] if argv is None else argv
if len(argv) != 1:
print("usage: tools/verify_bundle.py <bundle.plugin>", file=sys.stderr)
return 1
bundle_path = Path(argv[0])
if not bundle_path.is_file():
print(f"error: bundle not found at {bundle_path}", file=sys.stderr)
return 1
install_fakes()
source = bundle_path.read_text(encoding="utf-8")
modules_before = set(sys.modules)
ns = {"__name__": "chat_summaries"}
exec(compile(source, str(bundle_path), "exec"), ns)
new_modules = set(sys.modules) - modules_before
meta = json.loads(PLUGIN_JSON.read_text(encoding="utf-8"))
expected = expected_dunders(meta)
mismatches = [
f"{dunder}: expected {value!r}, got {ns.get(dunder)!r}"
for dunder, value in expected.items()
if ns.get(dunder) != value
]
check("metadata constants match plugin.json", not mismatches, "; ".join(mismatches))
tree = ast.parse(source, filename=str(bundle_path))
top_level_assigned = {
target.id
for node in tree.body
if isinstance(node, ast.Assign)
for target in node.targets
if isinstance(target, ast.Name)
}
check(
"metadata exposed as top-level literal assignments (AST)",
set(expected) <= top_level_assigned,
f"missing: {set(expected) - top_level_assigned}",
)
plugin_classes = [
value
for value in ns.values()
if isinstance(value, type) and issubclass(value, BasePlugin) and value is not BasePlugin
]
check(
"exactly one BasePlugin subclass defined",
len(plugin_classes) == 1,
f"found {len(plugin_classes)}",
)
plugin_cls = plugin_classes[0] if len(plugin_classes) == 1 else None
if plugin_cls is not None:
check(
"plugin class __module__ matches executed namespace",
plugin_cls.__module__ == ns["__name__"],
f"__module__={plugin_cls.__module__!r} != {ns['__name__']!r}",
)
plugin = plugin_cls()
MENU_ITEMS.clear()
LOG_LINES.clear()
plugin.on_plugin_load()
check(
"on_plugin_load emits load log line",
any("chat_summaries loaded" in line for line in LOG_LINES),
f"log lines: {LOG_LINES}",
)
check(
"on_plugin_load registers Summarize and Copy diagnostics CHAT_ACTION_MENU items",
len(MENU_ITEMS) == 2
and all(m.menu_type == MenuItemType.CHAT_ACTION_MENU for m in MENU_ITEMS)
and {m.text for m in MENU_ITEMS} == {"Summarize", "Copy diagnostics"},
f"menu items: {[(getattr(m, 'text', None), getattr(m, 'menu_type', None)) for m in MENU_ITEMS]}",
)
check(
"unread_hook installs dispatchTouchEvent, onPageDownClicked, and ChatUnreadCell hooks",
len(plugin.unread_hook._unhooks) == 3,
f"installed hooks: {len(plugin.unread_hook._unhooks)} (expected 3)",
)
# unread_count decays while a chat is open (markDialogAsRead runs as
# messages are viewed), so pressing the unread button a second time used
# to prefill a far smaller range than the first press: 1100 then 15 then
# 75. The resolved count must not shrink while the chat stays open.
class _UnreadDialog:
def __init__(self, count):
self.unread_count = count
class _UnreadFragment:
def __init__(self, divider, ids):
self.createUnreadMessageAfterId = divider
self.messages = _JavaList()
for mid in ids:
owner_obj = _TLObj("TL_message")
owner_obj.id = mid
self.messages.add(_FakeMessageObject(0, owner_obj))
unread_dialog = _UnreadDialog(1100)
cu_mod = sys.modules["client_utils"]
real_controller = cu_mod.get_messages_controller
class _UnreadController:
class dialogs_dict:
@staticmethod
def get(_dialog_id):
return unread_dialog
cu_mod.get_messages_controller = lambda: _UnreadController
try:
unread_fragment = _UnreadFragment(171000, list(range(172700, 172670, -1)))
resolved = []
for decayed in (1100, 15, 75):
unread_dialog.unread_count = decayed
resolved.append(
plugin.unread_hook._resolve_unread_count(unread_fragment, -1978105175)
)
finally:
cu_mod.get_messages_controller = real_controller
check(
"the unread prefill does not shrink as messages are marked read",
resolved == [1100, 1100, 1100],
f"resolved={resolved}",
)
settings_rows = plugin.create_settings()
settings_keys = {row.key for row in settings_rows if hasattr(row, "key")}
expected_keys = {
"custom_base_url",
"custom_api_key",
"custom_model",
"default_count",
"default_offset",
"global_prompt",
"include_attachments",
}
check(
"create_settings exposes endpoint and default settings keys",
expected_keys == settings_keys,
f"missing: {expected_keys - settings_keys}; unexpected: {settings_keys - expected_keys}",
)
# Per-chat/topic prompt override: falls back to global, then to a
# saved override once one is set for that specific (dialog, topic).
dialog_id, topic_id = -100123456, 7
global_prompt_key = "global_prompt"
plugin.set_setting(global_prompt_key, "Global default prompt")
before_override = plugin._get_prompt_for(dialog_id, topic_id)
check(
"prompt falls back to global default with no override",
before_override == "Global default prompt",
f"got {before_override!r}",
)
plugin._save_prompt_for(dialog_id, topic_id, "Chat-specific prompt")
after_override = plugin._get_prompt_for(dialog_id, topic_id)
check(
"per-chat/topic prompt override takes precedence once saved",
after_override == "Chat-specific prompt",
f"got {after_override!r}",
)
other_topic_prompt = plugin._get_prompt_for(dialog_id, topic_id + 1)
check(
"prompt override is scoped per-topic, not just per-chat",
other_topic_prompt == "Global default prompt",
f"got {other_topic_prompt!r}",
)
# Dispatcher: OpenAI path against the faked requests module. FormattedMessage
# is a private bundled module (never in sys.modules), so duck-type instead
# of importing it -- the dispatcher only needs a message with these attrs.
class _FakeMessage:
def __init__(self, sender_name, text, id=0, sender_id=0, reply_to_id=0,
reply_highlight="", attachments=None):
self.sender_name = sender_name
self.text = text
self.id = id
self.date = 1700000000
self.sender_id = sender_id
self.reply_to_id = reply_to_id
self.reply_highlight = reply_highlight
self.attachments = list(attachments or [])
fake_messages = [_FakeMessage("Alice", "hello", id=10, sender_id=111)]
plugin.set_setting("custom_base_url", "https://example.test/v1")
plugin.set_setting("custom_api_key", "sk-test")
results = {}
def on_done(answer, thinking, error):
results["answer"] = answer
results["error"] = error
plugin.dispatcher.summarize_streaming(fake_messages, "Summarize", lambda k, t: None, on_done)
check(
"dispatcher streams a summary from a faked SSE response",
results.get("error") is None and results.get("answer") == "Fake summary.",
f"results: {results}",
)
# The request must carry three separate roles, not one concatenated
# blob: script-owned system prompt, the user's instructions, and the
# anonymized JSON transcript.
sent = LAST_REQUEST.get("json") or {}
roles = [m["role"] for m in sent.get("messages", [])]
contents = [m["content"] for m in sent.get("messages", [])]
check(
"request sends a system prompt, the user prompt, and the transcript",
roles == ["system", "user", "user"]
and "anonymized" in contents[0]
and contents[1] == "Summarize"
and contents[2].startswith('{"messages":['),
f"roles={roles} third={contents[2][:60] if len(contents) > 2 else None!r}",
)
check(
"the system prompt documents the mention syntax the model must use",
"{userN}" in contents[0] and "{msgN}" in contents[0],
)
check(
"streaming is requested so deltas arrive incrementally",
sent.get("stream") is True,
f"stream={sent.get('stream')}",
)
# Endpoint is mandatory now that there is no built-in provider: an
# unconfigured base URL must produce a clear error, not a crash.
plugin.set_setting("custom_base_url", "")
unconfigured = {}
def on_unconfigured(answer, thinking, error):
unconfigured["answer"] = answer
unconfigured["error"] = error
plugin.dispatcher.summarize_streaming(
fake_messages, "Summarize", lambda k, t: None, on_unconfigured
)
check(
"dispatcher reports a clear error when no base URL is configured",
unconfigured.get("answer") is None
and "base URL" in (unconfigured.get("error") or ""),
f"results: {unconfigured}",
)
plugin.set_setting("custom_base_url", "https://example.test/v1")
# MessageFetcher: no controller available -> graceful error, not a crash.
fetch_results = {}
def on_fetched(messages, error):
fetch_results["messages"] = messages
fetch_results["error"] = error
plugin.fetcher.fetch_history(dialog_id=dialog_id, count=10, offset=0, callback=on_fetched)
check(
"fetch_history reports an error when no messages controller is available",
fetch_results.get("error") is not None,
f"fetch_results: {fetch_results}",
)
# Telegram returns java.util.ArrayList, which Chaquopy exposes without
# the iterator protocol: list()/for-in raise TypeError. The fetcher must
# read it through size()/get(i).
page_results = {}
def on_page(messages, error):
page_results["messages"] = messages
page_results["error"] = error
fake_response = _TLObj("TL_messages_messages")
raw_message = _TLObj("TL_message")
raw_message.id = 4242
raw_message.date = 1700000000
raw_message.message = "hello from java"
raw_message.from_id = None
fake_response.messages = _JavaArrayList([raw_message])
fake_response.users = _JavaArrayList()
fake_response.chats = _JavaArrayList()
plugin.fetcher._handle_response(
fake_response,
None,
[],
on_page,
dialog_id,
10,
0,
0,
100,
)
check(
"fetcher reads a non-iterable Java ArrayList response via size()/get()",
page_results.get("error") is None
and len(page_results.get("messages") or []) == 1
and page_results["messages"][0].text == "hello from java",
f"page_results={page_results}",
)
# Provisional anchor, chosen before the fetch returns: with an offset
# the analyzed range ends partway up the history, so the guess has to
# skip that many messages instead of grabbing the newest.
class _AnchorList(list):
def size(self):
return len(self)
def get(self, index):
return self[index]
class _AnchorActivity:
def __init__(self, ids):
self.messages = _AnchorList()
for mid in ids:
owner_obj = _TLObj("TL_message")
owner_obj.id = mid
holder = types.SimpleNamespace(messageOwner=owner_obj)
self.messages.append(holder)
anchor_activity = _AnchorActivity([900, 800, 700, 600])
check(
"with no offset the provisional anchor is the newest message",
plugin._newest_visible_message_id(anchor_activity) == 900,
f"got {plugin._newest_visible_message_id(anchor_activity)}",
)
check(
"an offset moves the provisional anchor up the history",
plugin._newest_visible_message_id(anchor_activity, skip=2) == 700,
f"got {plugin._newest_visible_message_id(anchor_activity, skip=2)}",
)
# Injected cards carry negative ids and must never be chosen as the
# anchor, or a second summary would anchor to the first one's card.
mixed_anchor = _AnchorActivity([-700007, 900, 800])
check(
"an existing injected card is never picked as the anchor",
plugin._newest_visible_message_id(mixed_anchor) == 900,
f"got {plugin._newest_visible_message_id(mixed_anchor)}",
)
# ------------------------------------------------------------------
# Bundled-module checks: markdown serialization, SSE streaming, and the
# SQLite store. Reached through the bundle's private registry, since
# bundled modules deliberately never enter sys.modules.
# ------------------------------------------------------------------
loaded = ns.get("_LOADED") or {}
bundle_import = ns.get("_bundle_load")
def bundled(name):
if name in loaded:
return loaded[name]
if bundle_import is not None:
try:
return bundle_import(name)
except Exception:
return None
return None
rich_markdown = bundled("services.rich_markdown")
if rich_markdown is None:
check("services.rich_markdown is importable from the bundle", False, "module not found")
else:
blocks = rich_markdown.markdown_to_blocks(
"# Title\n\n"
"Plain **bold** and `code`.\n\n"
"- one\n- two\n\n"
"1. first\n2. second\n\n"
"> quoted\n\n"
"---\n\n"
"```python\nprint(1)\n```\n"
)
kinds = [b.tl_name for b in blocks]
check(
"markdown_to_blocks maps every markdown construct to its block type",
kinds
== [
"pageBlockHeading1",
"pageBlockParagraph",
"pageBlockList",
"pageBlockOrderedList",
"pageBlockBlockquote",
"pageBlockDivider",
"pageBlockPreformatted",
],
f"got {kinds}",
)
code_block = blocks[-1]
check(
"fenced code block keeps its language and body",
code_block.language == "python" and code_block.text.text == "print(1)",
f"language={code_block.language!r} text={getattr(code_block.text, 'text', None)!r}",
)
check(
"list blocks carry one item per bullet",
len(blocks[2].items) == 2 and len(blocks[3].items) == 2,
f"bullet items={len(blocks[2].items)} ordered items={len(blocks[3].items)}",
)
check(
"ordered list items are numbered from the markdown start value",
[i.num for i in blocks[3].items] == ["1", "2"],
f"nums={[i.num for i in blocks[3].items]}",
)
# Inline styling must nest into a RichText tree, not be dropped.
inline = rich_markdown.build_rich_text("a **b** c `d` [e](http://x)")
inline_kinds = [p.tl_name for p in inline.texts]
check(
"inline markdown becomes bold/fixed/url RichText nodes",
"textBold" in inline_kinds and "textFixed" in inline_kinds and "textUrl" in inline_kinds,
f"kinds={inline_kinds}",
)
# Reasoning still arriving (no answer yet) animates as a thinking
# block; once the answer starts it is finished, so it collapses into a
# tappable details container that can be revealed and hidden.
reasoning_only = rich_markdown.build_rich_message("", thinking="reasoning", streaming=True)
check(
"reasoning still streaming renders as an animated thinking block",
[b.tl_name for b in reasoning_only.blocks] == ["pageBlockThinking"],
f"blocks={[b.tl_name for b in reasoning_only.blocks]}",
)
streaming_rm = rich_markdown.build_rich_message("answer", thinking="reasoning", streaming=True)
details = streaming_rm.blocks[0]
check(
"finished reasoning collapses into a closed details block",
details.tl_name == "pageBlockDetails"
and details.open is False
and details.title.text == rich_markdown.THINKING_TITLE
and len(details.blocks) == 1,
f"first={details.tl_name} open={getattr(details, 'open', None)}",
)
# `part` drives RichMessageLayout's partial branch, which also draws a
# server-backed "Show more" button; it must stay off for local summaries.
final_rm = rich_markdown.build_rich_message("answer", thinking="reasoning", streaming=False)
check(
"part stays unset so no Show more collapse is drawn",
streaming_rm.part is False and final_rm.part is False,
f"streaming={streaming_rm.part} final={final_rm.part}",
)
# Stage feedback: the card is injected on click, before any content
# exists, so a bare status must still render as a shimmer block.
status_rm = rich_markdown.build_rich_message("", status="Fetching messages…", streaming=True)
check(
"status-only RichMessage renders the stage as a thinking block",
len(status_rm.blocks) == 1 and status_rm.blocks[0].tl_name == "pageBlockThinking",
f"blocks={[b.tl_name for b in status_rm.blocks]}",
)
superseded = rich_markdown.build_rich_message(
"", thinking="real reasoning", status="Fetching messages…", streaming=True
)
check(
"real reasoning supersedes the status placeholder",
len(superseded.blocks) == 1
and superseded.blocks[0].text.text == "real reasoning",
f"blocks={[b.tl_name for b in superseded.blocks]}",
)
answered = rich_markdown.build_rich_message(
"# Answer", status="Summarizing…", streaming=True
)
check(
"status is dropped once answer text arrives",
[b.tl_name for b in answered.blocks] == ["pageBlockHeading1"],
f"blocks={[b.tl_name for b in answered.blocks]}",
)
empty_rm = rich_markdown.build_rich_message("", streaming=True)
check(
"an empty RichMessage still carries one block so the cell renders",
len(empty_rm.blocks) == 1,
f"blocks={len(empty_rm.blocks)}",
)
transcript_mod = bundled("services.transcript")
if transcript_mod is None:
check("services.transcript is importable from the bundle", False, "module not found")
else:
class _Msg:
def __init__(self, id, sender_id, sender_name, text,
reply_to_id=0, reply_highlight="", attachments=None,
sender_kind="user"):
self.id = id
self.date = 1700000000
self.sender_id = sender_id
self.sender_name = sender_name
self.sender_kind = sender_kind
self.text = text
self.reply_to_id = reply_to_id
self.reply_highlight = reply_highlight
self.attachments = list(attachments or [])
convo = [
_Msg(5001, 111, "Alice", "got new crocs", attachments=["randhash.png"]),
_Msg(5002, 222, "Bob", "cap", reply_to_id=5001, reply_highlight="new"),
_Msg(5003, 111, "Alice", "fr", reply_to_id=4000),
_Msg(5004, 1001234567890, "Some Channel", "announcement", sender_kind="chat"),
]
payload, mapping = transcript_mod.build_transcript(convo)
parsed = json.loads(payload)
check(
"transcript numbers messages from 1 and senders from 1 by first appearance",
[(m["id"], m["user"]) for m in parsed["messages"]] == [(1, 1), (2, 2), (3, 1), (4, 3)],
f"got {[(m['id'], m['user']) for m in parsed['messages']]}",
)
check(
"transcript is compact JSON with no spacing",
" " not in payload.replace("got new crocs", "").replace("randhash.png", ""),
f"payload={payload[:120]}",
)
check(
"reply_to is remapped to the anonymized id and carries the highlight",
parsed["messages"][1].get("reply_to") == 1
and parsed["messages"][1].get("reply_highlight") == "new",
f"entry={parsed['messages'][1]}",
)
check(
"real Telegram ids never reach the transcript",
"5001" not in payload and "111" not in payload and "4000" not in payload,
f"payload={payload}",
)
# Attachments are opt-in: message text is always sent, file names only
# when the setting is enabled.
check(
"attachments are omitted unless explicitly included",
"attachments" not in payload,
f"payload={payload}",
)
with_attachments, _ = transcript_mod.build_transcript(convo, include_attachments=True)
check(
"attachments are listed when included",
json.loads(with_attachments)["messages"][0].get("attachments") == ["randhash.png"],
f"entry={json.loads(with_attachments)['messages'][0]}",
)
# A reply whose parent was not analyzed loses meaning, so the parent is
# injected with a negative id marking it as outside the range.
parent = _Msg(4000, 333, "Carol", "original point")
ranged, ranged_map = transcript_mod.build_transcript(convo, extra_by_id={4000: parent})
ranged_entries = json.loads(ranged)["messages"]
ids = [m["id"] for m in ranged_entries]
check(
"an out-of-range reply parent is injected with a negative id before its child",
ids == [1, 2, -1, 3, 4] and ranged_entries[2]["text"] == "original point",
f"ids={ids}",
)
check(
"the injected parent is the reply target of its child",
ranged_entries[3].get("reply_to") == -1,
f"entry={ranged_entries[3]}",
)
check(
"out-of-range parents stay resolvable back to their real id",
ranged_map.real_message_id(-1) == 4000,
f"mapped={ranged_map.real_message_id(-1)}",
)
# The model answers with placeholders; substitution supplies the real
# display name and turns citations into tappable deep links.
answer = "{user1} said it broke ({msg1}, {msg2}), {user2} agreed."
rendered = transcript_mod.deanonymize(answer, mapping, -1001234567890)
check(
"user placeholders resolve to the real display name and profile link",
"[Alice](tg://user?id=111)" in rendered
and "[Bob](tg://user?id=222)" in rendered,
f"rendered={rendered}",
)
check(
"message placeholders become numbered links into this dialog",
"[№2](tg://openmessage?chat_id=1234567890&message_id=5002)" in rendered,
f"rendered={rendered}",
)
check(
"no placeholder survives substitution",
"{user" not in rendered and "{msg" not in rendered,
f"rendered={rendered}",
)
check(
"private-chat message links use user_id rather than chat_id",
"user_id=777" in transcript_mod.message_link(777, 42),
transcript_mod.message_link(777, 42),
)
# A group or channel sender is not a user: tg://user?id= on one reports
# "user not found", so chat senders need the chat link form.
check(
"a chat/channel sender links to the chat, not a user profile",
"[Some Channel](tg://openmessage?chat_id=1234567890)"
in transcript_mod.deanonymize("{user3} posted", mapping, -1),
transcript_mod.deanonymize("{user3} posted", mapping, -1),
)
check(
"sender_link uses the user form only for real users",
transcript_mod.sender_link(111, "user") == "tg://user?id=111"
and transcript_mod.sender_link(1001234567890, "chat")
== "tg://openmessage?chat_id=1234567890",
f"user={transcript_mod.sender_link(111, 'user')} "
f"chat={transcript_mod.sender_link(1001234567890, 'chat')}",
)
# tg://openmessage carries no thread parameter, so inside a forum it
# lands in the supergroup's global feed. The t.me/c form is the only
# one that opens the right topic.
check(
"a topic message link targets the topic, not the supergroup feed",
transcript_mod.message_link(-1001234567890, 42, 7)
== "https://t.me/c/1234567890/7/42",
transcript_mod.message_link(-1001234567890, 42, 7),
)
check(
"a group without a topic still uses the openmessage form",
transcript_mod.message_link(-1001234567890, 42, 0)
== "tg://openmessage?chat_id=1234567890&message_id=42",
transcript_mod.message_link(-1001234567890, 42, 0),
)
topic_payload, topic_mapping = transcript_mod.build_transcript(convo, topic_id=9)
check(
"the topic travels on the mapping so citations open in-topic",
topic_mapping.topic_id == 9
and "/c/1234567890/9/5002"
in transcript_mod.deanonymize("{msg2}", topic_mapping, -1001234567890),
transcript_mod.deanonymize("{msg2}", topic_mapping, -1001234567890),
)
check(
"the topic id is not leaked into the transcript payload",
'"topic' not in topic_payload,
f"payload={topic_payload[:80]}",
)
check(
"an unknown user placeholder degrades to readable text",
transcript_mod.deanonymize("{user99} spoke", mapping, -1) == "someone spoke",
transcript_mod.deanonymize("{user99} spoke", mapping, -1),
)
check(
"an unknown message placeholder is dropped rather than left dangling",
transcript_mod.deanonymize("see {msg99}", mapping, -1) == "see ",
transcript_mod.deanonymize("see {msg99}", mapping, -1),
)
# Streaming safety: this is why placeholders replaced Markdown links.
# A half-received token must not render as literal text.
check(
"a placeholder still being streamed is suppressed, not shown raw",
transcript_mod.strip_partial_placeholder("build broke ({msg1") == "build broke (",
transcript_mod.strip_partial_placeholder("build broke ({msg1"),
)
check(
"complete placeholders earlier in the text survive stripping",
transcript_mod.strip_partial_placeholder("a {msg1} b {us") == "a {msg1} b ",
transcript_mod.strip_partial_placeholder("a {msg1} b {us"),
)
check(
"text with no trailing placeholder is left untouched",
transcript_mod.strip_partial_placeholder("all done.") == "all done.",
)
# Reasoning is model-authored text too, so it carries placeholders and
# must go through the same substitution -- leaving them raw would show
# the reader the anonymized namespace.
reasoning = "Looking at {user1} and {msg2} to decide."
rendered_reasoning = transcript_mod.deanonymize(reasoning, mapping, -1001234567890)
check(
"reasoning text is substituted, not left with raw placeholders",
"{user" not in rendered_reasoning
and "{msg" not in rendered_reasoning
and "Alice" in rendered_reasoning
and "tg://" in rendered_reasoning,
f"rendered={rendered_reasoning}",
)
# Privacy boundary: only text and attachments cross the wire verbatim.
# Names, ids and topic stay local, while the DB keeps the real thing.
check(
"sender names never reach the transcript payload",
"Alice" not in payload and "Bob" not in payload and "Some Channel" not in payload,
f"payload={payload}",
)
check(
"the mapping retains real names and ids for local substitution",
mapping.user_name(1) == "Alice"
and mapping.real_user_id(1) == 111
and mapping.real_message_id(2) == 5002
and mapping.sender_kind(3) == "chat",
f"name={mapping.user_name(1)} id={mapping.real_user_id(1)} kind={mapping.sender_kind(3)}",
)
check(
"message text and attachment names are the only verbatim fields",
"got new crocs" in payload
and "randhash.png" in json.loads(with_attachments)["messages"][0]["attachments"],
f"payload={payload[:100]}",
)
streaming_mod = bundled("providers.streaming")
if streaming_mod is None:
check("providers.streaming is importable from the bundle", False, "module not found")
else:
# Reasoning fields differ per vendor; all three must be recognised.
check(
"reasoning_content and reasoning deltas are classified as thinking",
streaming_mod._extract_delta({"choices": [{"delta": {"reasoning_content": "r"}}]}) == ("thinking", "r")
and streaming_mod._extract_delta({"choices": [{"delta": {"reasoning": "r"}}]}) == ("thinking", "r"),
)
check(
"content deltas are classified as answer",
streaming_mod._extract_delta({"choices": [{"delta": {"content": "c"}}]}) == ("answer", "c"),
)
# A <think> tag split across chunks must not leak into the answer.
splitter = streaming_mod._ThinkTagSplitter()
got = []
for piece in ["before<thi", "nk>rea", "soning</thi", "nk>after"]:
got.extend(splitter.feed(piece))
got.extend(splitter.flush())
joined = [
("thinking", "".join(t for k, t in got if k == "thinking")),
("answer", "".join(t for k, t in got if k == "answer")),
]
check(
"think tags split across SSE chunks are reassembled correctly",
joined == [("thinking", "reasoning"), ("answer", "beforeafter")],
f"got {joined}",
)
sse_lines = [
b'data: {"choices":[{"delta":{"content":"hi"}}]}',
b"",
b"data: [DONE]",
b'data: {"choices":[{"delta":{"content":"never"}}]}',
]
objects = list(streaming_mod.iter_sse_objects(iter(sse_lines)))
check(
"SSE parsing stops at [DONE] and ignores blank lines",
len(objects) == 1,
f"parsed {len(objects)} objects",
)
summary_db_mod = bundled("services.summary_db")
if summary_db_mod is None:
check("services.summary_db is importable from the bundle", False, "module not found")
else:
db = summary_db_mod.SummaryDb(str(Path(tempfile.mkdtemp()) / "summaries.db"))
first = db.save(-100, 0, 555, "text one", "thinking one", 42)
rows = db.get_for_scope(-100, 0)
check(
"summary_db persists text, anchor id, and message count",
len(rows) == 1
and rows[0].anchor_msg_id == 555
and rows[0].text == "text one"
and rows[0].thinking == "thinking one"
and rows[0].msg_count == 42,
f"rows={rows}",
)
# Re-saving the same anchor must update in place: streaming reserves a
# row first and rewrites it when the stream finishes.
second = db.save(-100, 0, 555, "text two", "thinking two", 43)
rows = db.get_for_scope(-100, 0)
check(
"re-saving the same anchor updates the existing row instead of duplicating",
second == first and len(rows) == 1 and rows[0].text == "text two",
f"first={first} second={second} rows={rows}",
)
db.save(-100, 7, 555, "topic text", "", 1)
check(
"summaries are scoped per topic, not just per chat",
len(db.get_for_scope(-100, 0)) == 1 and len(db.get_for_scope(-100, 7)) == 1,
f"topic0={len(db.get_for_scope(-100, 0))} topic7={len(db.get_for_scope(-100, 7))}",
)
check("summary_db.delete removes a row", db.delete(first) and db.get(first) is None)
injector = bundled("services.rich_injector")
if injector is None:
check("services.rich_injector is importable from the bundle", False, "module not found")
else:
check(
"mock sender id is positive so getUser can resolve it",
injector.MOCK_SENDER_ID > 0,
f"id={injector.MOCK_SENDER_ID}",
)
user = injector.register_mock_sender(0)
from org.telegram.messenger import MessagesController # noqa: E402
resolved = MessagesController.getInstance(0).getUser(injector.MOCK_SENDER_ID)
check(
"mock sender resolves from the local user cache with a name",
resolved is not None and resolved.first_name == injector.MOCK_SENDER_NAME,
f"resolved={resolved}",
)
# Peer subtype drives isOutOwner; a wrong type renders as outgoing.
check(
"peer_for_dialog picks the peer subtype matching the dialog kind",
injector.peer_for_dialog(12345).tl_name == "TL_peerUser"
and injector.peer_for_dialog(-4242).tl_name == "TL_peerChat"
and injector.peer_for_dialog(-1001234567890).tl_name == "TL_peerChannel",
)
check(
"channel peer id is converted out of dialog-id space",
injector.peer_for_dialog(-1001234567890).channel_id == 1234567890,
f"channel_id={injector.peer_for_dialog(-1001234567890).channel_id}",
)
message_object = injector.build_summary_message(
0, -4242, 0, 555, injector.virtual_id_for(1, 555), "# Answer", "reasoning", True
)
owner = message_object.messageOwner
check(
"summary message carries rich_message with the type-36 flag bit",
owner.rich_message is not None
and bool(owner.flags2 & injector.FLAGS2_RICH_MESSAGE)
and message_object.type == 36,
f"flags2={owner.flags2} type={message_object.type}",
)
check(
"summary message is attributed to the mock sender and not outgoing",
owner.from_id.user_id == injector.MOCK_SENDER_ID
and owner.out is False
and message_object.isOutOwner() is False,
)
check(
"summary message replies to the newest analyzed message",
owner.reply_to.reply_to_msg_id == 555,
f"reply_to={owner.reply_to}",
)
check(
"virtual ids are negative and stable per (summary, anchor)",
injector.virtual_id_for(1, 555) < 0
and injector.virtual_id_for(1, 555) == injector.virtual_id_for(1, 555)
and injector.virtual_id_for(2, 555) != injector.virtual_id_for(1, 555),
)
# Insertion order: ChatActivity.messages is newest-first, so the
# summary must land at the anchor's index to sit after it on screen.
class _FakeAdapter:
def __init__(self):
self.notified = 0
def notifyDataSetChanged(self):
self.notified += 1
class _FakeCell:
"""Live ChatMessageCell: the row the targeted repaint should hit."""
def __init__(self, message_object):
self._message_object = message_object
self.rebound = 0
self.invalidated = 0
def getMessageObject(self):
return self._message_object
def setMessageObject(self, mo, grouped, pinned_bottom, pinned_top, first):
self._message_object = mo
self.rebound += 1
def invalidate(self):
self.invalidated += 1
class _FakeDelegate:
def __init__(self):
self.forced = 0
def forceUpdate(self, cell, a, b):
self.forced += 1
class _FakeListView:
def __init__(self):
self.cells = []
def getChildCount(self):
return len(self.cells)
def getChildAt(self, index):
return self.cells[index]
class _FakeChatActivity:
def __init__(self, ids):
self.messages = _JavaList()
for mid in ids:
owner_obj = _TLObj("TL_message")
owner_obj.id = mid
self.messages.add(_FakeMessageObject(0, owner_obj))
self.chatAdapter = _FakeAdapter()
self.chatMessageCellDelegate = _FakeDelegate()
self.chatListView = _FakeListView()
self.scrolled_to = []
def getDialogId(self):
return -4242
def scrollToMessageId(self, msg_id, from_id, select, load_index, force, unread):
self.scrolled_to.append(msg_id)
activity = _FakeChatActivity([600, 555, 500])
check(
"insertion index places the summary directly after its anchor",
injector.find_index_for_anchor(activity.messages, 555) == 1,
f"index={injector.find_index_for_anchor(activity.messages, 555)}",
)
# The anchor is the newest ANALYZED message, which with a non-zero
# offset is not the newest message in the chat. The card must land
# there, never at the bottom.
offset_activity = _FakeChatActivity([900, 800, 700, 600, 500])
check(
"with an offset the card lands at the analyzed range, not the chat bottom",
injector.find_index_for_anchor(offset_activity.messages, 700) == 2,
f"index={injector.find_index_for_anchor(offset_activity.messages, 700)}",
)
# Anchor not loaded in this view: falling back to index 0 would put the
# card below messages the summary never saw, which is the bug this
# ordering fallback exists to prevent.
# These exercise the id-ordering fallback itself, which is now opt-in:
# callers that must place a card somewhere (reanchor) ask for it, while
# the default refuses to guess a position for an unloaded anchor.
check(
"requested id ordering places an unloaded anchor by id",
injector.find_index_for_anchor(offset_activity.messages, 750, require_anchor=False) == 2,
f"index={injector.find_index_for_anchor(offset_activity.messages, 750, require_anchor=False)}",
)
check(
"an anchor older than everything loaded lands at the top of the list",
injector.find_index_for_anchor(offset_activity.messages, 100, require_anchor=False)
== offset_activity.messages.size(),
f"index={injector.find_index_for_anchor(offset_activity.messages, 100, require_anchor=False)}",
)
check(
"an anchor newer than everything loaded lands at the bottom",
injector.find_index_for_anchor(offset_activity.messages, 1000, require_anchor=False) == 0,
f"index={injector.find_index_for_anchor(offset_activity.messages, 1000, require_anchor=False)}",
)
# The real invariant is positional, not a fixed index: every message
# newer than the anchor stays below the card, every older one above,
# regardless of injected cards already sitting in the list.
def neighbours(activity_obj, anchor):
# require_anchor=False so the invariant is checked for unloaded
# anchors too: when a position is chosen at all, it must be correct.
index = injector.find_index_for_anchor(
activity_obj.messages, anchor, require_anchor=False
)
ids = [m.messageOwner.id for m in activity_obj.messages]
real = [i for i in ids if i > 0]
below = [i for i in ids[:index] if i > 0]
above = [i for i in ids[index:] if i > 0]
return below, above, real
mixed = _FakeChatActivity([900, 800, 700])
injected_owner = _TLObj("TL_message")
injected_owner.id = -800007
mixed.messages.add(1, _FakeMessageObject(0, injected_owner))
below, above, _ = neighbours(mixed, 750)
check(
"an injected card already in the list does not misplace the next one",
all(i > 750 for i in below) and all(i < 750 for i in above),
f"below={below} above={above}",
)
# Same invariant across the offset cases, which is what "injected only
# after the latest analyzed message" actually means.
for anchor in (700, 750, 900, 100, 1000):
below, above, _ = neighbours(offset_activity, anchor)
check(
f"no message newer than anchor {anchor} ends up below the card",
all(i > anchor for i in below) and all(i <= anchor for i in above),
f"anchor={anchor} below={below} above={above}",
)
virtual_id = injector.virtual_id_for(9, 555)
inj = injector.SummaryInjection(activity, 0, -4242, 0, 555, virtual_id)
inserted = inj.insert(status="Fetching messages…", streaming=True)
check(
"injection inserts one message at the anchor index and refreshes the adapter",
inserted
and activity.messages.size() == 4
and activity.messages.get(1).messageOwner.id == virtual_id
and activity.chatAdapter.notified >= 1,
f"size={activity.messages.size()} notified={activity.chatAdapter.notified}",
)
# The analyzed range can end far up the history, so the card may be
# injected off-screen; the chat has to move to it. The anchor is the
# scroll target because it is a real message the app can resolve, and
# the card sits directly above it.
check(
"inserting the card scrolls the chat to the analyzed range",
activity.scrolled_to == [555],
f"scrolled_to={activity.scrolled_to}",
)
before = activity.messages.get(1)
inj.update(answer_markdown="final answer", thinking="reasoning", streaming=False)
after = activity.messages.get(1)
check(
"update swaps in a rebuilt MessageObject rather than mutating in place",
activity.messages.size() == 4
and after is not before
and after.messageOwner.id == virtual_id
and after.type == 36
and [b.tl_name for b in after.messageOwner.rich_message.blocks]
== ["pageBlockDetails", "pageBlockParagraph"],
f"size={activity.messages.size()} same={after is before} "
f"blocks={[b.tl_name for b in after.messageOwner.rich_message.blocks]}",
)
check(
"a streaming update does not scroll again and fight the user",
activity.scrolled_to == [555],
f"scrolled_to={activity.scrolled_to}",
)
# Streaming repaints should hit the one visible cell, not rebind every
# row: notifyDataSetChanged() on each delta is what made the card
# flicker and lose scroll position.
activity.chatListView.cells = [_FakeCell(activity.messages.get(1))]
notified_before = activity.chatAdapter.notified
inj.update(answer_markdown="more text", thinking="reasoning", streaming=True)
check(
"a streaming repaint updates the live cell instead of the whole list",
activity.chatMessageCellDelegate.forced == 1
and activity.chatListView.cells[0].rebound == 1
and activity.chatAdapter.notified == notified_before,
f"forced={activity.chatMessageCellDelegate.forced} "
f"rebound={activity.chatListView.cells[0].rebound} "
f"notified={activity.chatAdapter.notified} (was {notified_before})",
)
# Off-screen card: no cell to repaint, so the adapter must be told.
activity.chatListView.cells = []
notified_before = activity.chatAdapter.notified
inj.update(answer_markdown="offscreen", thinking="reasoning", streaming=True)
check(
"an off-screen card falls back to a full adapter refresh",
activity.chatAdapter.notified == notified_before + 1,
f"notified={activity.chatAdapter.notified} (was {notified_before})",
)
# The card is injected before the fetch, so its anchor starts
# provisional and must be corrected once the real range is known.
reanchor_activity = _FakeChatActivity([600, 555, 500])
provisional_id = injector.virtual_id_for(0, 600)
reinj = injector.SummaryInjection(reanchor_activity, 0, -4242, 0, 600, provisional_id)
reinj.insert(status="Fetching messages…", streaming=True)
check(
"provisional card lands at the newest message before the fetch",
reanchor_activity.messages.get(0).messageOwner.id == provisional_id,
f"ids={[m.messageOwner.id for m in reanchor_activity.messages]}",
)
final_id = injector.virtual_id_for(11, 555)
reinj.reanchor(555, final_id)
ids = [m.messageOwner.id for m in reanchor_activity.messages]
check(
"reanchor moves the card to the real anchor and adopts its virtual id",
reanchor_activity.messages.size() == 4
and ids.index(final_id) == 1
and reanchor_activity.messages.get(1).messageOwner.reply_to.reply_to_msg_id == 555,
f"ids={ids}",
)
check(
"reanchor follows the card to the real end of the analyzed range",
reanchor_activity.scrolled_to == [600, 555],
f"scrolled_to={reanchor_activity.scrolled_to}",
)
# Regression: a streaming update after a reanchor used to add a second
# card, because the lookup missed the old id and fell through to add().
reinj.update(status="Summarizing 98 messages…", streaming=True)
ids = [m.messageOwner.id for m in reanchor_activity.messages]
check(
"updating after a reanchor leaves exactly one summary card",
reanchor_activity.messages.size() == 4
and ids.count(final_id) == 1
and provisional_id not in ids,
f"ids={ids}",
)
# Re-summarizing a range whose saved card the restore hook already
# re-injected must claim that card, not sit beside it.
collide_activity = _FakeChatActivity([600, 555, 500])
saved_id = injector.virtual_id_for(12, 555)
injector.restore_saved_summaries(
collide_activity,
0,
-4242,
0,
[summary_db_mod.StoredSummary(12, -4242, 0, 555, "previous run", "", 5, 0)],
)
collide_provisional = injector.virtual_id_for(0, 600)
collide = injector.SummaryInjection(collide_activity, 0, -4242, 0, 600, collide_provisional)
collide.insert(status="Fetching messages…", streaming=True)
collide.reanchor(555, saved_id)
collide.update(status="Summarizing…", streaming=True)
ids = [m.messageOwner.id for m in collide_activity.messages]
check(
"re-summarizing a restored range reuses its card instead of duplicating",
ids.count(saved_id) == 1 and collide_provisional not in ids,
f"ids={ids}",
)
# Re-inserting the same virtual id must replace, never duplicate.
inj2 = injector.SummaryInjection(activity, 0, -4242, 0, 555, virtual_id)
inj2.insert(answer_markdown="again", thinking="", streaming=False)
check(
"re-injecting an existing virtual id replaces instead of duplicating",
activity.messages.size() == 4,
f"size={activity.messages.size()}",
)
restore_activity = _FakeChatActivity([600, 555, 500])
restored = injector.restore_saved_summaries(
restore_activity,
0,
-4242,
0,
[summary_db_mod.StoredSummary(3, -4242, 0, 555, "saved text", "", 5, 0)],
)
check(
"stored summaries are re-injected when a chat is reopened",
restored == 1 and restore_activity.messages.size() == 4,
f"restored={restored} size={restore_activity.messages.size()}",
)
# Several stored summaries for one chat must each land after their own
# analyzed range, not pile up at one position.
multi_activity = _FakeChatActivity([900, 800, 700, 600, 500])
injector.restore_saved_summaries(
multi_activity,
0,
-4242,
0,
[
summary_db_mod.StoredSummary(11, -4242, 0, 600, "older range", "", 5, 0),
summary_db_mod.StoredSummary(12, -4242, 0, 800, "newer range", "", 5, 0),
],
)
ids = [m.messageOwner.id for m in multi_activity.messages]
positions = {}
for position, msg_id in enumerate(ids):
positions[msg_id] = position
older_card = injector.virtual_id_for(11, 600)
newer_card = injector.virtual_id_for(12, 800)
check(
"each restored summary sits directly above its own anchor",
positions[older_card] == positions[600] - 1
and positions[newer_card] == positions[800] - 1,
f"ids={ids}",
)
check(
"restoring several summaries keeps chat messages in order",
[i for i in ids if i > 0] == [900, 800, 700, 600, 500],
f"ids={ids}",
)
# Bug: every saved summary appeared stacked in a row at the bottom of
# the chat. Cards whose anchor is outside the loaded window have no
# correct position, and placing them by id order alone collapsed them
# together. They must be skipped until paging brings their anchor in.
narrow = _FakeChatActivity([600, 590, 580])
stranded = injector.restore_saved_summaries(
narrow,
0,
-4242,
0,
[
summary_db_mod.StoredSummary(21, -4242, 0, 120, "ancient", "", 5, 0),
summary_db_mod.StoredSummary(22, -4242, 0, 130, "old", "", 5, 0),
summary_db_mod.StoredSummary(23, -4242, 0, 590, "loaded", "", 5, 0),
],
)
narrow_ids = [m.messageOwner.id for m in narrow.messages]
injected = [i for i in narrow_ids if i < 0]
check(
"summaries whose anchor is not loaded are not injected at all",
stranded == 1 and injected == [injector.virtual_id_for(23, 590)],
f"dispatched={stranded} injected={injected} ids={narrow_ids}",
)
check(
"the one restorable summary still lands directly above its anchor",
narrow_ids.index(injector.virtual_id_for(23, 590)) == narrow_ids.index(590) - 1,
f"ids={narrow_ids}",
)
# Once paging widens the window, the previously skipped summary has a
# real anchor and must be restored on the next attempt.
for older in (130, 120):
owner_obj = _TLObj("TL_message")
owner_obj.id = older
narrow.messages.add(_FakeMessageObject(0, owner_obj))
after_paging = injector.restore_saved_summaries(
narrow,
0,
-4242,
0,
[
summary_db_mod.StoredSummary(21, -4242, 0, 120, "ancient", "", 5, 0),
summary_db_mod.StoredSummary(22, -4242, 0, 130, "old", "", 5, 0),
summary_db_mod.StoredSummary(23, -4242, 0, 590, "loaded", "", 5, 0),
],
)
paged_ids = [m.messageOwner.id for m in narrow.messages]
check(
"summaries are restored once paging loads their anchor",
after_paging == 2
and paged_ids.index(injector.virtual_id_for(21, 120)) == paged_ids.index(120) - 1
and paged_ids.index(injector.virtual_id_for(22, 130)) == paged_ids.index(130) - 1,
f"dispatched={after_paging} ids={paged_ids}",
)
check(
"re-restoring does not duplicate the summary already on screen",
paged_ids.count(injector.virtual_id_for(23, 590)) == 1,
f"ids={paged_ids}",
)
# Bug: the card appeared after the wrong message when many messages
# were included. The insert index was computed on the calling thread and
# applied later on the UI thread; anything arriving in between shifted
# the list and invalidated it. A fake dispatcher that mutates the list
# between computing and applying reproduces that window.
racing = _FakeChatActivity([700, 690, 680])
real_on_ui = injector._on_ui
def _racing_on_ui(fn):
owner_obj = _TLObj("TL_message")
owner_obj.id = 701
racing.messages.add(0, _FakeMessageObject(0, owner_obj))
fn()
injector._on_ui = _racing_on_ui
try:
racing_injection = injector.SummaryInjection(
racing, 0, -4242, 0, 690, injector.virtual_id_for(31, 690)
)
racing_injection.insert("summary body", "", streaming=False)
finally:
injector._on_ui = real_on_ui
racing_ids = [m.messageOwner.id for m in racing.messages]
card = injector.virtual_id_for(31, 690)
check(
"the card lands above its anchor even if the list shifts first",
racing_ids.index(card) == racing_ids.index(690) - 1,
f"ids={racing_ids}",
)
# find_index_for_anchor must report an absent anchor rather than
# guessing index 0 (chat bottom) or a nearest-by-id slot.
absent = _FakeChatActivity([900, 800])
check(
"an unloaded anchor is reported as absent",
injector.find_index_for_anchor(absent.messages, 123) == -1,
f"index={injector.find_index_for_anchor(absent.messages, 123)}",
)
check(
"id-ordered placement is still available when explicitly requested",
injector.find_index_for_anchor(absent.messages, 123, require_anchor=False) == 2,
f"index={injector.find_index_for_anchor(absent.messages, 123, require_anchor=False)}",
)
# Every real chat interleaves date separators, which are local
# MessageObjects carrying messageOwner.id == 0 (ChatActivity builds them
# with `iput v12, ...Message->id` where v12 is 0, and gives them ids from
# a separate dateObjectsStableIds space). They must never be treated as
# the insertion point or counted as a loaded anchor.
dated = _FakeChatActivity([700, 690])
separator_owner = _TLObj("TL_messageService")
separator_owner.id = 0
dated.messages.add(1, _FakeMessageObject(0, separator_owner))
dated_index = injector.find_index_for_anchor(dated.messages, 690)
dated_ids = [m.messageOwner.id for m in dated.messages]
check(
"a date separator is not mistaken for the anchor",
dated_ids[dated_index] == 690,
f"index={dated_index} ids={dated_ids}",
)
# A separator's id 0 must not make "anchor 0" look loaded, and must not
# be offered as a position; anchor ids are always positive.
check(
"a date separator does not satisfy an anchor-loaded check",
injector.find_index_for_anchor(dated.messages, 0) == -1,
f"index={injector.find_index_for_anchor(dated.messages, 0)}",
)
# A summary anchored on a real message must still restore correctly when
# separators sit between the loaded messages.
sep_restore = _FakeChatActivity([800, 790])
sep_owner = _TLObj("TL_messageService")
sep_owner.id = 0
sep_restore.messages.add(1, _FakeMessageObject(0, sep_owner))
sep_n = injector.restore_saved_summaries(
sep_restore,
0,
-4242,
0,
[summary_db_mod.StoredSummary(41, -4242, 0, 790, "text", "", 5, 0)],
)
sep_ids = [m.messageOwner.id for m in sep_restore.messages]
sep_card = injector.virtual_id_for(41, 790)
check(
"summaries restore correctly around date separators",
sep_n == 1 and sep_ids.index(sep_card) == sep_ids.index(790) - 1,
f"dispatched={sep_n} ids={sep_ids}",
)
# A live status card is the only feedback that pressing Summarize did
# anything, so it must appear even when the provisional anchor cannot be
# placed: a large offset reaches past the loaded window, and an
# unresolved anchor arrives as 0. Requiring a loaded anchor here left the
# user staring at an unchanged chat.
for label, anchor in (("unresolved anchor 0", 0), ("anchor past the window", 4242)):
live = _FakeChatActivity([700, 690, 680])
live_injection = injector.SummaryInjection(
live, 0, -4242, 0, anchor, injector.virtual_id_for(0, anchor or 1)
)
shown = live_injection.insert(status="Fetching messages…", streaming=True)
live_ids = [m.messageOwner.id for m in live.messages]
check(
f"the status card still appears with an {label}",
shown and len([i for i in live_ids if i < 0]) == 1,
f"inserted={shown} ids={live_ids}",
)
check(
"sys.modules gained no entry for bundled plugin module names",
not ({"main", "config", "services", "providers", "hooks", "ui.summarize_dialog"} & new_modules),
f"leaked: {({'main', 'config', 'services', 'providers', 'hooks', 'ui.summarize_dialog'} & new_modules)}",
)
if _FAILURES:
print(f"\n{len(_FAILURES)} check(s) failed: {', '.join(_FAILURES)}", file=sys.stderr)
return 1
print("\nall checks passed")
return 0
if __name__ == "__main__":
sys.exit(main())