generated from exteraGramPlugins/pluginBase
Message Logger: show logged edits and deletions from telegram-panel [no bump]
Publish plugin / publish (push) Successful in 4s
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:
1 parent
80f1d5e55c
commit
9b2f26a845
26 files changed
+2928
-112
No files matched your search
+129
-52
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user