generated from exteraGramPlugins/pluginBase
Publish plugin / publish (push) Successful in 4s
Replaces the pluginBase sample with the plugin: - remove FLAG_SECURE (FlagSecureReason + Window.setFlags) for screenshots - unlock forward/copy/save UI in protected chats and re-send protected messages as copies from the user's account (media re-uploaded, albums regrouped, bot-only parts dropped) - red lock before protected chats' names in the chat list, header and info - settings toggles for all three - verify_bundle.py rewritten for the plugin - bundle.py enables the git hooks on first run; README documents them Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
504 lines
16 KiB
Python
Executable File
504 lines
16 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 traceback
|
|
import types
|
|
from dataclasses import dataclass
|
|
from enum import Enum, auto
|
|
from pathlib import Path
|
|
|
|
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)
|
|
|
|
|
|
class HookStrategy(Enum):
|
|
DEFAULT = auto()
|
|
MODIFY = auto()
|
|
CANCEL = auto()
|
|
MODIFY_FINAL = auto()
|
|
|
|
|
|
@dataclass
|
|
class HookResult:
|
|
strategy: HookStrategy = HookStrategy.DEFAULT
|
|
params: object = None
|
|
|
|
|
|
LOG_LINES: list[str] = []
|
|
HOOKED: list[tuple[str, str, object]] = []
|
|
|
|
# Classes the plugin is expected to hook, mapped to the hooked method names.
|
|
EXPECTED_HOOKS = {
|
|
("org.telegram.messenger.FlagSecureReason", "isSecuredNow"),
|
|
("android.view.Window", "setFlags"),
|
|
("org.telegram.messenger.MessagesController", "isChatNoForwards"),
|
|
("org.telegram.messenger.MessagesController", "isUserNoForwards"),
|
|
("org.telegram.messenger.MessagesController", "isPeerNoForwards"),
|
|
("org.telegram.ui.ChatActivity", "hasSelectedNoforwardsMessage"),
|
|
("org.telegram.messenger.MessageObject", "canForwardMessage"),
|
|
("org.telegram.messenger.SendMessagesHelper", "sendMessage"),
|
|
("org.telegram.ui.Cells.DialogCell", "buildLayout"),
|
|
("org.telegram.ui.Components.ChatAvatarContainer", "setTitle"),
|
|
("org.telegram.messenger.AndroidUtilities", "escape"),
|
|
("org.telegram.ui.ProfileActivity", "updateProfileData"),
|
|
}
|
|
|
|
|
|
class MethodHook:
|
|
def before_hooked_method(self, param):
|
|
pass
|
|
|
|
def after_hooked_method(self, param):
|
|
pass
|
|
|
|
|
|
class MethodReplacement:
|
|
pass
|
|
|
|
|
|
class FakeJavaClass:
|
|
def __init__(self, name):
|
|
self.name = name
|
|
|
|
def getName(self):
|
|
return self.name
|
|
|
|
|
|
class BasePlugin:
|
|
def __init__(self):
|
|
self._settings = {}
|
|
|
|
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_on_send_message_hook(self, priority=0):
|
|
pass
|
|
|
|
def hook_all_methods(self, hook_class, method_name, xposed_hook=None, priority=None, **kwargs):
|
|
HOOKED.append((hook_class.getName(), method_name, xposed_hook))
|
|
return [object()]
|
|
|
|
def log(self, message):
|
|
LOG_LINES.append(str(message))
|
|
|
|
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))
|
|
|
|
|
|
def fake_find_class(name):
|
|
return FakeJavaClass(name)
|
|
|
|
|
|
class _SettingsRow:
|
|
def __init__(self, **kwargs):
|
|
self.__dict__.update(kwargs)
|
|
|
|
|
|
class FakeArrayList(list):
|
|
def isEmpty(self):
|
|
return not self
|
|
|
|
def add(self, item):
|
|
self.append(item)
|
|
|
|
def size(self):
|
|
return len(self)
|
|
|
|
def get(self, i):
|
|
return self[i]
|
|
|
|
|
|
class FakeMessageObject:
|
|
def __init__(self, protected=False, noforwards=False):
|
|
self.protected = protected
|
|
self.messageOwner = types.SimpleNamespace(noforwards=noforwards)
|
|
|
|
|
|
class FakeParam:
|
|
def __init__(self, args=(), this=None, result=None):
|
|
self.args = None if args is None else list(args)
|
|
self.thisObject = this
|
|
self._result = result
|
|
self.result_set = False
|
|
|
|
def getResult(self):
|
|
return self._result
|
|
|
|
def setResult(self, value):
|
|
self._result = value
|
|
self.result_set = True
|
|
|
|
|
|
def _module(name, **attrs):
|
|
mod = types.ModuleType(name)
|
|
mod.__dict__.update(attrs)
|
|
sys.modules[name] = mod
|
|
return mod
|
|
|
|
|
|
def install_fakes() -> None:
|
|
_module(
|
|
"base_plugin",
|
|
BasePlugin=BasePlugin,
|
|
HookResult=HookResult,
|
|
HookStrategy=HookStrategy,
|
|
MethodHook=MethodHook,
|
|
MethodReplacement=MethodReplacement,
|
|
)
|
|
_module("android_utils", log=fake_log, run_on_ui_thread=lambda fn, delay=0: fn())
|
|
_module(
|
|
"hook_utils",
|
|
find_class=fake_find_class,
|
|
set_private_field=lambda obj, name, value: setattr(obj, name, value) or True,
|
|
get_private_field=lambda obj, name: getattr(obj, name, None),
|
|
)
|
|
_module("client_utils", get_last_fragment=lambda: None)
|
|
ui_mod = _module("ui", __path__=[])
|
|
ui_mod.settings = _module(
|
|
"ui.settings",
|
|
**{n: type(n, (_SettingsRow,), {}) for n in ("Header", "Input", "Text", "Switch", "Divider")},
|
|
)
|
|
ui_mod.bulletin = _module("ui.bulletin", BulletinHelper=types.SimpleNamespace(
|
|
show_info=fake_log, show_error=fake_log, show_success=fake_log,
|
|
))
|
|
java_mod = _module("java", __path__=[])
|
|
java_mod.util = _module("java.util", ArrayList=FakeArrayList)
|
|
org = _module("org", __path__=[])
|
|
org.telegram = _module("org.telegram", __path__=[])
|
|
org.telegram.messenger = _module("org.telegram.messenger", MessageObject=FakeMessageObject)
|
|
|
|
|
|
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:
|
|
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 verify_hooks(plugin, hooks) -> None:
|
|
flag_secure = 0x2000
|
|
strip = hooks[("android.view.Window", "setFlags")]
|
|
for enabled in (True, False):
|
|
plugin.set_setting("allow_screenshots", enabled)
|
|
param = FakeParam(args=[flag_secure | 0x80, flag_secure | 0x80])
|
|
strip.before_hooked_method(param)
|
|
want = 0x80 if enabled else flag_secure | 0x80
|
|
check(
|
|
f"Window.setFlags strips FLAG_SECURE only when enabled (enabled={enabled})",
|
|
param.args[0] == want,
|
|
f"flags={param.args[0]:#x}",
|
|
)
|
|
|
|
plugin.set_setting("allow_screenshots", True)
|
|
param = FakeParam(result=True)
|
|
hooks[("org.telegram.messenger.FlagSecureReason", "isSecuredNow")].after_hooked_method(param)
|
|
check("FlagSecureReason.isSecuredNow forced false", param.getResult() is False)
|
|
|
|
no_forwards = hooks[("org.telegram.messenger.MessagesController", "isChatNoForwards")]
|
|
for enabled in (True, False):
|
|
plugin.set_setting("allow_forwarding", enabled)
|
|
param = FakeParam(result=True)
|
|
no_forwards.after_hooked_method(param)
|
|
check(
|
|
f"isChatNoForwards forced false only when enabled (enabled={enabled})",
|
|
param.getResult() is (not enabled),
|
|
f"got {param.getResult()!r}",
|
|
)
|
|
|
|
plugin.set_setting("allow_forwarding", True)
|
|
can_forward = hooks[("org.telegram.messenger.MessageObject", "canForwardMessage")]
|
|
message = FakeMessageObject(noforwards=True)
|
|
seen = []
|
|
# The re-invoked original answers "forwardable" only if noforwards is unset.
|
|
method = types.SimpleNamespace(
|
|
invoke=lambda obj: seen.append(obj.messageOwner.noforwards) or not obj.messageOwner.noforwards
|
|
)
|
|
param = FakeParam(this=message, result=False)
|
|
param.method = method
|
|
can_forward.after_hooked_method(param)
|
|
check(
|
|
"canForwardMessage re-evaluates without noforwards and restores the flag",
|
|
param.getResult() is True and seen == [False] and message.messageOwner.noforwards is True,
|
|
f"result={param.getResult()!r} seen={seen} flag={message.messageOwner.noforwards!r}",
|
|
)
|
|
param = FakeParam(this=FakeMessageObject(noforwards=False), result=False)
|
|
param.method = method
|
|
can_forward.after_hooked_method(param)
|
|
check("canForwardMessage keeps a refusal unrelated to noforwards", param.getResult() is False)
|
|
|
|
escape = hooks[("org.telegram.messenger.AndroidUtilities", "escape")]
|
|
badge = type(escape).after_hooked_method.__globals__
|
|
badge["_building_title"] = "Chat"
|
|
results = []
|
|
for arg in ("Chat", "Other"):
|
|
param = FakeParam(args=[arg], result=arg)
|
|
escape.after_hooked_method(param)
|
|
results.append(param.getResult())
|
|
badge["_building_title"] = None
|
|
param = FakeParam(args=["Chat"], result="Chat")
|
|
escape.after_hooked_method(param)
|
|
results.append(param.getResult())
|
|
check(
|
|
"escape() prefixes only the title being laid out",
|
|
results == ["\u2003 Chat", "Other", "Chat"],
|
|
f"{results}",
|
|
)
|
|
|
|
intercept = hooks[("org.telegram.messenger.SendMessagesHelper", "sendMessage")]
|
|
copier = type(intercept).before_hooked_method.__globals__["copier"]
|
|
|
|
cases = [
|
|
([], []),
|
|
([0, 0, 0], []),
|
|
([7], []),
|
|
([7, 7, 7], [(0, 3)]),
|
|
([0, 7, 7, 0, 9, 9, 9], [(1, 3), (4, 7)]),
|
|
([7, 7, 9, 9], [(0, 2), (2, 4)]),
|
|
([7, 0, 7], []),
|
|
([5] * 12, [(0, 10), (10, 12)]),
|
|
([5] * 11, [(0, 10)]),
|
|
]
|
|
bad = [(k, copier.album_runs(k), want) for k, want in cases if copier.album_runs(k) != want]
|
|
check("album_runs groups consecutive album items, max 10 per group", not bad, f"{bad}")
|
|
|
|
# Stub out the Java-facing parts of the bundled copier module.
|
|
copied = []
|
|
copier.is_protected = lambda m: m.protected
|
|
copier.copy_messages = lambda **kwargs: copied.append(kwargs)
|
|
|
|
free, locked = FakeMessageObject(), FakeMessageObject(protected=True)
|
|
param = FakeParam(args=[FakeArrayList([free, locked]), -100, False, True, True, 0, 0])
|
|
intercept.before_hooked_method(param)
|
|
check(
|
|
"mixed forward: protected message copied, the rest forwarded",
|
|
len(copied) == 1
|
|
and copied[0]["messages"] == [locked]
|
|
and copied[0]["peer"] == -100
|
|
and copied[0]["hide_caption"] is True
|
|
and list(param.args[0]) == [free]
|
|
and not param.result_set,
|
|
f"copied={copied} args0={list(param.args[0])} result_set={param.result_set}",
|
|
)
|
|
|
|
copied.clear()
|
|
param = FakeParam(args=[FakeArrayList([locked]), 5, False, False, True, 0, 0])
|
|
intercept.before_hooked_method(param)
|
|
check(
|
|
"all-protected forward: nothing left for the original sendMessage to forward",
|
|
len(copied) == 1 and list(param.args[0]) == [],
|
|
f"copied={copied} args0={list(param.args[0])}",
|
|
)
|
|
|
|
copied.clear()
|
|
|
|
def broken_check(m):
|
|
raise RuntimeError("boom")
|
|
|
|
copier.is_protected = broken_check
|
|
param = FakeParam(args=[FakeArrayList([free]), 5, False, False, True, 0, 0])
|
|
intercept.before_hooked_method(param)
|
|
check(
|
|
"failed protection check copies instead of forwarding",
|
|
len(copied) == 1 and list(param.args[0]) == [],
|
|
f"copied={copied} args0={list(param.args[0])}",
|
|
)
|
|
copier.is_protected = lambda m: m.protected
|
|
|
|
copied.clear()
|
|
param = FakeParam(args=[FakeArrayList([free]), 5, False, False, True, 0, 0])
|
|
intercept.before_hooked_method(param)
|
|
check(
|
|
"unprotected forward passes through untouched",
|
|
not copied and not param.result_set and list(param.args[0]) == [free],
|
|
)
|
|
|
|
plugin.set_setting("allow_forwarding", False)
|
|
param = FakeParam(args=[FakeArrayList([locked]), 5, False, False, True, 0, 0])
|
|
intercept.before_hooked_method(param)
|
|
check("forward interception off when setting disabled", not copied and not param.result_set)
|
|
|
|
plugin.set_setting("allow_forwarding", True)
|
|
param = FakeParam(args=[object()])
|
|
intercept.before_hooked_method(param)
|
|
check("sendMessage(SendMessageParams) overload ignored", not copied and not param.result_set)
|
|
|
|
|
|
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")
|
|
|
|
meta = json.loads(PLUGIN_JSON.read_text(encoding="utf-8"))
|
|
modules_before = set(sys.modules)
|
|
ns = {"__name__": meta["id"]}
|
|
exec(compile(source, str(bundle_path), "exec"), ns)
|
|
new_modules = set(sys.modules) - modules_before
|
|
|
|
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()
|
|
LOG_LINES.clear()
|
|
plugin.on_plugin_load()
|
|
check(
|
|
"on_plugin_load emits load log line",
|
|
any("unlocked_restrictions loaded" in line for line in LOG_LINES),
|
|
f"log lines: {LOG_LINES}",
|
|
)
|
|
hooked = {(cls, method) for cls, method, _ in HOOKED}
|
|
check(
|
|
"on_plugin_load hooks every target method",
|
|
EXPECTED_HOOKS <= hooked,
|
|
f"missing: {EXPECTED_HOOKS - hooked}",
|
|
)
|
|
|
|
switches = [row for row in plugin.create_settings() if type(row).__name__ == "Switch"]
|
|
check(
|
|
"settings expose all toggles, enabled by default",
|
|
{(row.key, row.default) for row in switches}
|
|
== {
|
|
("allow_screenshots", True),
|
|
("allow_forwarding", True),
|
|
("protected_lock_badge", True),
|
|
},
|
|
f"got {[(row.key, row.default) for row in switches]}",
|
|
)
|
|
|
|
verify_hooks(plugin, {(cls, method): hook for cls, method, hook in HOOKED})
|
|
|
|
# Raise inside a bundled module to prove linecache registration: the
|
|
# traceback must name the original source file and show the failing line.
|
|
screenshots = ns.get("screenshots")
|
|
traceback_text = ""
|
|
if screenshots is not None:
|
|
try:
|
|
screenshots.StripFlagSecureHook(lambda: True).before_hooked_method(FakeParam(args=None))
|
|
except TypeError:
|
|
traceback_text = traceback.format_exc()
|
|
check(
|
|
"hook traceback names src/screenshots.py",
|
|
"src/screenshots.py" in traceback_text,
|
|
f"traceback:\n{traceback_text}",
|
|
)
|
|
check(
|
|
"traceback includes the raising source line text",
|
|
"if len(args) != 2" in traceback_text,
|
|
f"traceback:\n{traceback_text}",
|
|
)
|
|
|
|
bundled = {"main", "hooking", "screenshots", "forwarding", "copier", "badge", "protection"}
|
|
check(
|
|
"sys.modules gained no entry for bundled module names",
|
|
not (bundled & new_modules),
|
|
f"leaked: {bundled & 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())
|