Message Logger: keep own deletions, remove from view, reset marks
Publish plugin / publish (push) Successful in 4s

- Delete dialog: "Keep message for myself" checkbox keeps your deletion on
  this device (marked deleted); unticked, the message is hidden and never
  restored. Already deleted messages get a plain "Remove from view" dialog
  (fixes the chat staying dimmed/touch-blocked after it).
- "Reset deleted/edited marks" in the chat menu (Plugins) and, for all
  chats, in the plugin settings.
- Fix restored text-only messages showing as empty bubbles (rich text
  wrappers like links lost their text); missing settings icons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lyuksovannyyandClaude Opus 5.5 committed 2026-10-09 16:59:48 +02:00
1 parent bbe4a557a8
commit 0176f7099d
9 files changed
+586 -12

No files matched your search

+8
View File
@@ -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).
+1 -1
View File
@@ -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",
+80 -3
View File
@@ -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()
+316
View File
@@ -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
+8 -1
View File
@@ -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)
+62 -6
View File
@@ -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",
),
]
+80
View File
@@ -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:
+1 -1
View File
@@ -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
+30
View File
@@ -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)