diff --git a/README.md b/README.md index 74102a6..2cf0394 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,11 @@ the device, so a chat opened again, even offline, shows them at once. - **Deleted messages stay.** Messages others delete remain in the chat at 75% opacity with a red trash icon (from the active icon pack) in the time row. Messages deleted while the app was closed are confirmed with Telegram and restored from the logger copy. +- **Keep your own deletions.** The delete dialog gets a *Keep message for myself* checkbox: ticked, + the deletion goes to Telegram but the message stays on this device, marked deleted. + Unticked (the default), it is deleted as usual and never restored from the logger here. + Deleting a message that is already deleted shows a plain dialog that removes it from view + on this device only. - **Edit history.** *Edit history* in the message menu (right above Forward, for edited messages) opens every version as a mock chat, also for the logger groups' own posts, with an *Open topic* button for the history topic in the activity group. @@ -29,6 +34,9 @@ the device, so a chat opened again, even offline, shows them at once. - **Links into chats you're not in.** Message links into a chat the current account isn't in (e.g. a log post's "Message" link) open that chat as a signed-in account that is in it; going back returns to your account. +- **Reset marks.** *Reset deleted/edited marks* in the chat's menu (Plugins) forgets that + chat's cached marks and checks it with the server again; the same row in the plugin + settings does it for every chat. Messages you removed from view stay hidden. - **Cache lifetime.** Cached answers are kept for 1, 7, 30, 90 or 365 days, or forever (30 by default). diff --git a/plugin.json b/plugin.json index 9df290f..2173ba2 100644 --- a/plugin.json +++ b/plugin.json @@ -3,7 +3,7 @@ "name": "Message Logger", "description": "Shows deleted and edited messages and their edit history from your telegram-panel message logger.", "author": "@lyuksovannyy", - "version": "0.1.1", + "version": "0.1.2", "icon": "exteraGramPluginIcons/4", "app_version": ">=12.5.1", "sdk_version": ">=1.4.4.3", diff --git a/src/hooks/chat.py b/src/hooks/chat.py index b80f11d..073bf64 100644 --- a/src/hooks/chat.py +++ b/src/hooks/chat.py @@ -115,6 +115,13 @@ def _loaded(fragment, message_id): return False +def jint_index(index): + """ArrayList.remove(int index), not remove(Object).""" + from java import jint + + return jint(index) + + def _android_peer_id(marked): """Telethon sender id -> Android peer id (channels are -id).""" marked = int(marked) @@ -141,10 +148,13 @@ def _rich_text(node): value = getattr(node, "text", None) if isinstance(value, str): return value + if value is not None: # a wrapper (url, bold, spoiler...): its inner text + return _rich_text(value) + # textConcat; every RichText carries `texts`, empty unless it concatenates texts = getattr(node, "texts", None) if texts is not None and hasattr(texts, "size"): return "".join(_rich_text(t) for t in java_list(texts)) - return _rich_text(value) + return "" def _rich_plain_text(rich): @@ -347,6 +357,8 @@ class ChatSync: def _restore(self, fragment, account, dialog_id, chat_id, me, states): for state in states: message_id = int(state["message_id"]) + if store.is_hidden(account, dialog_id, message_id): + continue # the user removed it from view key = (account, dialog_id, message_id) if key in self._restoring: continue @@ -422,7 +434,7 @@ class ChatSync: if strip: _strip_rich_header(rich) plain = _rich_plain_text(rich) - if plain is not None: + if plain: # never a blank bubble: else it stays rich # Text only: a normal bubble, as the original was. msg.message = plain msg.entities = ArrayList() @@ -495,7 +507,72 @@ class ChatSync: if fragment is not None: on_ui(lambda: self.schedule(fragment), delay_ms) - def refresh_dialog(self, account, dialog_id, ids): + def _open_chat(self, account, dialog_id): + """The ChatActivity showing the dialog: the one last synced, else the + screen on top when it is that chat.""" fragment = self._fragments.get((account, dialog_id)) + if fragment is not None: + return fragment + from client_utils import get_last_fragment + + top = get_last_fragment() + try: + if ( + top is not None + and int(top.getCurrentAccount()) == account + and int(top.getDialogId()) == dialog_id + ): + return top + except Exception: + pass + return None + + def refresh_dialog(self, account, dialog_id, ids): + fragment = self._open_chat(account, dialog_id) if fragment is not None: on_ui(lambda: self.refresh(fragment, set(int(i) for i in ids))) + + def reset(self, account=None, dialog_id=None): + """Forgets the cache of a chat (all chats when None) and redraws and + re-checks the open one(s). Returns the number of marks forgotten.""" + removed = store.reset(account, dialog_id) + for (acc, dia), fragment in list(self._fragments.items()): + if dialog_id is not None and (acc, dia) != (account, dialog_id): + continue + + def redraw(fragment=fragment): + adapter = field(fragment, "chatAdapter") + if adapter is not None: + for m in java_list(field(fragment, "messages")): + if m is not None: + m.forceUpdate = True + adapter.notifyDataSetChanged() + self.schedule(fragment) + + on_ui(redraw) + return removed + + def drop_from_view(self, account, dialog_id, ids): + """Takes messages out of the open chat's list (UI thread).""" + fragment = self._open_chat(account, dialog_id) + if fragment is None: + return + wanted = set(int(i) for i in ids) + messages = field(fragment, "messages") + if messages is None: + return + removed = 0 + for index in range(messages.size() - 1, -1, -1): + message = messages.get(index) + if message is not None and int(message.getId()) in wanted: + messages.remove(jint_index(index)) + removed += 1 + try: + by_id = field(fragment, "messagesDict")[0] + for i in wanted: + by_id.remove(int(i)) + except Exception: + pass + adapter = field(fragment, "chatAdapter") + if removed and adapter is not None: + adapter.notifyDataSetChanged() diff --git a/src/hooks/delete_dialog.py b/src/hooks/delete_dialog.py new file mode 100644 index 0000000..30f93f7 --- /dev/null +++ b/src/hooks/delete_dialog.py @@ -0,0 +1,316 @@ +"""The app's "Delete message" dialog, extended: + +- a "Keep message for myself" checkbox: ticked, the deletion goes to Telegram but + the message stays on this device, marked deleted (its local removal is + filtered out of MessagesStorage.markMessagesAsDeleted and the + messagesDeleted notification; the server's echo then counts as someone + else's deletion, see hooks/deletes.py). Unticked (default), it is deleted + as usual and remembered as hidden, so it is never restored from the + logger here; +- already deleted (kept) messages get a plain dialog without checkboxes + whose button removes them from view (locally only) and hides them. + +AlertsCreator.createDeleteMessagesAlert(fragment, user, chat, encryptedChat, +chatInfo, mergeDialogId, selectedMessage, selectedMessages[2], group, +topicId, mode, participants, onDelete, hideDim, resourcesProvider) builds and +shows the dialog (exteraGram 12.10.6, probed). +""" + +import time + +from base_plugin import MethodHook + +import diag +import store +from jutil import field, hook_methods, java_ints, java_list, on_ui + +ALERTS = "org.telegram.ui.Components.AlertsCreator" +_KEEP_SECONDS = 60 + + +def _targets(args): + """MessageObjects the dialog is about.""" + selected, arrays, group = args[6], args[7], args[8] + if selected is not None: + if group is not None: + return [m for m in java_list(group.messages) if m is not None] + return [selected] + out = [] + for array in arrays or []: + if array is None: + continue + for index in range(array.size()): + out.append(array.valueAt(index)) + return [m for m in out if m is not None] + + +def _key(message): + return int(message.currentAccount), int(message.getDialogId()), int(message.getId()) + + +def _deleted(message): + return int(message.getId()) > 0 and store.is_deleted(*_key(message)) + + +class DeleteDialog: + def __init__(self, plugin, chats): + self.plugin = plugin + self.chats = chats # hooks/chat.ChatSync + self.handles = [] + self._choice = None # [checked] of the open dialog + self._keeping = {} # (account, id) -> monotonic time to keep until + + # filtering a kept deletion out of the local removal + + def keeping(self, account, ids): + now = time.monotonic() + return [i for i in ids if self._keeping.get((account, i), 0) > now] + + def install(self): + dialog = self + + class BeforeAlert(MethodHook): + def before_hooked_method(self, param): + try: + if dialog.before_alert(param): + param.setResult(None) + except Exception as exc: + diag.error("delete_dialog.before", exc) + + def after_hooked_method(self, param): + try: + dialog.after_alert(param) + except Exception as exc: + diag.error("delete_dialog.after", exc) + + class BeforeStorage(MethodHook): + def before_hooked_method(self, param): + try: + dialog.filter_storage(param) + except Exception as exc: + diag.error("delete_dialog.storage", exc) + + class BeforeNotify(MethodHook): + def before_hooked_method(self, param): + try: + dialog.filter_notification(param) + except Exception as exc: + diag.error("delete_dialog.notify", exc) + + self.handles += hook_methods( + self.plugin, ALERTS, "createDeleteMessagesAlert", BeforeAlert() + ) + self.handles += hook_methods( + self.plugin, + "org.telegram.messenger.MessagesStorage", + "markMessagesAsDeleted", + BeforeStorage(), + ) + self.handles += hook_methods( + self.plugin, + "org.telegram.messenger.NotificationCenter", + "postNotificationName", + BeforeNotify(), + ) + + def uninstall(self): + for handle in self.handles: + try: + self.plugin.unhook_method(handle) + except Exception: + pass + self.handles = [] + + # the dialog + + def before_alert(self, param): + """Already deleted messages only: our own dialog instead (True).""" + self._choice = None + targets = _targets(param.args) + if not targets or not all(_deleted(m) for m in targets): + return False + on_delete, hide_dim = param.args[12], param.args[13] + on_ui(lambda: self._hide_dialog(param.args[0], targets, on_delete, hide_dim)) + return True + + def after_alert(self, param): + """Adds the "Keep message here" checkbox to the app's dialog.""" + targets = _targets(param.args) + if not targets or all(_deleted(m) for m in targets): + return + dialog = param.args[0].getVisibleDialog() + if dialog is None: + return + content = _message_column(dialog.getWindow().getDecorView()) + if content is None: + diag.log("delete dialog: no place for the keep checkbox") + return + from android.widget import LinearLayout + from android_utils import OnClickListener + from org.telegram.messenger import AndroidUtilities + from org.telegram.ui.Cells import CheckBoxCell + + choice = [False] + self._choice = choice + cell = CheckBoxCell(content.getContext(), 1) + cell.setText("Keep message for myself", "", False, False) + + def toggle(_view): + choice[0] = not choice[0] + cell.setChecked(choice[0], True) + + cell.setOnClickListener(OnClickListener(toggle)) + params = LinearLayout.LayoutParams(-1, -2) + params.topMargin = AndroidUtilities.dp(4) + content.addView(cell, params) + + def _hide_dialog(self, fragment, targets, on_delete, hide_dim): + from ui.alert import AlertDialogBuilder + + count = len(targets) + builder = AlertDialogBuilder(fragment.getParentActivity()) + builder.set_title("Remove deleted message" + ("s" if count > 1 else "")) + builder.set_message( + ("These messages were" if count > 1 else "This message was") + + " already deleted. Remove " + + ("them" if count > 1 else "it") + + " from view on this device?" + ) + + def remove(_builder, _which): + self._remove_from_view(targets) + if on_delete is not None: + on_delete.run() + + def dismissed(_builder): + # The app dims the chat for its own dialog and undims it when + # that closes: without this the chat stays touch-blocked. + if hide_dim is not None: + hide_dim.run() + + builder.set_positive_button("Remove", remove) + builder.set_negative_button("Back") + builder.make_button_red(AlertDialogBuilder.BUTTON_POSITIVE) + builder.set_on_dismiss_listener(dismissed) + builder.show() + + def _remove_from_view(self, targets): + """Removes already deleted messages from this device only.""" + from java import jboolean, jint, jlong + from java.lang import Object + from java.util import ArrayList + from org.telegram.messenger import ChatObject, MessagesController, MessagesStorage + from org.telegram.messenger import NotificationCenter + + by_chat = {} + for message in targets: + account, dialog_id, message_id = _key(message) + by_chat.setdefault((account, dialog_id), []).append(message_id) + for (account, dialog_id), ids in by_chat.items(): + store.hide(account, dialog_id, ids) + store.forget_deleted(account, dialog_id, ids) + channel_id = 0 + if dialog_id < 0: + chat = MessagesController.getInstance(account).getChat(-dialog_id) + if chat is not None and ChatObject.isChannel(chat): + channel_id = -dialog_id + items = ArrayList() + for i in ids: + items.add(jint(i)) + MessagesStorage.getInstance(account).markMessagesAsDeleted( + jlong(dialog_id), items, jboolean(True), jboolean(False), jint(0), jint(0) + ) + from java import jarray + + NotificationCenter.getInstance(account).postNotificationName( + NotificationCenter.messagesDeleted, + jarray(Object)([items, jlong(channel_id), jboolean(False)]), + ) + # The notification removes them where the app tracks them; a + # kept message may sit in the open chat's list only. + self.chats.drop_from_view(account, dialog_id, ids) + diag.log(f"removed from view {dialog_id}: {ids}") + + # the deletion itself + + def apply_choice(self, account, dialog_id, ids): + """The dialog's choice for ids being deleted (MessagesController. + deleteMessages, whose overloads call one another: the first call + takes the choice, later ones see the kept ids). Returns kept ids.""" + choice, self._choice = self._choice, None + if choice is None: + return self.keeping(account, ids) + if choice[0]: + until = time.monotonic() + _KEEP_SECONDS + for i in ids: + self._keeping[(account, i)] = until + # Marked deleted here; the server's echo is then kept like any + # deletion by someone else (hooks/deletes.py). + store.mark_deleted(account, dialog_id, ids) + diag.log(f"keeping own deletion {dialog_id}: {ids}") + return list(ids) + store.hide(account, dialog_id, ids) + return [] + + def filter_storage(self, param): + """markMessagesAsDeleted(dialogId, ArrayList ids, ...): drop kept ids.""" + args = param.args + if len(args) < 2 or not hasattr(args[1], "size"): + return + account = int(field(param.thisObject, "currentAccount") or 0) + kept = set(self.keeping(account, java_ints(args[1]))) + if not kept: + return + items = args[1] + for index in range(items.size() - 1, -1, -1): + if int(items.get(index)) in kept: + items.remove(jint_index(index)) + + def filter_notification(self, param): + """messagesDeleted with kept ids: those are dropped from it.""" + from org.telegram.messenger import NotificationCenter + + if int(param.args[0]) != int(NotificationCenter.messagesDeleted): + return + extra = param.args[1] + if extra is None or len(extra) < 1 or not hasattr(extra[0], "size"): + return + account = int(field(param.thisObject, "currentAccount") or 0) + kept = set(self.keeping(account, java_ints(extra[0]))) + if not kept: + return + items = extra[0] + for index in range(items.size() - 1, -1, -1): + if int(items.get(index)) in kept: + items.remove(jint_index(index)) + if items.size() == 0: + param.setResult(None) + + +def jint_index(index): + """ArrayList.remove(int index), not remove(Object).""" + from java import jint + + return jint(index) + + +def _message_column(view): + """The dialog's vertical layout holding its message text (and checkboxes).""" + from android.widget import LinearLayout, TextView + + found = [] + + def walk(node): + if found: + return + if isinstance(node, TextView): + parent = node.getParent() + if isinstance(parent, LinearLayout) and parent.getOrientation() == LinearLayout.VERTICAL: + found.append(parent) + return + if hasattr(node, "getChildCount"): + for index in range(node.getChildCount()): + walk(node.getChildAt(index)) + + walk(view) + return found[0] if found else None diff --git a/src/hooks/deletes.py b/src/hooks/deletes.py index 62f1e3f..dc185be 100644 --- a/src/hooks/deletes.py +++ b/src/hooks/deletes.py @@ -23,10 +23,11 @@ UPDATES = ("TL_updateDeleteMessages", "TL_updateDeleteChannelMessages") class Deletes: - def __init__(self, plugin, api, chats): + def __init__(self, plugin, api, chats, dialog=None): self.plugin = plugin self.api = api self.chats = chats + self.dialog = dialog # hooks/delete_dialog.DeleteDialog self.handles = [] def install(self): @@ -70,6 +71,12 @@ class Deletes: return account = int(field(param.thisObject, "currentAccount") or 0) dialog_id = int(args[3] or 0) + kept = self.dialog.apply_choice(account, dialog_id, ids) if self.dialog else [] + if kept: + self.chats.refresh_dialog(account, dialog_id, kept) + ids = [i for i in ids if i not in kept] + if not ids: + return store.note_own_deletion(account, ids) store.forget_deleted(account, dialog_id, ids) diff --git a/src/main.py b/src/main.py index f897222..99ac1c1 100644 --- a/src/main.py +++ b/src/main.py @@ -1,4 +1,4 @@ -from base_plugin import BasePlugin, HookResult +from base_plugin import BasePlugin, HookResult, MenuItemData, MenuItemType from ui.settings import Header, Input, Selector, Switch, Text import accounts @@ -18,6 +18,7 @@ from config import ( ) from hooks.chat import EDIT_UPDATES, ChatSync from hooks.comments import CommentsButton +from hooks.delete_dialog import DeleteDialog from hooks.deletes import UPDATES, Deletes from hooks.links import Links from hooks.menu import Menu @@ -42,20 +43,60 @@ class MessageLoggerPlugin(BasePlugin): self.history = History(self, self.api) self.render = Render(self) self.chats = ChatSync(self, self.api) - self.deletes = Deletes(self, self.api, self.chats) + self.delete_dialog = DeleteDialog(self, self.chats) + self.deletes = Deletes(self, self.api, self.chats, self.delete_dialog) self.links = Links(self) self.menu = Menu(self, self.history.open) self.comments = CommentsButton(self, Comments(self, self.api).open) - parts = (self.render, self.chats, self.deletes, self.links, self.menu, self.comments) + parts = ( + self.render, + self.chats, + self.deletes, + self.delete_dialog, + self.links, + self.menu, + self.comments, + ) for part in parts: try: part.install() except Exception as exc: diag.error(f"install {type(part).__name__}", exc) + self.add_menu_item( + MenuItemData( + menu_type=MenuItemType.CHAT_ACTION_MENU, + text="Reset deleted/edited marks", + icon="msg_clear", + on_click=self._reset_chat, + ) + ) in_background(accounts.refresh) in_background(self.purge_cache) diag.log("loaded") + def _reset_chat(self, context): + """Chat menu: forget this chat's cached marks and check it again.""" + try: + fragment = context.get("fragment") + if fragment is None: + from client_utils import get_last_fragment + + fragment = get_last_fragment() + account = int(fragment.getCurrentAccount()) + dialog_id = int(context.get("dialog_id") or fragment.getDialogId()) + removed = self.chats.reset(account, dialog_id) + _bulletin(f"Message Logger: {removed} marks reset, checking again") + except Exception as exc: + diag.error("reset chat", exc) + + def _reset_all(self, _view=None): + """Settings: forget every chat's cached marks.""" + def run(): + removed = self.chats.reset() + _bulletin(f"Message Logger: {removed} marks reset in all chats") + + in_background(run) + def purge_cache(self, force=False): """Removes cached answers older than the configured lifetime; at most once a day (the day turns at local midnight) unless `force`.""" @@ -66,7 +107,15 @@ class MessageLoggerPlugin(BasePlugin): store.purge_old(CACHE_DAYS_CHOICES[index][1], force=force) def on_plugin_unload(self): - for part in ("render", "chats", "deletes", "links", "menu", "comments"): + for part in ( + "render", + "chats", + "deletes", + "delete_dialog", + "links", + "menu", + "comments", + ): if hasattr(self, part): getattr(self, part).uninstall() diag.log("unloaded") @@ -141,7 +190,14 @@ class MessageLoggerPlugin(BasePlugin): text="Report deletions", default=DEFAULTS[REPORT_DELETIONS], subtext="Tell the server about deletions this client sees", - icon="msg_sync", + icon="msg_report", + ), + Text( + text="Reset deleted/edited marks", + subtext="Forget all cached marks; chats are checked with the server again", + icon="msg_clear", + red=True, + on_click=self._reset_all, ), Selector( key=CACHE_DAYS, @@ -158,6 +214,6 @@ class MessageLoggerPlugin(BasePlugin): text="Deleted message opacity", default=DEFAULTS[DELETED_ALPHA], items=[label for label, _ in ALPHA_CHOICES], - icon="msg_hide", + icon="msg_palette", ), ] diff --git a/src/store.py b/src/store.py index 286e791..861c20b 100644 --- a/src/store.py +++ b/src/store.py @@ -91,6 +91,11 @@ def _conn(): "at REAL, PRIMARY KEY (account, dialog_id, message_id))" ) _db.execute("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)") + _db.execute( + "CREATE TABLE IF NOT EXISTS hidden (" + "account INTEGER, dialog_id INTEGER, message_id INTEGER, at REAL, " + "PRIMARY KEY (account, dialog_id, message_id))" + ) _db.execute( "CREATE TABLE IF NOT EXISTS coverage (" "account INTEGER, dialog_id INTEGER, low INTEGER, high INTEGER, at REAL, " @@ -180,10 +185,12 @@ def cached_deleted(account, dialog_id, low, high): [low, high] (what a restore needs, without asking the server again).""" with _lock: _local_ids(account, dialog_id) + hidden = _hidden_ids(account, dialog_id) return [ state.raw for message_id, state in _states.get((account, dialog_id), {}).items() if low <= message_id <= high + and message_id not in hidden and state.raw is not None and state.raw.get("deleted_at") and state.raw.get("logged") @@ -284,6 +291,79 @@ def forget_deleted(account, dialog_id, message_ids): diag.error("store.forget_deleted", exc) +def reset(account=None, dialog_id=None): + """Forgets cached marks, local deletion records and checked ranges of a + chat (or of every chat when `dialog_id` is None); the next look asks the + server again. Messages the user removed from view stay hidden. + Returns the number of marks forgotten.""" + where, args = "", () + if dialog_id is not None: + where, args = " WHERE account=? AND dialog_id=?", (int(account), int(dialog_id)) + with _lock: + removed = 0 + try: + db = _conn() + for table in ("states", "deleted", "coverage"): + count = db.execute(f"DELETE FROM {table}{where}", args).rowcount + if table != "coverage": + removed += count + db.commit() + except Exception as exc: + diag.error("store.reset", exc) + if dialog_id is None: + _states.clear() + _local.clear() + _coverage.clear() + _loaded.clear() + else: + key = (int(account), int(dialog_id)) + for cache in (_states, _local, _coverage): + cache.pop(key, None) + _loaded.discard(key) + return removed + + +_hidden = {} # (account, dialog_id) -> set of ids the user removed from view + + +def _hidden_ids(account, dialog_id): + key = (account, dialog_id) + if key not in _hidden: + found = set() + try: + rows = _conn().execute( + "SELECT message_id FROM hidden WHERE account=? AND dialog_id=?", key + ).fetchall() + found = {r[0] for r in rows} + except Exception as exc: + diag.error("store.hidden", exc) + _hidden[key] = found + return _hidden[key] + + +def hide(account, dialog_id, message_ids): + """Messages the user deleted (or removed from view): never shown or + restored here again.""" + ids = [int(i) for i in message_ids] + with _lock: + _hidden_ids(account, dialog_id).update(ids) + try: + db = _conn() + now = time.time() + db.executemany( + "INSERT OR IGNORE INTO hidden VALUES (?, ?, ?, ?)", + [(account, dialog_id, i, now) for i in ids], + ) + db.commit() + except Exception as exc: + diag.error("store.hide", exc) + + +def is_hidden(account, dialog_id, message_id): + with _lock: + return int(message_id) in _hidden_ids(account, dialog_id) + + def note_own_deletion(account, message_ids): now = time.monotonic() with _lock: diff --git a/src/views/comments.py b/src/views/comments.py index 458761c..dfecef1 100644 --- a/src/views/comments.py +++ b/src/views/comments.py @@ -304,7 +304,7 @@ class Comments(History): if rich is not None: _strip_rich_header(rich) plain = _rich_plain_text(rich) - if plain is not None: # text only: a normal bubble + if plain: # text only: a normal bubble msg.message = plain msg.entities = ArrayList() msg.rich_message = None diff --git a/tools/verify_bundle.py b/tools/verify_bundle.py index bb7c5ab..7f3eb4d 100755 --- a/tools/verify_bundle.py +++ b/tools/verify_bundle.py @@ -311,9 +311,39 @@ def main(argv=None) -> int: f"removed={removed} left={left}", ) check("age purge runs once a day", store.purge_old(1) == 0) + store.put_server_states(0, -7, [{"message_id": 1, "edit_count": 1}]) + store.put_server_states(0, -8, [{"message_id": 1, "edit_count": 1}]) + store.mark_deleted(0, -7, [2]) + store.hide(0, -7, [3]) + check( + "reset forgets one chat's marks, keeps others and hidden ones", + store.reset(0, -7) == 2 + and store.get(0, -7, 1) is None + and store.get(0, -8, 1) is not None + and store.is_hidden(0, -7, 3), + ) for name in ("cached_deleted", "coverage", "set_coverage", "expire_coverage", "replace_window"): check(f"store.{name} exists", callable(getattr(store, name, None))) + class JList(list): + def size(self): + return len(self) + + def get(self, index): + return self[index] + + class Node: + def __init__(self, text=None, texts=()): + self.text, self.texts = text, JList(texts) + + url = Node(Node("https://t.me/c/1/2")) # textUrl: empty `texts` + concat = Node(None, [Node("a "), Node(Node("b"))]) + check( + "rich text: wrappers and concatenations", + chat._rich_text(url) == "https://t.me/c/1/2" and chat._rich_text(concat) == "a b", + repr((chat._rich_text(url), chat._rich_text(concat))), + ) + api = load("api") client = api.Api(BasePlugin()) check("API unconfigured without URL and token", not client.configured)