Message Logger: show logged edits and deletions from telegram-panel [no bump]
Publish plugin / publish (push) Successful in 4s

Keeps deleted messages (75% opacity, red icon-pack trash icon), restores
logged messages deleted while away, reports deletions, and shows edit
history as a mock chat (Edit history in the message menu, Open topic).
Message links and history open as any signed-in account with access.
Per-master API tokens; chat state synced per checked range with an hour's
validity, cached in SQLite with a configurable lifetime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lyuksovannyyandClaude Opus 5.5 committed 2026-10-08 23:55:23 +02:00
1 parent 80f1d5e55c
commit 9b2f26a845
26 files changed
+2928 -112

No files matched your search

+129 -52
View File
@@ -42,6 +42,27 @@ class HookStrategy(Enum):
class HookResult:
strategy: HookStrategy = HookStrategy.DEFAULT
params: object = None
update: object = None
class MenuItemType(Enum):
MESSAGE_CONTEXT_MENU = auto()
@dataclass
class MenuItemData:
menu_type: MenuItemType
text: str
on_click: object
item_id: object = None
icon: object = None
subtext: object = None
condition: object = None
priority: int = 0
class MethodHook:
pass
LOG_LINES: list[str] = []
@@ -84,6 +105,9 @@ def install_fakes() -> None:
base_plugin_mod.BasePlugin = BasePlugin
base_plugin_mod.HookResult = HookResult
base_plugin_mod.HookStrategy = HookStrategy
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")
@@ -94,18 +118,20 @@ def install_fakes() -> None:
ui_mod.__path__ = []
sys.modules["ui"] = ui_mod
import tempfile
file_utils_mod = types.ModuleType("file_utils")
_files = tempfile.mkdtemp(prefix="ml-verify-")
file_utils_mod.get_files_dir = lambda: _files
sys.modules["file_utils"] = file_utils_mod
ui_settings_mod = types.ModuleType("ui.settings")
for cls_name in ("Header", "Input", "Text", "Switch"):
for cls_name in ("Header", "Input", "Text", "Switch", "Selector"):
setattr(ui_settings_mod, cls_name, type(cls_name, (_SettingsRow,), {}))
sys.modules["ui.settings"] = ui_settings_mod
ui_mod.settings = ui_settings_mod
class SimpleParams:
def __init__(self, message):
self.message = message
KEY_TO_DUNDER = {
"id": "__id__",
"name": "__name__",
@@ -147,7 +173,7 @@ def main(argv=None) -> int:
source = bundle_path.read_text(encoding="utf-8")
modules_before = set(sys.modules)
ns = {"__name__": "hello_bundle"}
ns = {"__name__": "message_logger"}
exec(compile(source, str(bundle_path), "exec"), ns)
new_modules = set(sys.modules) - modules_before
@@ -194,63 +220,114 @@ def main(argv=None) -> int:
)
plugin = plugin_cls()
LOG_LINES.clear()
plugin.on_plugin_load()
keys = {getattr(row, "key", None) for row in plugin.create_settings()}
check(
"on_plugin_load emits load log line",
any("hello_bundle loaded" in line for line in LOG_LINES),
f"log lines: {LOG_LINES}",
"settings expose server URL and API token",
{"server_url", "api_token"} <= keys,
f"keys: {keys}",
)
params = SimpleParams(".hello alice smith")
result = plugin.on_send_message_hook(account=0, params=params)
load = ns.get("_bundle_load")
check("bundle exposes its module loader", load is not None)
if load is not None:
header = load("header")
text = "✉️ From: Bob\n ⤷ 💬 Message\n🗑 Deleted at x\n\nHi 😀 there"
end = header.utf16_len(text)
body, entities = header.strip(text, [(0, 4, "from"), (end - 5, 5, "bold")])
check("header stripped from logged copy", body == "Hi 😀 there", repr(body))
check(
"'.hello alice smith' renders greeting",
params.message == "Hello, Alice Smith!",
f"got {params.message!r}",
"entities shifted past the header (UTF-16)",
entities == [(6, 5, "bold")],
repr(entities),
)
check(
"on_send_message_hook returns MODIFY strategy",
result.strategy == HookStrategy.MODIFY,
f"got {result.strategy}",
"text without a header is kept",
header.strip("plain", [(0, 5, "x")]) == ("plain", [(0, 5, "x")]),
)
plugin.set_setting("template", "{nope}")
params2 = SimpleParams(".hello someone")
plugin.on_send_message_hook(account=0, params=params2)
peers = load("peers")
check(
"bad template produces 'Bad template:' message",
params2.message.startswith("Bad template:"),
f"got {params2.message!r}",
"marked channel id -> channel id",
peers.channel_id_from_marked(-1000000001234) == 1234,
)
chat = load("hooks.chat")
check(
"Telethon sender ids -> Android peer ids",
chat._android_peer_id(-1000000001234) == -1234 and chat._android_peer_id(42) == 42,
)
# main.py imports `render`/`TemplateError` by name, so they live directly
# in the executed namespace — call render() directly to inspect the raised
# traceback (proves linecache registration for bundled, non-file modules).
render = ns.get("render")
template_error = ns.get("TemplateError")
traceback_text = ""
if render is not None and template_error is not None:
try:
render("{nope}", "x")
except template_error:
traceback_text = traceback.format_exc()
check(
"render() traceback names src/greeting/template.py",
"src/greeting/template.py" in traceback_text,
f"traceback:\n{traceback_text}",
)
check(
"traceback includes the raising source line text",
"unsupported placeholder" in traceback_text,
f"traceback:\n{traceback_text}",
)
links = load("hooks.links")
check(
"private message links parse (with and without topic)",
links.parse("https://t.me/c/123/45") == (-123, 45)
and links.parse("t.me/c/123/7/45") == (-123, 45)
and links.parse("tg://privatepost?channel=123&post=45") == (-123, 45),
)
check("non-message links are left alone", links.parse("https://t.me/c/123") is None)
check(
"sys.modules gained no entry for bundled module names",
not ({"greeting", "textutil", "main"} & new_modules),
f"leaked: {({'greeting', 'textutil', 'main'} & new_modules)}",
)
plan, hour = chat._plan, 3600
check(
"sync plan: fresh when unchecked or older than an hour",
plan(None, 50, 2**31 - 1, 10_000) == (50, 2**31 - 1, True)
and plan((50, 2**31 - 1, 10_000 - hour), 50, 2**31 - 1, 10_000)
== (50, 2**31 - 1, True),
)
check(
"sync plan: nothing while the loaded range is checked",
plan((40, 2**31 - 1, 9_000), 50, 2**31 - 1, 10_000) is None,
)
check(
"sync plan: only the older history loaded since",
plan((40, 2**31 - 1, 9_000), 10, 2**31 - 1, 10_000) == (10, 39, False)
and plan((40, 100, 9_000), 50, 120, 10_000) == (101, 120, False),
)
store = load("store")
store.put_server_states(
0, -5, [{"message_id": i, "edit_count": 1} for i in range(1, 1201)]
)
(cached,) = store._conn().execute(
"SELECT count(*) FROM states WHERE account=0 AND dialog_id=-5"
).fetchone()
check("cache has no per-chat cap", cached == 1200, f"{cached}")
check(
"a full check replaces the chat's cached marks",
store.replace_window(0, -5, 1, 10, [{"message_id": 5, "edit_count": 2}], False, full=True)
is not None
and store._conn().execute(
"SELECT count(*) FROM states WHERE account=0 AND dialog_id=-5"
).fetchone()[0]
== 1,
)
store._conn().execute("UPDATE states SET at = at - 40 * 86400")
store._conn().commit()
store.put_server_states(0, -6, [{"message_id": 1, "edit_count": 1}])
removed = store.purge_old(30, force=True)
left = store._conn().execute("SELECT dialog_id FROM states").fetchall()
check(
"age purge drops answers older than the lifetime only",
removed == 1 and left == [(-6,)],
f"removed={removed} left={left}",
)
check("age purge runs once a day", store.purge_old(1) == 0)
for name in ("cached_deleted", "coverage", "set_coverage", "expire_coverage", "replace_window"):
check(f"store.{name} exists", callable(getattr(store, name, None)))
api = load("api")
client = api.Api(BasePlugin())
check("API unconfigured without URL and token", not client.configured)
client.plugin.set_setting("server_url", "https://panel.example/ ")
client.plugin.set_setting("api_token", "tlp_x, tlp_y\ntlp_z")
check("several tokens, any separator", client.tokens() == ["tlp_x", "tlp_y", "tlp_z"])
check(
"API base URL",
client._base() == "https://panel.example/api/v1/client/message-logger",
client._base(),
)
leaked = {"main", "header", "api", "store", "peers", "hooks", "views"} & new_modules
check("sys.modules gained no entry for bundled module names", not leaked, f"leaked: {leaked}")
if _FAILURES:
print(f"\n{len(_FAILURES)} check(s) failed: {', '.join(_FAILURES)}", file=sys.stderr)