generated from exteraGramPlugins/pluginBase
Three defects in the restore/injection path. Scrolling stuttered in any chat that had been summarized. The restore gate keyed on id(chat_activity), but every Xposed callback hands Python a fresh Chaquopy wrapper around the same Java object, so the key never matched and each scroll frame opened SQLite and rescanned the message list. updateMessagesVisiblePart runs per frame, so that ran continuously. BaseFragment.classGuid is assigned once per fragment instance, so it identifies an open chat across calls; measured 1 query per 300 frames instead of 300. Saved rows are cached per scope, invalidated on save. The card could attach to the wrong message when many messages were included. insert() computed the index on the calling thread and applied it later on the UI thread; a large range takes long enough for paging or an incoming message to shift the list in between, so a stale index was used. The index is now computed on the UI thread immediately before the mutation that consumes it. Every saved summary appeared stacked together in a row. find_index_for_anchor fell back to nearest-by-id whenever the anchor was not loaded, and a chat opens with a short window, so summaries anchored far up all collapsed to adjacent slots at the bottom. Absence is now reported as -1 and those cards are skipped until paging brings their anchor in; the id-order fallback stays opt-in for reanchor, which must place a card it has already removed. Verified against the 6 real saved summaries for one chat: previously all 6 were injected consecutively on open, now none are, and each appears in its correct place once its range is scrolled in. Also makes restore linear in list length rather than rescanning per summary.
1945 lines
75 KiB
Python
Executable File
1945 lines
75 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)",
|
|
)
|
|
|
|
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)}",
|
|
)
|
|
|
|
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())
|