diff --git a/README.md b/README.md
index d32f405..e2b9564 100644
--- a/README.md
+++ b/README.md
@@ -1,38 +1,72 @@
-
+
-exteraGram Plugin Base
+Ghost Mode
-**TL;DR:** a template repo for writing [exteraGram](https://exteragram.app) plugins as normal
-multi-file Python projects. Write code in `src/`, run one command, get a single `.plugin`
-file deployed to your phone or published to Releases.
+**TL;DR:** AyuGram's ghost mode as an [exteraGram](https://exteragram.app) plugin: read,
+browse and type without anyone seeing it. You can set every option globally, per account
+and per chat.
## Description
-exteraGram loads every plugin from exactly one Python file. This template lets you split a
-plugin into ordinary modules and packages anyway: a bundler compiles `src/` and `plugin.json`
-into that single file, an offline harness verifies the result without a device, ADB scripts
-push it to a connected phone and reload it, and CI publishes every version to the repo's
-Releases tab.
+Ghost Mode stops the app from sending the signals that give you away: read receipts, story
+views, typing statuses and your online status. It also adds AyuGram's companion options:
+read on interact, scheduled sending and silent sending. Nothing is sent and nothing changes
+until you turn something on.
+
+Every option works on three levels, and the most specific one with a value wins:
+
+1. **Chat**: open a chat or profile, tap ⋮ → *Ghost mode*.
+2. **Account**: plugin settings → *Accounts* → pick an account.
+3. **Global**: the switches on the plugin's main settings page.
+
+So you can stay a ghost everywhere except in one chat, or only on a second account. A forum
+(a group with topics) is one chat: its settings cover every topic, and there's nothing per
+topic.
## Features
-None for the end user. The bundled sample plugin (`Hello Bundle`) only proves that the
-toolchain works. This repo is meant to be used as a **template** for creating other plugins:
-click "Use this template" (or clone it), then replace `plugin.json` and `src/` with your own.
+**Ghost essentials** (the *Ghost mode* switch turns all five on or off)
+
+- **Don't read messages.** Read receipts aren't sent. Chats still look read on your side.
+- **Don't read stories.** Story views aren't sent.
+- **Don't send online.** Your online status is sent as offline. *Account level only.*
+- **Don't send typing.** Typing, recording and uploading statuses aren't sent.
+- **Go offline automatically.** Every online status is followed straight away by an
+ offline one. *Account level only.*
+
+**Options**
+
+- **Read on interact** (on by default). Sending a message to a chat where messages aren't
+ read marks that chat as read, as AyuGram does.
+- **Use scheduled messages.** Messages and forwards are scheduled 12 seconds ahead (media
+ gets more time to upload). Telegram's servers then deliver them, so sending doesn't put you
+ online. Secret chats, bots and Saved Messages are never scheduled.
+- **Send without sound.** Messages and forwards are sent silently.
+
+**In the app**
+
+- Chat and profile ⋮ menu → **Ghost mode**: this chat's options. Each one shows its value
+ and whether it comes from the chat, the account or the global settings. Tapping an option
+ cycles *Default → On → Off*. The menu also has a ghost toggle for the chat, *Read all
+ messages now* and a reset.
+- Message menu → **Read until here**: sends a read receipt up to that message.
+- Side menu → **Ghost mode**: the options of the account in use. It has the same list and
+ *Global → On → Off* cycling as its settings page, plus a ghost toggle and a reset.
+- Plugin settings → **Accounts**: each account's options (*Global / On / Off*) and its list
+ of chat exceptions.
## Documentation
+- [How Ghost Mode works](docs/how-it-works.md): what is intercepted, how settings resolve,
+ runtime notes
- [Development](docs/development.md): getting started, repo layout, building and verifying, icon export
- [How exteraGram plugins work](docs/how-plugins-work.md): the plugin format and lifecycle
- [Device setup and ADB automation](docs/device-and-adb.md): deploy, watch, logs, debugger
- [Versioning and releases](docs/versioning-and-releases.md): git hooks, auto-bump, CI publishing
- [SDK notes](docs/sdk-notes.md): condensed exteraGram SDK API surface
-## Quick start
+## Credits
-```bash
-python3 tools/bundle.py --check # once after cloning: enables the git hooks
-bash scripts/deploy.sh --enable # build + push to a connected device + enable
-```
+Ghost mode's behaviour follows [AyuGram](https://github.com/AyuGram/AyuGram4A) by Radolyn.
diff --git a/docs/development.md b/docs/development.md
index 8f49826..cc61fab 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -17,8 +17,15 @@
plugin.json single source of truth for plugin metadata
src/ the plugin, written as an ordinary Python package
main.py entry module — exactly one BasePlugin subclass, absolute imports only
- greeting/ sample package (proves relative + cross-module imports survive bundling)
- textutil.py sample top-level sibling module
+ features.py the ghost mode options and their defaults
+ prefs.py storage and resolution: chat -> account -> global
+ network.py request hooks: reads, stories, typing, online status, read on interact
+ sending.py scheduled and silent sending, forward hook, scheduled-screen guard
+ menus.py chat/profile/message/drawer menu items, the chat and account dialogs
+ drawer.py the drawer row for drawer layouts without a Plugins entry
+ settings_page.py the plugin settings screen and the per-account pages
+ peers.py accounts, dialog ids and names
+ hooking.py Xposed and UI-thread helpers
tools/
bundle.py compiles plugin.json + src/ into dist/.plugin
devclient.py speaks the on-device dev-server protocol (127.0.0.1:42690)
diff --git a/docs/how-it-works.md b/docs/how-it-works.md
new file mode 100644
index 0000000..386385a
--- /dev/null
+++ b/docs/how-it-works.md
@@ -0,0 +1,75 @@
+# How Ghost Mode works
+
+## What is intercepted
+
+Like AyuGram's hook in `ConnectionsManager.sendRequestInternal`, the plugin checks requests
+on their way out (`pre_request_hook`). Each request is checked against the options for the
+account it comes from and, where it has one, the chat it's about:
+
+| Option | Requests | What happens |
+| --- | --- | --- |
+| Don't read messages | `messages.readHistory`, `messages.readDiscussion`, `messages.readEncryptedHistory`, `messages.readMessageContents`, `channels.readHistory`, `channels.readMessageContents` | dropped |
+| Don't read stories | `stories.readStories`, `stories.incrementStoryViews` | dropped |
+| Don't send typing | `messages.setTyping`, `messages.setEncryptedTyping` | dropped |
+| Don't send online | `account.updateStatus` | `offline` forced to true |
+| Go offline automatically | `account.updateStatus` | an offline status is sent after each online one |
+| Read on interact | `messages.sendMessage`, `sendMedia`, `sendMultiMedia`, `sendInlineBotResult`, `forwardMessages` | once the send succeeds, the chat is read up to its newest message |
+
+Forum topics have no settings of their own. A topic's read receipt (`messages.readDiscussion`)
+and typing status carry the group as their peer, so the group's settings apply to every topic.
+The plugin's own read receipts follow the app's behaviour in forums: *Read until here* and
+*Read on interact* read the message's topic, and *Read all messages now* reads each loaded
+topic up to its newest message.
+
+`messages.readMessageContents` carries only message ids. It is sent for the chat on screen,
+so that chat's settings apply. The app still marks messages read locally, so chats look read
+to you and unread to the sender. AyuGram behaves the same way.
+
+The plugin's own read receipts (*Read on interact*, *Read all messages now*, *Read until
+here*) are let through by a one-shot allowance per chat that expires after 10 seconds.
+
+Scheduled and silent sending change the message before the app builds it:
+`on_send_message_hook` handles text and media, and an Xposed hook on
+`SendMessagesHelper.sendMessage(ArrayList, …)` handles forwards. Telegram sends
+anything scheduled less than 10 seconds ahead immediately, so the plugin schedules 12 seconds
+ahead, plus 10 seconds for photos and 15 for documents. All parts of an album share one date.
+When a scheduled message lands in the chat, the app would normally jump to the scheduled
+messages screen. For messages the plugin scheduled, it stays in the chat.
+
+## How settings resolve
+
+```
+chat override → account override → global switch
+```
+
+- Global: `g_` (bool), the switches on the main settings page.
+- Account: `a__` (0 Global, 1 On, 2 Off), the selectors on an account's
+ page. Accounts are keyed by Telegram user id, not by app slot, so overrides follow the
+ account when slots shift.
+- Chat: `c_`, a JSON object `{"": {"": bool}}`. Only chat-level
+ options can be stored here. *Don't send online* and *Go offline automatically* apply to
+ the whole account.
+
+"Ghost mode is on" means all five essentials are on, the same test as AyuGram's
+`isGhostModeActive`. At chat level only the three chat essentials count.
+
+## The drawer row
+
+exteraGram only shows plugin drawer items where the drawer layout has its *Plugins* entry, and
+that entry is optional. When the layout has no *Plugins* entry, an after-hook on
+`DrawerMenuView.rebuildMenu` appends a `DrawerMenuItemView`, built the way the app builds its
+own rows. With the entry, the app shows the plugin's `DRAWER_MENU` item there instead, so the
+row never shows twice. The row's icon is exteraGram's own `ghost` drawable.
+
+## Runtime notes
+
+Things the on-device runtime does differently from the SDK docs, all handled in `src/`:
+
+- Bundled modules aren't in `sys.modules`, so `dataclasses` can't be used in them.
+- `param.setResult()` in a before-hook doesn't skip the original method. To stop the
+ scheduled-screen jump, the chat is marked paused during that one
+ `didReceivedNotification` call.
+- `SendMessageParams.notify` clashes with `Object.notify()`, so it is read and written with
+ `hook_utils.get_private_field` / `set_private_field`.
+- A Python `int` put into a hooked method's `Object[]` arguments becomes a `Long`, so the
+ forward hook writes a boxed `Integer` and `Boolean`.
diff --git a/plugin.json b/plugin.json
index 6a6e8b5..9af3e6c 100644
--- a/plugin.json
+++ b/plugin.json
@@ -1,7 +1,7 @@
{
- "id": "hello_bundle",
- "name": "Hello Bundle",
- "description": "Multi-file exteraGram plugin built by tools/bundle.py",
+ "id": "ghost_mode",
+ "name": "Ghost Mode",
+ "description": "AyuGram's ghost mode: don't read messages or stories, don't send online or typing, go offline automatically, read on interact, scheduled and silent sending. Every option can be set globally, per account and per chat.",
"author": "@lyuksovannyy",
"version": "1.0.0",
"icon": "exteraGramPluginIcons/5",
diff --git a/src/drawer.py b/src/drawer.py
new file mode 100644
index 0000000..f417c9f
--- /dev/null
+++ b/src/drawer.py
@@ -0,0 +1,74 @@
+"""A "Ghost mode" row in the side drawer, opening the current account's options.
+
+exteraGram only shows plugin drawer items (MenuItemType.DRAWER_MENU) where the
+drawer layout has its "Plugins" entry (MainMenuItem.PLUGINS), and that entry
+is optional. DrawerMenuView.rebuildMenu(int account, BaseFragment) builds the
+drawer from ExteraConfig.getMainMenuLayout(); when the layout has no Plugins
+entry, a row made the way rebuildMenu makes its own (a DrawerMenuItemView) is
+appended after it. With the Plugins entry the app shows the plugin's own
+DRAWER_MENU item there instead, so the row is never doubled.
+"""
+
+from base_plugin import MethodHook
+
+from hooking import field, hook_all, unhook_all
+from icons import GHOST, drawable_id
+
+DRAWER = "com.exteragram.messenger.drawer.DrawerMenuView"
+TEXT = "Ghost mode"
+
+
+def plugins_entry_shown():
+ """Whether the drawer layout has the Plugins entry."""
+ from com.exteragram.messenger import ExteraConfig, MainMenuItem
+
+ plugins = int(MainMenuItem.PLUGINS.getId())
+ layout = ExteraConfig.getMainMenuLayout()
+ return any(int(layout.get(i)) == plugins for i in range(layout.size()))
+
+
+class Drawer:
+ def __init__(self, plugin, open_account):
+ self.plugin = plugin
+ # open_account(account slot): shows that account's options
+ self.open_account = open_account
+ self.handles = []
+
+ def install(self):
+ drawer = self
+
+ class AfterRebuild(MethodHook):
+ def after_hooked_method(self, param):
+ try:
+ drawer._add_row(param.thisObject, int(param.args[0]))
+ except Exception as exc:
+ drawer.plugin.log(f"drawer row: {exc}")
+
+ self.handles += hook_all(self.plugin, DRAWER, "rebuildMenu", AfterRebuild())
+
+ def uninstall(self):
+ unhook_all(self.plugin, self.handles)
+
+ def _add_row(self, menu, account):
+ if plugins_entry_shown():
+ return
+ container = field(menu, "container")
+ if container is None:
+ return
+ from android_utils import OnClickListener
+ from com.exteragram.messenger import MainMenuItem
+ from com.exteragram.messenger.drawer import DrawerMenuItemView
+
+ row = DrawerMenuItemView(container.getContext())
+ # The id only picks an unread counter, and Plugins has none.
+ # icons.Icons shrinks the 32dp ghost to 24dp as setMenuItem sets it.
+ row.setMenuItem(int(MainMenuItem.PLUGINS.getId()), account, drawable_id(GHOST), TEXT)
+
+ def clicked(_view):
+ close = field(menu, "onItemClick")
+ if close is not None:
+ close.run()
+ self.open_account(account)
+
+ row.setOnClickListener(OnClickListener(clicked))
+ container.addView(row)
diff --git a/src/features.py b/src/features.py
new file mode 100644
index 0000000..d755d1e
--- /dev/null
+++ b/src/features.py
@@ -0,0 +1,115 @@
+"""The ghost mode options, modelled on AyuGram's "Ghost essentials" and the
+options next to them.
+
+Every option is a boolean where True means the ghost behaviour is active
+(e.g. NO_READ on: read receipts are not sent). Options with `chat=True` can be
+overridden per chat; the others are about the account as a whole (online
+status), so they stop at the account level.
+"""
+
+
+class Feature:
+ # Not a dataclass: dataclasses look their module up in sys.modules, and
+ # bundled modules aren't there.
+ __slots__ = ("key", "title", "subtext", "icon", "emoji", "default", "chat", "essential")
+
+ def __init__(self, key, title, subtext, icon, emoji, default, chat, essential):
+ self.key = key
+ self.title = title
+ self.subtext = subtext
+ self.icon = icon
+ self.emoji = emoji
+ self.default = default
+ self.chat = chat
+ self.essential = essential
+
+ def __repr__(self):
+ return f"Feature({self.key!r})"
+
+
+NO_READ = Feature(
+ "no_read",
+ "Don't read messages",
+ "Others don't see that you've read their messages",
+ "msg_markunread",
+ "📨",
+ False,
+ chat=True,
+ essential=True,
+)
+NO_READ_STORIES = Feature(
+ "no_read_stories",
+ "Don't read stories",
+ "Others don't see that you've viewed their stories",
+ "msg_views",
+ "👀",
+ False,
+ chat=True,
+ essential=True,
+)
+NO_ONLINE = Feature(
+ "no_online",
+ "Don't send online",
+ "You stay offline while using the app",
+ "msg_online",
+ "📡",
+ False,
+ chat=False,
+ essential=True,
+)
+NO_TYPING = Feature(
+ "no_typing",
+ "Don't send typing",
+ "Typing, recording and uploading statuses are not sent",
+ "msg_edit",
+ "⌨️",
+ False,
+ chat=True,
+ essential=True,
+)
+AUTO_OFFLINE = Feature(
+ "auto_offline",
+ "Go offline automatically",
+ "Every online status is followed by an offline one right away",
+ "msg_leave",
+ "💤",
+ False,
+ chat=False,
+ essential=True,
+)
+READ_ON_INTERACT = Feature(
+ "read_on_interact",
+ "Read on interact",
+ "Sending a message to a chat marks it as read, even when messages aren't read",
+ "msg_markread",
+ "💬",
+ True,
+ chat=True,
+ essential=False,
+)
+SCHEDULED = Feature(
+ "scheduled",
+ "Use scheduled messages",
+ "Messages are scheduled a few seconds ahead, so sending doesn't put you online",
+ "msg_calendar2",
+ "⏰",
+ False,
+ chat=True,
+ essential=False,
+)
+SILENT = Feature(
+ "silent",
+ "Send without sound",
+ "Recipients get your messages without a notification sound",
+ "input_notify_off",
+ "🔕",
+ False,
+ chat=True,
+ essential=False,
+)
+
+ESSENTIALS = (NO_READ, NO_READ_STORIES, NO_ONLINE, NO_TYPING, AUTO_OFFLINE)
+EXTRAS = (READ_ON_INTERACT, SCHEDULED, SILENT)
+ALL = ESSENTIALS + EXTRAS
+CHAT = tuple(f for f in ALL if f.chat)
+BY_KEY = {f.key: f for f in ALL}
diff --git a/src/greeting/__init__.py b/src/greeting/__init__.py
deleted file mode 100644
index f0d6578..0000000
--- a/src/greeting/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-from .template import render, TemplateError
diff --git a/src/greeting/template.py b/src/greeting/template.py
deleted file mode 100644
index 5ccc0e9..0000000
--- a/src/greeting/template.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from textutil import normalize
-
-
-class TemplateError(ValueError):
- pass
-
-
-def render(template: str, name: str) -> str:
- normalized = normalize(name)
- try:
- return template.format(name=normalized)
- except (KeyError, IndexError, ValueError):
- raise TemplateError(f"unsupported placeholder in {template!r}")
diff --git a/src/hooking.py b/src/hooking.py
new file mode 100644
index 0000000..0edcb43
--- /dev/null
+++ b/src/hooking.py
@@ -0,0 +1,56 @@
+"""Small helpers around the SDK's Xposed hooking and Java threading."""
+
+from hook_utils import find_class
+
+
+def hook_all(plugin, class_name, method_name, hook):
+ """Hooks every overload of `class_name.method_name`; returns the unhook handles."""
+ clazz = find_class(class_name)
+ if clazz is None:
+ plugin.log(f"class not found: {class_name}")
+ return []
+ try:
+ unhooks = plugin.hook_all_methods(clazz, method_name, hook)
+ except Exception as exc:
+ plugin.log(f"failed to hook {class_name}.{method_name}: {exc}")
+ return []
+ unhooks = list(unhooks or [])
+ if not unhooks:
+ plugin.log(f"no overloads hooked for {class_name}.{method_name}")
+ return unhooks
+
+
+def unhook_all(plugin, handles):
+ for handle in handles:
+ try:
+ plugin.unhook_method(handle)
+ except Exception:
+ pass
+ del handles[:]
+
+
+def field(obj, name, default=None):
+ """A (possibly private) field of a Java object."""
+ value = getattr(obj, name, None)
+ if value is not None:
+ return value
+ try:
+ from hook_utils import get_private_field
+
+ value = get_private_field(obj, name)
+ except Exception:
+ value = None
+ return default if value is None else value
+
+
+def on_ui(plugin, fn, delay_ms=0):
+ """Runs `fn` on the UI thread; exceptions are logged, not lost."""
+ from android_utils import run_on_ui_thread
+
+ def run():
+ try:
+ fn()
+ except Exception as exc:
+ plugin.log(f"ui task failed: {exc}")
+
+ run_on_ui_thread(run, delay_ms)
diff --git a/src/icons.py b/src/icons.py
new file mode 100644
index 0000000..4c8af17
--- /dev/null
+++ b/src/icons.py
@@ -0,0 +1,90 @@
+"""Keeps the ghost icon at menu-icon size.
+
+exteraGram's `ghost` drawable is 32dp (its icon-pack variants 30dp), while
+menu and drawer icons are 24dp, and both views show icons at their intrinsic
+size (ImageView.ScaleType.CENTER). The menus only take a resource id, so
+after a view has set the ghost icon, the drawable it holds (already swapped
+for the active icon pack by the app's resources) is wrapped in a LayerDrawable
+sized to 24dp:
+
+ ActionBarMenuSubItem.setTextAndIcon(CharSequence, int, ...) chat/profile menus
+ DrawerMenuItemView.setMenuItem(int, int, int, CharSequence) drawer rows
+"""
+
+from base_plugin import MethodHook
+
+from hooking import field, hook_all, unhook_all
+
+GHOST = "ghost"
+SIZE_DP = 24
+
+SUB_ITEM = "org.telegram.ui.ActionBar.ActionBarMenuSubItem"
+DRAWER_ITEM = "com.exteragram.messenger.drawer.DrawerMenuItemView"
+
+_ids = {}
+
+
+def drawable_id(name):
+ """A drawable's resource id by name (the app's R isn't importable)."""
+ if name not in _ids:
+ from org.telegram.messenger import ApplicationLoader
+
+ context = ApplicationLoader.applicationContext
+ _ids[name] = int(context.getResources().getIdentifier(name, "drawable", context.getPackageName()))
+ return _ids[name]
+
+
+def fit(drawable, size_dp=SIZE_DP):
+ """`drawable` drawn into a size_dp square, or as is when it isn't bigger."""
+ if drawable is None:
+ return None
+ from android.graphics.drawable import LayerDrawable
+ from org.telegram.messenger import AndroidUtilities
+
+ size = int(AndroidUtilities.dp(size_dp))
+ if max(int(drawable.getIntrinsicWidth()), int(drawable.getIntrinsicHeight())) <= size:
+ return drawable
+ layer = LayerDrawable([drawable])
+ layer.setLayerSize(0, size, size)
+ return layer
+
+
+def shrink(image_view):
+ if image_view is not None:
+ image_view.setImageDrawable(fit(image_view.getDrawable()))
+
+
+class Icons:
+ def __init__(self, plugin):
+ self.plugin = plugin
+ self.handles = []
+
+ def install(self):
+ icons = self
+
+ class SubItemHook(MethodHook):
+ # setTextAndIcon(CharSequence text, int icon, ...)
+ def after_hooked_method(self, param):
+ icons._after(param, 1, "imageView")
+
+ class DrawerItemHook(MethodHook):
+ # setMenuItem(int layoutId, int account, int icon, CharSequence text)
+ def after_hooked_method(self, param):
+ icons._after(param, 2, "iconView")
+
+ self.handles += hook_all(self.plugin, SUB_ITEM, "setTextAndIcon", SubItemHook())
+ self.handles += hook_all(self.plugin, DRAWER_ITEM, "setMenuItem", DrawerItemHook())
+
+ def uninstall(self):
+ unhook_all(self.plugin, self.handles)
+
+ def _after(self, param, icon_arg, view_field):
+ try:
+ args = param.args
+ if len(args) <= icon_arg or not isinstance(args[icon_arg], int):
+ return
+ if int(args[icon_arg]) != drawable_id(GHOST):
+ return
+ shrink(field(param.thisObject, view_field))
+ except Exception as exc:
+ self.plugin.log(f"ghost icon size: {exc}")
diff --git a/src/main.py b/src/main.py
index f24e0fa..6e5ffc1 100644
--- a/src/main.py
+++ b/src/main.py
@@ -1,50 +1,65 @@
from base_plugin import BasePlugin, HookResult, HookStrategy
-from android_utils import log
-from ui.settings import Header, Input, Text
-from greeting import render, TemplateError
-DEFAULT_TEMPLATE = "Hello, {name}!"
+import peers
+from drawer import Drawer
+from icons import Icons
+from menus import Menus
+from network import Network
+from prefs import Prefs
+from sending import Sending
+from settings_page import SettingsPage
-class HelloPlugin(BasePlugin):
+class GhostModePlugin(BasePlugin):
def on_plugin_load(self):
- log("hello_bundle loaded")
+ self.prefs = Prefs(self, peers.uid_of_slot)
+ self.network = Network(self, self.prefs)
+ self.sending = Sending(self, self.prefs)
+ self.menus = Menus(self, self.prefs, self.network)
+ self.drawer = Drawer(self, self.menus.open_account_dialog)
+ self.icons = Icons(self)
+ self.page = SettingsPage(self, self.prefs, self.menus)
+ # The drawer hook goes in before the menu items, so a drawer rebuild
+ # they cause already has the row.
+ for part in (self.network, self.sending, self.icons, self.drawer, self.menus):
+ try:
+ part.install()
+ except Exception as exc:
+ self.log(f"install {type(part).__name__}: {exc}")
self.add_on_send_message_hook()
+ self.log("ghost_mode loaded")
def on_plugin_unload(self):
- log("hello_bundle unloaded")
+ for name in ("network", "sending", "icons", "drawer", "menus"):
+ part = getattr(self, name, None)
+ if part is not None:
+ try:
+ part.uninstall()
+ except Exception as exc:
+ self.log(f"uninstall {name}: {exc}")
+ self.log("ghost_mode unloaded")
- def create_settings(self):
- return [
- Header(text="Hello Bundle"),
- Input(
- key="template",
- text="Greeting template",
- default=DEFAULT_TEMPLATE,
- subtext="Use {name} as placeholder.",
- icon="msg_edit",
- ),
- Text(
- text="Example",
- subtext=".hello alice smith -> Hello, Alice Smith!",
- icon="msg_info",
- ),
- ]
+ def pre_request_hook(self, request_name, account, request):
+ try:
+ return self.network.pre(request_name, account, request)
+ except Exception as exc:
+ self.log(f"pre_request_hook {request_name}: {exc}")
+ return HookResult()
+
+ def post_request_hook(self, request_name, account, response, error):
+ try:
+ return self.network.post(request_name, account, response, error)
+ except Exception as exc:
+ self.log(f"post_request_hook {request_name}: {exc}")
+ return HookResult()
def on_send_message_hook(self, account, params):
- message = getattr(params, "message", None)
- if not isinstance(message, str):
- return HookResult()
- stripped = message.strip()
- if not stripped.startswith(".hello"):
- return HookResult()
- parts = stripped.split(" ", 1)
- name = parts[1] if len(parts) > 1 else ""
- if not name.strip():
- params.message = "Usage: .hello "
- else:
- try:
- params.message = render(self.get_setting("template", DEFAULT_TEMPLATE), name)
- except TemplateError as exc:
- params.message = "Bad template: " + str(exc)
- return HookResult(strategy=HookStrategy.MODIFY, params=params)
+ try:
+ if self.sending.on_send_message(account, params):
+ return HookResult(strategy=HookStrategy.MODIFY, params=params)
+ except Exception as exc:
+ self.log(f"on_send_message_hook: {exc}")
+ return HookResult()
+
+ def create_settings(self):
+ return self.page.build()
diff --git a/src/menus.py b/src/menus.py
new file mode 100644
index 0000000..5eb8220
--- /dev/null
+++ b/src/menus.py
@@ -0,0 +1,256 @@
+"""Ghost mode in the app's menus.
+
+ chat ⋮ menu / profile ⋮ menu "Ghost mode": this chat's options
+ message menu "Read until here": read receipt up to it
+ side drawer "Ghost mode": the current account's options
+ (see drawer.py for drawers without a
+ Plugins entry)
+
+Both popups (options_dialog.py) show each option's own state; tapping one
+cycles Inherit -> On -> Off. A chat's Inherit follows its account, an
+account's (shown as Global) follows the global switches. Forum topics have no options of their
+own: the chat dialog opened from a topic edits the whole group.
+"""
+
+from base_plugin import MenuItemData, MenuItemType
+
+import features
+import peers
+import options_dialog
+from hooking import on_ui
+from options_dialog import INHERITED_STATE, OFF_STATE, ON_STATE, Row
+from prefs import INHERIT, OFF, ON
+
+# exteraGram's own ghost drawable (icon packs have their own variants).
+GHOST_ICON = "ghost"
+# Inherit (Global for an account) -> On -> Off -> Inherit
+_NEXT = {INHERIT: ON, ON: OFF, OFF: INHERIT}
+_STATE = {INHERIT: INHERITED_STATE, ON: ON_STATE, OFF: OFF_STATE}
+
+
+def _state(value):
+ return "On" if value else "Off"
+
+
+def _bulletin(text, error=False):
+ from ui.bulletin import BulletinHelper
+
+ (BulletinHelper.show_error if error else BulletinHelper.show_info)(text)
+
+
+def _activity():
+ from client_utils import get_last_fragment
+
+ fragment = get_last_fragment()
+ return fragment.getParentActivity() if fragment is not None else None
+
+
+def _int(value):
+ try:
+ return int(value) if value is not None else None
+ except (TypeError, ValueError):
+ return None
+
+
+def _context_chat(context):
+ """(account, dialog id) a chat or profile menu was opened for."""
+ fragment = context.get("fragment")
+ account = _int(context.get("account"))
+ if account is None and fragment is not None:
+ account = _int(fragment.getCurrentAccount())
+ dialog = _int(context.get("dialog_id"))
+ if not dialog:
+ user_id = _int(context.get("userId"))
+ chat_id = _int(context.get("chatId"))
+ user = context.get("user")
+ chat = context.get("chat")
+ if user_id:
+ dialog = user_id
+ elif chat_id:
+ dialog = -chat_id
+ elif user is not None:
+ dialog = _int(user.id)
+ elif chat is not None:
+ dialog = -_int(chat.id)
+ if not dialog and fragment is not None and hasattr(fragment, "getDialogId"):
+ dialog = _int(fragment.getDialogId())
+ if account is None:
+ account = peers.selected_slot()
+ return account, dialog
+
+
+class Menus:
+ def __init__(self, plugin, prefs, network):
+ self.plugin = plugin
+ self.prefs = prefs
+ self.network = network
+ self.item_ids = []
+
+ def install(self):
+ items = (
+ MenuItemData(
+ menu_type=MenuItemType.CHAT_ACTION_MENU,
+ text="Ghost mode",
+ icon=GHOST_ICON,
+ on_click=self._on_chat_menu,
+ ),
+ MenuItemData(
+ menu_type=MenuItemType.PROFILE_ACTION_MENU,
+ text="Ghost mode",
+ icon=GHOST_ICON,
+ on_click=self._on_chat_menu,
+ ),
+ MenuItemData(
+ menu_type=MenuItemType.MESSAGE_CONTEXT_MENU,
+ text="Read until here",
+ icon="msg_view_file",
+ on_click=self._on_read_until,
+ ),
+ MenuItemData(
+ menu_type=MenuItemType.DRAWER_MENU,
+ text="Ghost mode",
+ icon=GHOST_ICON,
+ on_click=self._on_drawer,
+ ),
+ )
+ for item in items:
+ try:
+ item_id = self.plugin.add_menu_item(item)
+ if item_id:
+ self.item_ids.append(item_id)
+ except Exception as exc:
+ self.plugin.log(f"menu item {item.text!r}: {exc}")
+
+ def uninstall(self):
+ for item_id in self.item_ids:
+ try:
+ self.plugin.remove_menu_item(item_id)
+ except Exception:
+ pass
+ self.item_ids = []
+
+ # handlers
+
+ def _on_chat_menu(self, context):
+ try:
+ account, dialog = _context_chat(context)
+ if not dialog:
+ _bulletin("Ghost mode: can't tell which chat this is", True)
+ return
+ self.open_chat_dialog(account, dialog)
+ except Exception as exc:
+ self.plugin.log(f"chat menu: {exc}")
+ _bulletin(f"Ghost mode: {exc}", True)
+
+ def _on_read_until(self, context):
+ try:
+ message = context.get("message")
+ if message is None:
+ return
+ account = _int(context.get("account"))
+ if account is None:
+ account = _int(getattr(message, "currentAccount", None))
+ if account is None:
+ account = peers.selected_slot()
+ dialog = int(message.getDialogId())
+ max_id = int(message.getId())
+ if max_id <= 0:
+ _bulletin("This message isn't sent yet", True)
+ return
+ # In a forum the read receipt covers the message's topic, as the
+ # app's own would; the settings still belong to the whole group.
+ topic = peers.message_topic(message) if dialog < 0 else None
+ if not self.network.read_now(account, dialog, max_id, self._read_done, topic=topic):
+ _bulletin("Read receipts can't be sent in this chat", True)
+ except Exception as exc:
+ self.plugin.log(f"read until here: {exc}")
+ _bulletin(f"Ghost mode: {exc}", True)
+
+ def _read_done(self, ok):
+ on_ui(self.plugin, lambda: _bulletin("Marked as read" if ok else "Couldn't mark as read", not ok))
+
+ def _on_drawer(self, context):
+ try:
+ account = _int(context.get("account"))
+ if account is None:
+ account = peers.selected_slot()
+ self.open_account_dialog(account)
+ except Exception as exc:
+ self.plugin.log(f"drawer: {exc}")
+ _bulletin(f"Ghost mode: {exc}", True)
+
+ # dialogs
+
+ def _show(self, title, inherited_word, build_rows, on_close=None):
+ activity = _activity()
+ if activity is None:
+ return
+ options_dialog.show(self.plugin, activity, title, inherited_word, build_rows, on_close)
+
+ def open_account_dialog(self, account, on_close=None):
+ """The account's own options, each On / Global / Off."""
+ uid = peers.uid_of_slot(account)
+ if not uid:
+ _bulletin("Ghost mode: this account isn't signed in", True)
+ return
+ prefs = self.prefs
+
+ def build_rows():
+ ghost = prefs.ghost_active(uid)
+ rows = [Row("👻", "Ghost mode", lambda: self._toggle_account(uid, not ghost), _STATE[ON if ghost else OFF])]
+ for feature in features.ALL:
+ choice = prefs.account_choice(uid, feature)
+ rows.append(Row(feature.emoji, feature.title, lambda f=feature: self._cycle_account(uid, f), _STATE[choice]))
+ if prefs.account_overrides(uid):
+ rows.append(Row("↩️", "Use global settings", lambda: self._reset_account(uid)))
+ return rows
+
+ self._show(f"Ghost mode · {peers.account_name(account)}", "Global", build_rows, on_close)
+
+ def open_chat_dialog(self, account, dialog, on_close=None):
+ """The chat's options, each On / Inherit (the account's) / Off.
+ Forum topics share their group's options; nothing is per topic."""
+ uid = peers.uid_of_slot(account)
+ if not uid:
+ _bulletin("Ghost mode: this account isn't signed in", True)
+ return
+ prefs = self.prefs
+
+ def build_rows():
+ ghost = prefs.ghost_active(uid, dialog)
+ rows = [Row("👻", "Ghost mode", lambda: self._toggle_chat(uid, dialog, not ghost), _STATE[ON if ghost else OFF])]
+ for feature in features.CHAT:
+ choice = prefs.chat_choice(uid, dialog, feature)
+ rows.append(Row(feature.emoji, feature.title, lambda f=feature: self._cycle_chat(uid, dialog, f), _STATE[choice]))
+ if prefs.resolve(uid, features.NO_READ, dialog)[0] and not peers.is_encrypted(dialog):
+ rows.append(Row("✅", "Read all messages now", lambda: self._read_all(account, dialog), closes=True))
+ if prefs.chat_overrides(uid).get(int(dialog)):
+ rows.append(Row("↩️", "Use account settings", lambda: prefs.clear_chat(uid, dialog)))
+ return rows
+
+ self._show(f"Ghost mode · {peers.dialog_title(account, dialog)}", "Inherit", build_rows, on_close)
+
+ def _toggle_account(self, uid, enable):
+ for feature in features.ESSENTIALS:
+ self.prefs.set_account_choice(uid, feature, ON if enable else OFF)
+
+ def _cycle_account(self, uid, feature):
+ choice = self.prefs.account_choice(uid, feature)
+ self.prefs.set_account_choice(uid, feature, _NEXT[choice])
+
+ def _reset_account(self, uid):
+ for feature in features.ALL:
+ self.prefs.set_account_choice(uid, feature, INHERIT)
+
+ def _toggle_chat(self, uid, dialog, enable):
+ self.prefs.set_chat_choices(
+ uid, dialog, {f: ON if enable else OFF for f in features.ESSENTIALS if f.chat}
+ )
+
+ def _cycle_chat(self, uid, dialog, feature):
+ choice = self.prefs.chat_choice(uid, dialog, feature)
+ self.prefs.set_chat_choice(uid, dialog, feature, _NEXT[choice])
+
+ def _read_all(self, account, dialog):
+ if not self.network.read_now(account, dialog, None, self._read_done):
+ _bulletin("Nothing to mark as read", True)
diff --git a/src/network.py b/src/network.py
new file mode 100644
index 0000000..b824cea
--- /dev/null
+++ b/src/network.py
@@ -0,0 +1,306 @@
+"""The request side of ghost mode, after AyuGram's hook in
+ConnectionsManager.sendRequestInternal: every request the app sends that
+gives you away is checked against the options for its account and chat
+before it leaves.
+
+ read receipts dropped (Don't read messages)
+ story views dropped (Don't read stories)
+ typing statuses dropped (Don't send typing)
+ online status sent as offline (Don't send online)
+ or followed by an offline (Go offline automatically)
+ sent messages followed by a read receipt (Read on interact)
+
+The app still marks things read locally, so chats look read here and unread
+to the other side. Forum topics have no settings of their own: their read
+receipts (messages.readDiscussion) and typing carry the group as their peer,
+so the group's settings apply to every topic. Read receipts this plugin sends itself (Read on interact,
+"Read now", "Read until here") are let through by an allowance per chat.
+"""
+
+import threading
+import time
+
+from base_plugin import HookResult, HookStrategy
+
+import features
+import peers
+from hooking import on_ui
+
+# request name -> where its dialog is
+READS = {
+ "TL_messages_readHistory": "peer",
+ "TL_messages_readDiscussion": "peer",
+ "TL_messages_readEncryptedHistory": "encrypted",
+ # Carries message ids only (private chats and basic groups, where ids
+ # are per account): it's sent for the chat on screen.
+ "TL_messages_readMessageContents": "open_chat",
+ "TL_channels_readHistory": "channel",
+ "TL_channels_readMessageContents": "channel",
+}
+STORIES = {
+ "TL_stories_readStories": "peer",
+ "TL_stories_incrementStoryViews": "peer",
+}
+TYPING = {
+ "TL_messages_setTyping": "peer",
+ "TL_messages_setEncryptedTyping": "encrypted",
+}
+SENDS = {
+ "TL_messages_sendMessage": "peer",
+ "TL_messages_sendMedia": "peer",
+ "TL_messages_sendMultiMedia": "peer",
+ "TL_messages_sendInlineBotResult": "peer",
+ "TL_messages_forwardMessages": "to_peer",
+}
+STATUS = "TL_account_updateStatus"
+
+NAMES = tuple(READS) + tuple(STORIES) + tuple(TYPING) + tuple(SENDS) + (STATUS,)
+
+# Sends whose response hasn't arrived yet are matched to their chat in order;
+# this caps the queue should a response never be reported.
+_MAX_PENDING = 32
+# Delay before reading a chat after sending to it, so the sent message and
+# whatever arrived just before it are in the app's state.
+_INTERACT_DELAY_MS = 400
+# How long an allowance for one of our own read receipts lasts: should the
+# receipt never pass the hook, a later read of the chat mustn't use it.
+_ALLOWANCE_SECONDS = 10.0
+
+
+class Network:
+ def __init__(self, plugin, prefs):
+ self.plugin = plugin
+ self.prefs = prefs
+ self._lock = threading.Lock()
+ self._allowed = {} # (account, dialog id) -> [expiry times] of own read requests
+ self._interacted = {} # account -> [(dialog id, topic id or 0)] sent to, awaiting a read
+ self._went_online = set() # accounts owing an offline status
+
+ def install(self):
+ for name in NAMES:
+ self.plugin.add_hook(name)
+
+ def uninstall(self):
+ for name in NAMES:
+ try:
+ self.plugin.remove_hook(name)
+ except Exception:
+ pass
+
+ # hooks
+
+ def pre(self, name, account, request):
+ if name in READS:
+ return self._on_read(account, self._dialog(account, request, READS[name]))
+ if name in STORIES:
+ dialog = self._dialog(account, request, STORIES[name])
+ if self.prefs.enabled(account, features.NO_READ_STORIES, dialog):
+ return HookResult(strategy=HookStrategy.CANCEL)
+ return HookResult()
+ if name in TYPING:
+ dialog = self._dialog(account, request, TYPING[name])
+ if self.prefs.enabled(account, features.NO_TYPING, dialog):
+ return HookResult(strategy=HookStrategy.CANCEL)
+ return HookResult()
+ if name == STATUS:
+ return self._on_status(account, request)
+ if name in SENDS:
+ self._on_send(account, self._dialog(account, request, SENDS[name]), request)
+ return HookResult()
+
+ def post(self, name, account, response, error):
+ if name == STATUS:
+ with self._lock:
+ owed = account in self._went_online
+ self._went_online.discard(account)
+ if owed:
+ self.send_offline(account)
+ elif name in SENDS:
+ with self._lock:
+ queue = self._interacted.get(account)
+ entry = queue.pop(0) if queue else None
+ if entry is not None and error is None:
+ dialog, topic = entry
+ on_ui(self.plugin, lambda: self._read_interacted(account, dialog, topic), _INTERACT_DELAY_MS)
+ return HookResult()
+
+ # pieces
+
+ def _dialog(self, account, request, where):
+ try:
+ if where == "peer":
+ return peers.dialog_of_input_peer(request.peer, self._self_uid(account))
+ if where == "to_peer":
+ return peers.dialog_of_input_peer(request.to_peer, self._self_uid(account))
+ if where == "channel":
+ return peers.dialog_of_input_channel(request.channel)
+ if where == "encrypted":
+ return peers.dialog_of_encrypted(request.peer)
+ if where == "open_chat":
+ return peers.open_chat(account)[1]
+ except Exception as exc:
+ self.plugin.log(f"no dialog for request: {exc}")
+ return None
+
+ def _self_uid(self, account):
+ try:
+ return peers.uid_of_slot(account)
+ except Exception:
+ return 0
+
+ def _on_read(self, account, dialog):
+ key = (account, dialog)
+ now = time.monotonic()
+ with self._lock:
+ pending = [t for t in self._allowed.pop(key, []) if t > now]
+ if pending:
+ pending.pop(0)
+ if pending:
+ self._allowed[key] = pending
+ return HookResult()
+ if self.prefs.enabled(account, features.NO_READ, dialog):
+ return HookResult(strategy=HookStrategy.CANCEL)
+ return HookResult()
+
+ def _on_status(self, account, request):
+ if bool(getattr(request, "offline", True)):
+ return HookResult()
+ if self.prefs.enabled(account, features.NO_ONLINE):
+ request.offline = True
+ return HookResult(strategy=HookStrategy.MODIFY, request=request)
+ if self.prefs.enabled(account, features.AUTO_OFFLINE):
+ with self._lock:
+ self._went_online.add(account)
+ return HookResult()
+
+ def _on_send(self, account, dialog, request):
+ if not dialog:
+ return
+ if not self.prefs.enabled(account, features.READ_ON_INTERACT, dialog):
+ return
+ if not self.prefs.enabled(account, features.NO_READ, dialog):
+ return # the app reads the chat itself
+ topic = peers.request_topic(request) if dialog < 0 else 0
+ with self._lock:
+ queue = self._interacted.setdefault(account, [])
+ queue.append((dialog, topic))
+ del queue[:-_MAX_PENDING]
+
+ def _read_interacted(self, account, dialog, topic):
+ """Reads the chat, or in a forum the topic, a message was sent to."""
+ from org.telegram.messenger import MessagesController
+
+ if dialog < 0 and not topic:
+ chat = MessagesController.getInstance(int(account)).getChat(-dialog)
+ if peers.is_forum(chat):
+ topic = peers.GENERAL_TOPIC
+ self.read_now(account, dialog, topic=topic or None)
+
+ # requests of our own
+
+ def allow_read(self, account, dialog):
+ with self._lock:
+ key = (account, dialog)
+ self._allowed.setdefault(key, []).append(time.monotonic() + _ALLOWANCE_SECONDS)
+
+ def read_now(self, account, dialog, max_id=None, on_done=None, topic=None):
+ """Sends a read receipt for `dialog` up to `max_id` (default: its
+ newest message) even with Don't read messages on.
+
+ Forums are read per topic, as the app does (messages.readDiscussion):
+ `topic` alone, or with no topic every topic, each up to its newest
+ message. Settings are never per topic: a topic follows its group.
+
+ Returns False when there is nothing to read or the chat is a
+ secret chat, whose read receipts this doesn't send.
+ """
+ from org.telegram.messenger import ChatObject, MessagesController
+ from org.telegram.tgnet import TLRPC
+
+ account, dialog = int(account), int(dialog)
+ if peers.is_encrypted(dialog):
+ return False
+ controller = MessagesController.getInstance(account)
+ chat = controller.getChat(-dialog) if dialog < 0 else None
+
+ if peers.is_forum(chat):
+ if topic:
+ reads = [(int(topic), max_id or peers.topic_top_message(controller, -dialog, topic))]
+ else:
+ reads = peers.forum_topics(controller, -dialog)
+ requests = []
+ for topic_id, top in reads:
+ if not top or top <= 0:
+ continue
+ request = TLRPC.TL_messages_readDiscussion()
+ request.peer = controller.getInputPeer(dialog)
+ request.msg_id = int(topic_id)
+ request.read_max_id = int(top)
+ requests.append(request)
+ return self._send_reads(account, dialog, requests, controller, on_done)
+
+ if max_id is None:
+ max_id = self._top_message(controller, dialog)
+ if not max_id or max_id <= 0:
+ return False
+ if chat is not None and ChatObject.isChannel(chat):
+ request = TLRPC.TL_channels_readHistory()
+ request.channel = controller.getInputChannel(-dialog)
+ else:
+ request = TLRPC.TL_messages_readHistory()
+ request.peer = controller.getInputPeer(dialog)
+ request.max_id = int(max_id)
+ return self._send_reads(account, dialog, [request], controller, on_done)
+
+ def _send_reads(self, account, dialog, requests, controller, on_done):
+ if not requests:
+ return False
+ state = {"left": len(requests), "ok": True}
+
+ def done(response, error):
+ if error is None and response is not None:
+ pts = getattr(response, "pts", None)
+ if pts is not None and hasattr(response, "pts_count"):
+ controller.processNewDifferenceParams(-1, int(pts), -1, int(response.pts_count))
+ with self._lock:
+ state["left"] -= 1
+ state["ok"] = state["ok"] and error is None
+ finished = state["left"] == 0
+ if finished and on_done is not None:
+ on_done(state["ok"])
+
+ for request in requests:
+ self.allow_read(account, dialog)
+ self._send(account, request, done)
+ return True
+
+ @staticmethod
+ def _top_message(controller, dialog):
+ try:
+ entry = controller.dialogs_dict.get(dialog)
+ return int(entry.top_message) if entry is not None else 0
+ except Exception:
+ return 0
+
+ def send_offline(self, account):
+ from org.telegram.tgnet import TLRPC
+
+ request = TLRPC.TL_account_updateStatus()
+ request.offline = True
+ self._send(account, request, None)
+
+ def _send(self, account, request, done):
+ from client_utils import RequestCallback
+ from org.telegram.tgnet import ConnectionsManager
+
+ plugin = self.plugin
+
+ def callback(response, error):
+ if done is None:
+ return
+ try:
+ done(response, error)
+ except Exception as exc:
+ plugin.log(f"request callback failed: {exc}")
+
+ ConnectionsManager.getInstance(int(account)).sendRequest(request, RequestCallback(callback))
diff --git a/src/options_dialog.py b/src/options_dialog.py
new file mode 100644
index 0000000..6e13d8d
--- /dev/null
+++ b/src/options_dialog.py
@@ -0,0 +1,135 @@
+"""The ghost mode popups (chat and account options).
+
+A plain AlertDialog item list can't color part of a row, so the rows are
+TextViews in the dialog's custom view. Each row reads
+
+
+
+with the state colored: On green, Off red, and the "follow the level above"
+state (Inherit for a chat, Global for an account) gray. Tapping a row runs its
+action and redraws the rows in place; rows that leave the popup (Read all
+messages now) close it.
+"""
+
+from hooking import on_ui
+
+ON_STATE, OFF_STATE, INHERITED_STATE = "on", "off", "inherited"
+
+
+class Row:
+ __slots__ = ("emoji", "title", "state", "action", "closes")
+
+ def __init__(self, emoji, title, action, state=None, closes=False):
+ self.emoji = emoji
+ self.title = title
+ self.action = action
+ # ON_STATE / OFF_STATE / INHERITED_STATE, or None for a plain action
+ self.state = state
+ self.closes = closes
+
+
+def _theme_color(name, fallback):
+ try:
+ from org.telegram.ui.ActionBar import Theme
+
+ return int(Theme.getColor(getattr(Theme, name)))
+ except Exception:
+ # Java ints are signed: 0xAARRGGBB literals have to wrap around.
+ return fallback - (1 << 32) if fallback >= 1 << 31 else fallback
+
+
+def state_color(state):
+ if state == ON_STATE:
+ return _theme_color("key_color_green", 0xFF4CAF50)
+ if state == OFF_STATE:
+ return _theme_color("key_color_red", 0xFFE53935)
+ return _theme_color("key_dialogTextGray2", 0xFF8A8A8A)
+
+
+def label(row, inherited_word):
+ """(text, (start, end, color) or None) for a row."""
+ head = f"{row.emoji} "
+ if row.state is None:
+ return head + row.title, None
+ word = {ON_STATE: "On", OFF_STATE: "Off"}.get(row.state, inherited_word)
+ text = f"{head}{word} {row.title}"
+ return text, (len(head), len(head) + len(word), state_color(row.state))
+
+
+def _styled(text, span):
+ from android.graphics import Typeface
+ from android.text import SpannableStringBuilder, Spanned
+ from android.text.style import ForegroundColorSpan, StyleSpan
+
+ builder = SpannableStringBuilder(text)
+ if span is not None:
+ # Spans count UTF-16 units; emoji outside the BMP take two.
+ start, end, color = span
+ start16 = len(text[:start].encode("utf-16-le")) // 2
+ end16 = start16 + len(text[start:end].encode("utf-16-le")) // 2
+ flags = Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
+ builder.setSpan(ForegroundColorSpan(color), start16, end16, flags)
+ builder.setSpan(StyleSpan(Typeface.BOLD), start16, end16, flags)
+ return builder
+
+
+def show(plugin, activity, title, inherited_word, build_rows, on_close=None):
+ """Shows the popup; `build_rows()` returns the [Row]s, and is called
+ again after every tap."""
+ from android.text import TextUtils
+ from android.util import TypedValue
+ from android.view import Gravity
+ from android.widget import LinearLayout, ScrollView, TextView
+ from android_utils import OnClickListener
+ from org.telegram.messenger import AndroidUtilities
+ from org.telegram.ui.ActionBar import Theme
+ from ui.alert import AlertDialogBuilder
+
+ dp = AndroidUtilities.dp
+ text_color = _theme_color("key_dialogTextBlack", 0xFF000000)
+ selector = _theme_color("key_listSelector", 0x22000000)
+
+ scroll = ScrollView(activity)
+ container = LinearLayout(activity)
+ container.setOrientation(LinearLayout.VERTICAL)
+ container.setPadding(0, dp(4), 0, dp(4))
+ scroll.addView(container)
+ builder = AlertDialogBuilder(activity)
+
+ def render():
+ container.removeAllViews()
+ for row in build_rows():
+ view = TextView(activity)
+ view.setText(_styled(*label(row, inherited_word)))
+ view.setTextColor(text_color)
+ view.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 16.0)
+ view.setSingleLine(True)
+ view.setEllipsize(TextUtils.TruncateAt.END)
+ view.setGravity(Gravity.CENTER_VERTICAL | Gravity.START)
+ view.setMinHeight(dp(48))
+ view.setPadding(dp(24), dp(4), dp(24), dp(4))
+ view.setBackground(Theme.createSelectorDrawable(selector, 2))
+ view.setOnClickListener(OnClickListener(lambda _view, row=row: tapped(row)))
+ container.addView(view)
+
+ def tapped(row):
+ try:
+ row.action()
+ except Exception as exc:
+ plugin.log(f"ghost popup {row.title!r}: {exc}")
+ if row.closes:
+ builder.dismiss()
+ else:
+ on_ui(plugin, render)
+
+ def dismissed(_builder):
+ if on_close is not None:
+ on_close()
+
+ render()
+ builder.set_title(title)
+ builder.set_view(scroll)
+ builder.set_negative_button("Close", None)
+ builder.set_on_dismiss_listener(dismissed)
+ builder.show()
+ return builder
diff --git a/src/peers.py b/src/peers.py
new file mode 100644
index 0000000..8d81e72
--- /dev/null
+++ b/src/peers.py
@@ -0,0 +1,237 @@
+"""Accounts, dialog ids and names.
+
+Dialog ids follow the app: users are positive, basic groups and channels are
+negative (-chat_id / -channel_id), secret chats use DialogObject's encrypted
+range. Everything Java is imported lazily so the module loads off-device.
+
+Forum topics are not dialogs of their own: a topic belongs to its group's
+dialog, and topic ids only matter for which messages a read receipt covers.
+"""
+
+# The General topic of a forum; messages sent there carry no topic.
+GENERAL_TOPIC = 1
+
+
+def uid_of_slot(slot):
+ """The Telegram user id signed in at `slot`, else 0."""
+ from org.telegram.messenger import UserConfig
+
+ config = UserConfig.getInstance(int(slot))
+ if not config.isClientActivated():
+ return 0
+ return int(config.getClientUserId())
+
+
+def signed_in():
+ """[(slot, uid)] of every signed-in account."""
+ from org.telegram.messenger import UserConfig
+
+ out = []
+ for slot in range(UserConfig.MAX_ACCOUNT_COUNT):
+ try:
+ uid = uid_of_slot(slot)
+ except Exception:
+ continue
+ if uid:
+ out.append((slot, uid))
+ return out
+
+
+def slot_of_uid(uid):
+ for slot, signed in signed_in():
+ if signed == int(uid):
+ return slot
+ return None
+
+
+def account_name(slot):
+ from org.telegram.messenger import UserConfig, UserObject
+
+ user = UserConfig.getInstance(int(slot)).getCurrentUser()
+ if user is None:
+ return f"Account {int(slot) + 1}"
+ name = str(UserObject.getUserName(user) or "").strip()
+ username = getattr(user, "username", None)
+ if username:
+ return f"{name} (@{username})" if name else f"@{username}"
+ return name or f"Account {int(slot) + 1}"
+
+
+def selected_slot():
+ from org.telegram.messenger import UserConfig
+
+ return int(UserConfig.selectedAccount)
+
+
+def _int(obj, name):
+ try:
+ return int(getattr(obj, name, 0) or 0)
+ except (TypeError, ValueError):
+ return 0
+
+
+def dialog_of_input_peer(peer, self_uid=0):
+ """Dialog id of a TLRPC.InputPeer (inputPeerSelf is `self_uid`)."""
+ if peer is None:
+ return None
+ user_id = _int(peer, "user_id")
+ if user_id:
+ return user_id
+ chat_id = _int(peer, "chat_id")
+ if chat_id:
+ return -chat_id
+ channel_id = _int(peer, "channel_id")
+ if channel_id:
+ return -channel_id
+ if "inputPeerSelf" in _class_name(peer):
+ return self_uid or None
+ return None
+
+
+def dialog_of_input_channel(channel):
+ channel_id = _int(channel, "channel_id")
+ return -channel_id if channel_id else None
+
+
+def dialog_of_encrypted(encrypted_chat):
+ """Dialog id of a TLRPC.InputEncryptedChat."""
+ chat_id = _int(encrypted_chat, "chat_id")
+ if not chat_id:
+ return None
+ from org.telegram.messenger import DialogObject
+
+ return int(DialogObject.makeEncryptedDialogId(chat_id))
+
+
+def dialog_of_peer(peer):
+ """Dialog id of a TLRPC.Peer."""
+ if peer is None:
+ return None
+ user_id = _int(peer, "user_id")
+ if user_id:
+ return user_id
+ chat_id = _int(peer, "chat_id")
+ if chat_id:
+ return -chat_id
+ channel_id = _int(peer, "channel_id")
+ return -channel_id if channel_id else None
+
+
+def is_encrypted(dialog_id):
+ from org.telegram.messenger import DialogObject
+
+ return bool(DialogObject.isEncryptedDialog(int(dialog_id)))
+
+
+def is_bot(slot, dialog_id):
+ if not dialog_id or int(dialog_id) <= 0:
+ return False
+ from org.telegram.messenger import MessagesController
+
+ user = MessagesController.getInstance(int(slot)).getUser(int(dialog_id))
+ return bool(user is not None and getattr(user, "bot", False))
+
+
+def is_forum(chat):
+ return bool(chat is not None and getattr(chat, "forum", False))
+
+
+def topic_top_message(controller, chat_id, topic_id):
+ """The newest message id of a forum topic, else 0."""
+ try:
+ topic = controller.getTopicsController().findTopic(int(chat_id), int(topic_id))
+ except Exception:
+ return 0
+ return int(getattr(topic, "top_message", 0) or 0) if topic is not None else 0
+
+
+def forum_topics(controller, chat_id):
+ """[(topic id, newest message id)] of a forum's loaded topics."""
+ try:
+ topics = controller.getTopicsController().getTopics(int(chat_id))
+ except Exception:
+ return []
+ if topics is None:
+ return []
+ out = []
+ for i in range(topics.size()):
+ topic = topics.get(i)
+ out.append((int(topic.id), int(getattr(topic, "top_message", 0) or 0)))
+ return out
+
+
+def request_topic(request):
+ """The forum topic a send request goes to, else 0 (General or no forum)."""
+ top = _int(request, "top_msg_id") # messages.forwardMessages
+ if top:
+ return top
+ reply_to = getattr(request, "reply_to", None)
+ if reply_to is None:
+ return 0
+ return _int(reply_to, "top_msg_id") or 0
+
+
+def message_topic(message):
+ """The forum topic of a MessageObject (General when it has none)."""
+ owner = getattr(message, "messageOwner", None)
+ try:
+ from org.telegram.messenger import MessageObject
+
+ topic = int(MessageObject.getTopicId(int(message.currentAccount), owner, True))
+ if topic:
+ return topic
+ except Exception:
+ pass
+ if "TopicCreate" in _class_name(getattr(owner, "action", None)):
+ return int(message.getId())
+ reply_to = getattr(owner, "reply_to", None)
+ if reply_to is not None and getattr(reply_to, "forum_topic", False):
+ return _int(reply_to, "reply_to_top_id") or _int(reply_to, "reply_to_msg_id") or GENERAL_TOPIC
+ return GENERAL_TOPIC
+
+
+def open_chat(slot=None):
+ """(account, dialog id) of the chat on screen, else (None, None)."""
+ from client_utils import get_last_fragment
+
+ fragment = get_last_fragment()
+ if fragment is None or _class_name(fragment) != "ChatActivity":
+ return None, None
+ account = int(fragment.getCurrentAccount())
+ if slot is not None and account != int(slot):
+ return None, None
+ return account, int(fragment.getDialogId())
+
+
+def dialog_title(slot, dialog_id):
+ """A readable name for `dialog_id` as account `slot` sees it."""
+ from org.telegram.messenger import DialogObject, MessagesController, UserObject
+
+ controller = MessagesController.getInstance(int(slot))
+ dialog_id = int(dialog_id)
+ try:
+ if DialogObject.isEncryptedDialog(dialog_id):
+ encrypted = controller.getEncryptedChat(DialogObject.getEncryptedChatId(dialog_id))
+ user = controller.getUser(encrypted.user_id) if encrypted is not None else None
+ if user is not None:
+ return "🔒 " + str(UserObject.getUserName(user))
+ elif dialog_id > 0:
+ user = controller.getUser(dialog_id)
+ if user is not None:
+ if UserObject.isUserSelf(user):
+ return "Saved Messages"
+ return str(UserObject.getUserName(user))
+ else:
+ chat = controller.getChat(-dialog_id)
+ if chat is not None and chat.title:
+ return str(chat.title)
+ except Exception:
+ pass
+ return f"Chat {dialog_id}"
+
+
+def _class_name(obj):
+ try:
+ return str(obj.getClass().getSimpleName())
+ except Exception:
+ return type(obj).__name__
diff --git a/src/prefs.py b/src/prefs.py
new file mode 100644
index 0000000..be8549d
--- /dev/null
+++ b/src/prefs.py
@@ -0,0 +1,186 @@
+"""Where ghost mode options are stored and how a value is decided.
+
+Three levels, the most specific one that has a value wins:
+
+ chat per account, per chat (chat options only, see features.CHAT)
+ account per account (any option)
+ global the plugin's own switches (always has a value)
+
+Accounts are keyed by their Telegram user id, not by their slot in the app:
+slots move when an account is logged out, user ids don't.
+
+Storage, all in the plugin's settings:
+
+ g_ bool global switch
+ a__ int account choice: INHERIT / ON / OFF
+ (the index of a settings Selector)
+ c_ JSON string {"": {"": bool}}
+"""
+
+import json
+import threading
+
+import features
+
+INHERIT, ON, OFF = 0, 1, 2
+
+_CHOICE_TO_VALUE = {ON: True, OFF: False}
+
+
+def global_key(feature):
+ return f"g_{feature.key}"
+
+
+def account_key(uid, feature):
+ return f"a_{uid}_{feature.key}"
+
+
+def chats_key(uid):
+ return f"c_{uid}"
+
+
+class Prefs:
+ def __init__(self, plugin, uid_of_slot):
+ self.plugin = plugin
+ # slot -> Telegram user id (0 when the slot isn't signed in)
+ self.uid_of_slot = uid_of_slot
+ self._lock = threading.Lock()
+ # c_ raw JSON -> parsed dict, so hooks don't re-parse on every request
+ self._parsed = {}
+
+ # global
+
+ def global_value(self, feature):
+ return bool(self.plugin.get_setting(global_key(feature), feature.default))
+
+ def set_global(self, feature, value, reload_settings=False):
+ self.plugin.set_setting(global_key(feature), bool(value), reload_settings)
+
+ # account
+
+ def account_choice(self, uid, feature):
+ """INHERIT, ON or OFF."""
+ try:
+ choice = int(self.plugin.get_setting(account_key(uid, feature), INHERIT) or INHERIT)
+ except (TypeError, ValueError):
+ return INHERIT
+ return choice if choice in (ON, OFF) else INHERIT
+
+ def set_account_choice(self, uid, feature, choice, reload_settings=False):
+ self.plugin.set_setting(account_key(uid, feature), int(choice), reload_settings)
+
+ def account_overrides(self, uid):
+ """{feature key: bool} of the options this account overrides."""
+ out = {}
+ for feature in features.ALL:
+ choice = self.account_choice(uid, feature)
+ if choice != INHERIT:
+ out[feature.key] = _CHOICE_TO_VALUE[choice]
+ return out
+
+ # chats
+
+ def chat_overrides(self, uid):
+ """{dialog id: {feature key: bool}} for every chat with an override."""
+ raw = self.plugin.get_setting(chats_key(uid), "") or ""
+ with self._lock:
+ cached = self._parsed.get(uid)
+ if cached is not None and cached[0] == raw:
+ return cached[1]
+ parsed = {}
+ try:
+ data = json.loads(raw) if raw else {}
+ except ValueError:
+ data = {}
+ if isinstance(data, dict):
+ for dialog, values in data.items():
+ try:
+ dialog_id = int(dialog)
+ except (TypeError, ValueError):
+ continue
+ if not isinstance(values, dict):
+ continue
+ clean = {
+ k: bool(v)
+ for k, v in values.items()
+ if k in features.BY_KEY and features.BY_KEY[k].chat
+ }
+ if clean:
+ parsed[dialog_id] = clean
+ with self._lock:
+ self._parsed[uid] = (raw, parsed)
+ return parsed
+
+ def chat_choice(self, uid, dialog_id, feature):
+ value = self.chat_overrides(uid).get(int(dialog_id), {}).get(feature.key)
+ if value is None:
+ return INHERIT
+ return ON if value else OFF
+
+ def set_chat_choice(self, uid, dialog_id, feature, choice, reload_settings=False):
+ self.set_chat_choices(uid, dialog_id, {feature: choice}, reload_settings)
+
+ def set_chat_choices(self, uid, dialog_id, choices, reload_settings=False):
+ """Sets several {feature: INHERIT/ON/OFF} for one chat at once."""
+ overrides = {d: dict(v) for d, v in self.chat_overrides(uid).items()}
+ values = overrides.setdefault(int(dialog_id), {})
+ for feature, choice in choices.items():
+ if not feature.chat:
+ raise ValueError(f"{feature.key} can't be set per chat")
+ if choice == INHERIT:
+ values.pop(feature.key, None)
+ else:
+ values[feature.key] = choice == ON
+ if not values:
+ overrides.pop(int(dialog_id), None)
+ self._save_chats(uid, overrides, reload_settings)
+
+ def clear_chat(self, uid, dialog_id, reload_settings=False):
+ overrides = dict(self.chat_overrides(uid))
+ if overrides.pop(int(dialog_id), None) is not None:
+ self._save_chats(uid, overrides, reload_settings)
+
+ def clear_chats(self, uid, reload_settings=False):
+ self._save_chats(uid, {}, reload_settings)
+
+ def _save_chats(self, uid, overrides, reload_settings=False):
+ raw = json.dumps(
+ {str(d): v for d, v in sorted(overrides.items()) if v},
+ sort_keys=True,
+ separators=(",", ":"),
+ )
+ self.plugin.set_setting(chats_key(uid), "" if raw == "{}" else raw, reload_settings)
+
+ # resolution
+
+ def resolve(self, uid, feature, dialog_id=None):
+ """(value, level) where level is "chat", "account" or "global"."""
+ if uid:
+ if dialog_id and feature.chat:
+ value = self.chat_overrides(uid).get(int(dialog_id), {}).get(feature.key)
+ if value is not None:
+ return value, "chat"
+ choice = self.account_choice(uid, feature)
+ if choice != INHERIT:
+ return _CHOICE_TO_VALUE[choice], "account"
+ return self.global_value(feature), "global"
+
+ def resolve_parent(self, uid, feature, dialog_id=None):
+ """The value a chat (with `dialog_id`) or an account (without) would
+ have if it didn't override `feature`."""
+ if dialog_id and uid:
+ return self.resolve(uid, feature)
+ return self.resolve(0, feature)
+
+ def enabled(self, slot, feature, dialog_id=None):
+ """Whether `feature` is on for account `slot` (in `dialog_id`)."""
+ try:
+ uid = int(self.uid_of_slot(slot) or 0) if slot is not None else 0
+ except Exception:
+ uid = 0
+ return self.resolve(uid, feature, dialog_id)[0]
+
+ def ghost_active(self, uid, dialog_id=None):
+ """AyuGram's "ghost mode is on": every essential that applies is on."""
+ relevant = [f for f in features.ESSENTIALS if f.chat or not dialog_id]
+ return all(self.resolve(uid, f, dialog_id)[0] for f in relevant)
diff --git a/src/sending.py b/src/sending.py
new file mode 100644
index 0000000..2114007
--- /dev/null
+++ b/src/sending.py
@@ -0,0 +1,243 @@
+"""The sending side of ghost mode: Use scheduled messages and Send without
+sound, after AyuGram's hooks in SendMessagesHelper.
+
+A message scheduled a few seconds ahead is sent by Telegram's servers, so
+sending it doesn't put you online. Telegram sends anything scheduled less
+than 10 seconds ahead immediately, so messages get 12 seconds, and media
+more, since it has to finish uploading before the date (as in AyuGram).
+
+Text and media go through SendMessagesHelper.sendMessage(SendMessageParams),
+which the SDK's send-message hook sees; forwards go through
+sendMessage(ArrayList, long peer, boolean forwardFromMyName,
+boolean hideCaption, boolean notify, int scheduleDate, ...), hooked here.
+
+ChatActivity opens the scheduled messages screen when a scheduled message
+shows up in the chat (didReceivedNotification(didReceiveNewMessages), unless
+the chat is paused); for the messages scheduled here it stays put instead:
+the chat is marked paused for the duration of that one call. (setResult() in
+a before-hook doesn't skip the original method in this runtime, so
+openScheduledMessages() itself can't be blocked.)
+
+SendMessageParams.notify clashes with Object.notify() in Chaquopy, so it is
+read and written through hook_utils.
+"""
+
+import threading
+import time
+
+from base_plugin import MethodHook
+
+import features
+import peers
+from hooking import field, hook_all, on_ui, unhook_all
+
+SCHEDULE_AHEAD = 12
+PHOTO_EXTRA = 10
+DOCUMENT_EXTRA = 15
+# Parts of one album are sent one by one and must share their date.
+_SHARED_DATE_SECONDS = 3.0
+# How long after scheduling the chat may want to open the scheduled screen.
+_QUIET_SECONDS = 5.0
+
+
+def _boxed_int(value):
+ """A Java Integer: a plain Python int put into the Object[] of hooked
+ arguments would become a Long, which an int parameter doesn't take."""
+ try:
+ from java.lang import Integer
+ except ImportError:
+ return int(value)
+ return Integer.valueOf(int(value))
+
+
+def _boxed_bool(value):
+ try:
+ from java.lang import Boolean
+ except ImportError:
+ return bool(value)
+ return Boolean.TRUE if value else Boolean.FALSE
+
+
+class Sending:
+ def __init__(self, plugin, prefs):
+ self.plugin = plugin
+ self.prefs = prefs
+ self.handles = []
+ self._lock = threading.Lock()
+ self._dates = {} # (account, dialog) -> (monotonic time, schedule date)
+ self._quiet = {} # (account, dialog) -> monotonic time the chat stays put until
+ self._forwarded = {} # (account, dialog) -> monotonic time of a scheduled forward
+ # ChatActivity paused by the notification hook, to unpause afterwards.
+ # Notifications are delivered on the UI thread one at a time.
+ self._paused = None
+ self._new_messages_id = None
+
+ def install(self):
+ sending = self
+
+ class ForwardHook(MethodHook):
+ def before_hooked_method(self, param):
+ try:
+ sending._on_forward(param)
+ except Exception as exc:
+ sending.plugin.log(f"forward hook: {exc}")
+
+ class NotificationHook(MethodHook):
+ def before_hooked_method(self, param):
+ try:
+ sending._before_notification(param)
+ except Exception as exc:
+ sending.plugin.log(f"scheduled screen hook: {exc}")
+
+ def after_hooked_method(self, param):
+ try:
+ sending._after_notification()
+ except Exception as exc:
+ sending.plugin.log(f"scheduled screen hook: {exc}")
+
+ class AfterForwardHook(MethodHook):
+ def after_hooked_method(self, param):
+ try:
+ sending._after_chat_forward(param.thisObject)
+ except Exception as exc:
+ sending.plugin.log(f"forward panel hook: {exc}")
+
+ self.handles += hook_all(
+ self.plugin, "org.telegram.messenger.SendMessagesHelper", "sendMessage", ForwardHook()
+ )
+ self.handles += hook_all(
+ self.plugin, "org.telegram.ui.ChatActivity", "didReceivedNotification", NotificationHook()
+ )
+ self.handles += hook_all(
+ self.plugin, "org.telegram.ui.ChatActivity", "forwardMessages", AfterForwardHook()
+ )
+ return len(self.handles)
+
+ def uninstall(self):
+ unhook_all(self.plugin, self.handles)
+
+ # SendMessageParams
+
+ def on_send_message(self, account, params):
+ """Adjusts the SDK's SendMessageParams; True when something changed."""
+ dialog = int(getattr(params, "peer", 0) or 0)
+ if not dialog:
+ return False
+ changed = False
+ if self.prefs.enabled(account, features.SILENT, dialog):
+ from hook_utils import get_private_field, set_private_field
+
+ # Plain attribute access would find Object.notify() instead.
+ if get_private_field(params, "notify") is True:
+ set_private_field(params, "notify", False)
+ changed = True
+ if int(getattr(params, "scheduleDate", 0) or 0) == 0 and self._may_schedule(account, dialog, params):
+ extra = 0
+ if getattr(params, "document", None) is not None:
+ extra = DOCUMENT_EXTRA
+ elif getattr(params, "photo", None) is not None:
+ extra = PHOTO_EXTRA
+ params.scheduleDate = self._schedule_date(account, dialog, extra)
+ changed = True
+ return changed
+
+ def _may_schedule(self, account, dialog, params=None):
+ if not self.prefs.enabled(account, features.SCHEDULED, dialog):
+ return False
+ if params is not None:
+ # Quick reply shortcuts and paid messages can't be scheduled.
+ if getattr(params, "quick_reply_shortcut", None) or getattr(params, "quick_reply_shortcut_id", 0):
+ return False
+ if getattr(params, "payStars", 0):
+ return False
+ if peers.is_encrypted(dialog):
+ return False
+ if dialog == peers.uid_of_slot(account):
+ return False # Saved Messages: that would be a reminder
+ if peers.is_bot(account, dialog):
+ return False
+ return True
+
+ def _schedule_date(self, account, dialog, extra):
+ from org.telegram.tgnet import ConnectionsManager
+
+ now = int(ConnectionsManager.getInstance(int(account)).getCurrentTime())
+ date = now + SCHEDULE_AHEAD + extra
+ key = (int(account), int(dialog))
+ clock = time.monotonic()
+ with self._lock:
+ last = self._dates.get(key)
+ if last is not None and clock - last[0] < _SHARED_DATE_SECONDS and last[1] >= now + 10:
+ date = max(date, last[1]) if extra else last[1]
+ self._dates[key] = (clock, date)
+ self._quiet[key] = clock + _QUIET_SECONDS
+ return date
+
+ # forwards
+
+ def _on_forward(self, param):
+ from java.util import ArrayList
+
+ args = param.args
+ if len(args) < 6 or not isinstance(args[0], ArrayList) or args[0].isEmpty():
+ return
+ account = int(field(param.thisObject, "currentAccount", 0))
+ dialog = int(args[1])
+ if self.prefs.enabled(account, features.SILENT, dialog) and bool(args[4]):
+ args[4] = _boxed_bool(False)
+ if int(args[5]) == 0 and self._may_schedule(account, dialog):
+ args[5] = _boxed_int(self._schedule_date(account, dialog, 0))
+ with self._lock:
+ self._forwarded[(account, dialog)] = time.monotonic()
+
+ def _after_chat_forward(self, chat):
+ """ChatActivity waits for forwarded messages to show up in the chat
+ before it hides the forward panel; scheduled ones never do."""
+ key = (int(chat.getCurrentAccount()), int(chat.getDialogId()))
+ with self._lock:
+ at = self._forwarded.pop(key, None)
+ if at is None or time.monotonic() - at > _QUIET_SECONDS:
+ return
+
+ def hide():
+ from hook_utils import set_private_field
+
+ set_private_field(chat, "waitingForSendingMessageLoad", False)
+ chat.hideFieldPanel(True)
+
+ on_ui(self.plugin, hide)
+
+ def _before_notification(self, param):
+ if self._new_messages_id is None:
+ from org.telegram.messenger import NotificationCenter
+
+ self._new_messages_id = int(NotificationCenter.didReceiveNewMessages)
+ args = param.args
+ if len(args) < 3 or int(args[0]) != self._new_messages_id:
+ return
+ chat = param.thisObject
+ payload = args[2]
+ # (dialog id, messages, scheduled, ...)
+ if payload is None or len(payload) < 3 or not bool(payload[2]):
+ return
+ if int(payload[0]) != int(chat.getDialogId()) or not self._stay_in_chat(chat):
+ return
+ if field(chat, "isPaused", False) is True:
+ return
+ from hook_utils import set_private_field
+
+ if set_private_field(chat, "isPaused", True):
+ self._paused = chat
+
+ def _after_notification(self):
+ chat, self._paused = self._paused, None
+ if chat is not None:
+ from hook_utils import set_private_field
+
+ set_private_field(chat, "isPaused", False)
+
+ def _stay_in_chat(self, chat):
+ key = (int(chat.getCurrentAccount()), int(chat.getDialogId()))
+ with self._lock:
+ until = self._quiet.get(key)
+ return until is not None and time.monotonic() < until
diff --git a/src/settings_page.py b/src/settings_page.py
new file mode 100644
index 0000000..01722eb
--- /dev/null
+++ b/src/settings_page.py
@@ -0,0 +1,186 @@
+"""The plugin's settings screen.
+
+ main page global ghost mode switch, the option switches, the accounts
+ account page that account's choice per option (Global / On / Off) and
+ its chat exceptions
+"""
+
+from ui.settings import Divider, Header, Selector, Switch, Text
+
+import features
+import peers
+from prefs import INHERIT, OFF, ON, account_key, chats_key, global_key
+
+GHOST_KEY = "g_ghost"
+GHOST_ICON = "ghost"
+
+
+def _state(value):
+ return "On" if value else "Off"
+
+
+class SettingsPage:
+ def __init__(self, plugin, prefs, menus):
+ self.plugin = plugin
+ self.prefs = prefs
+ self.menus = menus
+
+ # main page
+
+ def build(self):
+ prefs = self.prefs
+ # The master switch shows whether every essential is on (AyuGram's
+ # isGhostModeActive), whichever way they got there.
+ self.plugin.set_setting(GHOST_KEY, prefs.ghost_active(0))
+ rows = [
+ Header(text="Ghost mode"),
+ Switch(
+ key=GHOST_KEY,
+ text="Ghost mode",
+ default=False,
+ subtext="Turns all ghost essentials on or off",
+ icon=GHOST_ICON,
+ on_change=self._on_ghost_change,
+ ),
+ Header(text="Ghost essentials"),
+ ]
+ rows += [self._switch(f) for f in features.ESSENTIALS]
+ rows.append(Header(text="Options"))
+ rows += [self._switch(f) for f in features.EXTRAS]
+ rows.append(Divider(text="These are the defaults. Each account can override them, and each chat can override its account: open the chat's ⋮ menu → Ghost mode."))
+ rows.append(Header(text="Accounts"))
+ accounts = self._accounts()
+ for slot, uid in accounts:
+ rows.append(
+ Text(
+ text=peers.account_name(slot),
+ subtext=self._account_summary(uid),
+ icon="msg_contacts",
+ create_sub_fragment=lambda slot=slot, uid=uid: self.build_account(slot, uid),
+ )
+ )
+ if not accounts:
+ rows.append(Text(text="No signed-in accounts", icon="msg_info"))
+ rows.append(Divider(text="The side menu's Ghost mode item opens these options for the account in use."))
+ return rows
+
+ def _switch(self, feature):
+ return Switch(
+ key=global_key(feature),
+ text=feature.title,
+ default=feature.default,
+ subtext=feature.subtext,
+ icon=feature.icon,
+ on_change=self._on_essential_change if feature.essential else None,
+ )
+
+ def _on_ghost_change(self, enabled):
+ for feature in features.ESSENTIALS:
+ self.prefs.set_global(feature, enabled)
+ self.plugin.set_setting(GHOST_KEY, bool(enabled), True)
+
+ def _on_essential_change(self, _value):
+ active = self.prefs.ghost_active(0)
+ if bool(self.plugin.get_setting(GHOST_KEY, False)) != active:
+ self.plugin.set_setting(GHOST_KEY, active, True)
+
+ def _accounts(self):
+ try:
+ return peers.signed_in()
+ except Exception as exc:
+ self.plugin.log(f"accounts: {exc}")
+ return []
+
+ def _account_summary(self, uid):
+ overridden = len(self.prefs.account_overrides(uid))
+ chats = len(self.prefs.chat_overrides(uid))
+ parts = [f"Ghost mode {_state(self.prefs.ghost_active(uid)).lower()}"]
+ parts.append(f"{overridden} own option{'s' if overridden != 1 else ''}" if overridden else "global options")
+ if chats:
+ parts.append(f"{chats} chat exception{'s' if chats != 1 else ''}")
+ return " · ".join(parts)
+
+ # account page
+
+ def build_account(self, slot, uid):
+ prefs = self.prefs
+ ghost_key = f"a_{uid}_ghost"
+ self.plugin.set_setting(ghost_key, self._ghost_choice(uid))
+ rows = [
+ Header(text=peers.account_name(slot)),
+ Selector(
+ key=ghost_key,
+ text="Ghost mode",
+ default=INHERIT,
+ items=["Global", "On", "Off"],
+ icon=GHOST_ICON,
+ on_change=lambda choice: self._on_account_ghost_change(uid, choice),
+ ),
+ Header(text="Options"),
+ ]
+ for feature in features.ALL:
+ global_value = prefs.global_value(feature)
+ rows.append(
+ Selector(
+ key=account_key(uid, feature),
+ text=feature.title,
+ default=INHERIT,
+ items=[f"Global ({_state(global_value)})", "On", "Off"],
+ icon=feature.icon,
+ on_change=lambda _choice: self._refresh_account_ghost(uid),
+ )
+ )
+ rows.append(Divider(text="Global follows the plugin's main switches."))
+ rows.append(Header(text="Chat exceptions"))
+ chats = prefs.chat_overrides(uid)
+ for dialog, values in sorted(chats.items(), key=lambda item: peers.dialog_title(slot, item[0]).lower()):
+ rows.append(
+ Text(
+ text=peers.dialog_title(slot, dialog),
+ subtext=" · ".join(
+ f"{features.BY_KEY[k].title}: {_state(v)}" for k, v in values.items()
+ ),
+ icon="msg_discussion",
+ on_click=lambda _view, dialog=dialog: self._edit_chat(slot, uid, dialog),
+ )
+ )
+ if chats:
+ rows.append(
+ Text(
+ text="Remove all chat exceptions",
+ icon="msg_delete",
+ red=True,
+ on_click=lambda _view: prefs.clear_chats(uid, True),
+ )
+ )
+ else:
+ rows.append(Text(text="No chat exceptions", icon="msg_info"))
+ rows.append(Divider(text="Add one from a chat's ⋮ menu → Ghost mode. Tap an exception to change it."))
+ return rows
+
+ def _ghost_choice(self, uid):
+ """What the account's Ghost mode selector shows."""
+ choices = {self.prefs.account_choice(uid, f) for f in features.ESSENTIALS}
+ if choices == {INHERIT}:
+ return INHERIT
+ return ON if self.prefs.ghost_active(uid) else OFF
+
+ def _on_account_ghost_change(self, uid, choice):
+ for feature in features.ESSENTIALS:
+ self.prefs.set_account_choice(uid, feature, choice)
+ self.plugin.set_setting(f"a_{uid}_ghost", int(choice), True)
+
+ def _refresh_account_ghost(self, uid):
+ key = f"a_{uid}_ghost"
+ choice = self._ghost_choice(uid)
+ if self.plugin.get_setting(key, INHERIT) != choice:
+ self.plugin.set_setting(key, choice, True)
+
+ def _edit_chat(self, slot, uid, dialog):
+ # Reload the page once the dialog closes, so the list shows the result.
+ raw_key = chats_key(uid)
+
+ def closed():
+ self.plugin.set_setting(raw_key, self.plugin.get_setting(raw_key, ""), True)
+
+ self.menus.open_chat_dialog(slot, dialog, closed)
diff --git a/src/textutil.py b/src/textutil.py
deleted file mode 100644
index 7c3107b..0000000
--- a/src/textutil.py
+++ /dev/null
@@ -1,2 +0,0 @@
-def normalize(name: str) -> str:
- return " ".join(w[:1].upper() + w[1:] for w in name.split())
diff --git a/tools/verify_bundle.py b/tools/verify_bundle.py
index 2dbc171..6666cd6 100755
--- a/tools/verify_bundle.py
+++ b/tools/verify_bundle.py
@@ -1,6 +1,10 @@
#!/usr/bin/env python3
"""Offline verification harness for a built .plugin bundle. Runs without a phone.
+Executes the bundle against faked SDK modules and faked Telegram classes, then
+drives the plugin's hooks the way the app would and checks what reaches the
+network.
+
Usage: python3 tools/verify_bundle.py
"""
from __future__ import annotations
@@ -8,7 +12,6 @@ from __future__ import annotations
import ast
import json
import sys
-import traceback
import types
from dataclasses import dataclass
from enum import Enum, auto
@@ -31,6 +34,9 @@ def check(name: str, condition: bool, detail: str = "") -> None:
_FAILURES.append(name)
+# --- SDK fakes ---------------------------------------------------------------
+
+
class HookStrategy(Enum):
DEFAULT = auto()
MODIFY = auto()
@@ -41,10 +47,47 @@ class HookStrategy(Enum):
@dataclass
class HookResult:
strategy: HookStrategy = HookStrategy.DEFAULT
+ request: object = None
+ response: object = None
+ update: object = None
+ updates: object = None
+ error: object = None
params: object = None
+class MenuItemType(Enum):
+ MESSAGE_CONTEXT_MENU = auto()
+ DRAWER_MENU = auto()
+ MAIN_MENU = auto()
+ CHAT_ACTION_MENU = auto()
+ PROFILE_ACTION_MENU = auto()
+
+
+@dataclass
+class MenuItemData:
+ menu_type: MenuItemType
+ text: str
+ on_click: object
+ item_id: str = None
+ icon: str = None
+ subtext: str = None
+ condition: str = None
+ priority: int = 0
+
+
+class MethodHook:
+ def before_hooked_method(self, param):
+ pass
+
+ def after_hooked_method(self, param):
+ pass
+
+
LOG_LINES: list[str] = []
+HOOKED: dict[tuple[str, str], object] = {}
+ADDED_HOOKS: list[str] = []
+MENU_ITEMS: list[MenuItemData] = []
+SETTINGS_RELOADS: list[str] = []
class BasePlugin:
@@ -54,24 +97,44 @@ class BasePlugin:
def get_setting(self, key, default=None):
return self._settings.get(key, default)
- def set_setting(self, key, value):
+ def set_setting(self, key, value, reload_settings=False):
self._settings[key] = value
+ if reload_settings:
+ SETTINGS_RELOADS.append(key)
- def add_on_send_message_hook(self):
+ def add_hook(self, name, match_substring=False, priority=0):
+ ADDED_HOOKS.append(name)
+
+ def remove_hook(self, name):
+ ADDED_HOOKS.remove(name)
+
+ def add_on_send_message_hook(self, priority=0):
pass
- def on_plugin_load(self):
+ def hook_all_methods(self, hook_class, method_name, xposed_hook=None, priority=None, **kwargs):
+ HOOKED[(hook_class.getName(), method_name)] = xposed_hook
+ return [object()]
+
+ def unhook_method(self, unhook):
pass
- def on_plugin_unload(self):
- pass
+ def add_menu_item(self, item):
+ MENU_ITEMS.append(item)
+ return f"item{len(MENU_ITEMS)}"
- def create_settings(self):
- return []
+ def remove_menu_item(self, item_id):
+ return True
+
+ def log(self, message):
+ LOG_LINES.append(str(message))
-def fake_log(*args, **kwargs):
- LOG_LINES.append(" ".join(str(a) for a in args))
+class FakeJavaClass:
+ def __init__(self, name):
+ self.name = name
+
+ def getName(self):
+ return self.name
class _SettingsRow:
@@ -79,31 +142,510 @@ class _SettingsRow:
self.__dict__.update(kwargs)
+BULLETINS: list[str] = []
+DIALOGS: list["FakeAlertDialogBuilder"] = []
+
+
+class FakeAlertDialogBuilder:
+ def __init__(self, context, *args, **kwargs):
+ self.view = None
+ self.dismiss_listener = None
+ self.title = None
+ self.dismissed = False
+ DIALOGS.append(self)
+
+ def set_title(self, title):
+ self.title = title
+ return self
+
+ def set_view(self, view, height=-2):
+ self.view = view
+ return self
+
+ def set_negative_button(self, text, listener=None):
+ return self
+
+ def set_on_dismiss_listener(self, listener=None):
+ self.dismiss_listener = listener
+ return self
+
+ def show(self):
+ return self
+
+ def dismiss(self):
+ self.dismissed = True
+ if self.dismiss_listener is not None:
+ self.dismiss_listener(self)
+
+ @property
+ def rows(self):
+ return self.view.children[0].children
+
+ @property
+ def items(self):
+ return [str(r.text) for r in self.rows]
+
+ def find(self, title):
+ return next(i for i, text in enumerate(self.items) if text.endswith(title))
+
+ def click(self, index):
+ self.rows[index].listener.onClick(None)
+
+
+# --- Android view fakes ------------------------------------------------------
+
+
+class FakeSpannable:
+ def __init__(self, text):
+ self.text = text
+ self.spans = [] # (span, start, end)
+
+ def setSpan(self, span, start, end, flags):
+ self.spans.append((span, start, end))
+
+ def __str__(self):
+ return self.text
+
+ def colored(self):
+ """[(utf-16 slice text, color)] of the color spans."""
+ units = self.text.encode("utf-16-le")
+ return [
+ (units[2 * a : 2 * b].decode("utf-16-le"), span.color)
+ for span, a, b in self.spans
+ if hasattr(span, "color")
+ ]
+
+
+class FakeView:
+ def __init__(self, context=None):
+ self.children = []
+ self.text = None
+ self.listener = None
+
+ def addView(self, view, *args):
+ self.children.append(view)
+
+ def removeAllViews(self):
+ self.children = []
+
+ def setText(self, text):
+ self.text = text
+
+ def setOnClickListener(self, listener):
+ self.listener = listener
+
+ def __getattr__(self, name): # setPadding, setTextColor, setGravity, ...
+ if name.startswith("set"):
+ return lambda *args: None
+ raise AttributeError(name)
+
+
+GREEN, RED, GRAY = 0x11, 0x22, 0x33
+
+
+class FakeTheme:
+ key_color_green, key_color_red, key_dialogTextGray2 = "green", "red", "gray"
+ key_dialogTextBlack, key_listSelector = "black", "selector"
+ COLORS = {"green": GREEN, "red": RED, "gray": GRAY, "black": 0, "selector": 0}
+
+ @classmethod
+ def getColor(cls, key):
+ return cls.COLORS[key]
+
+ @staticmethod
+ def createSelectorDrawable(color, mask):
+ return object()
+
+
+class FakeDrawable:
+ def __init__(self, size):
+ self.size = size
+
+ def getIntrinsicWidth(self):
+ return self.size
+
+ def getIntrinsicHeight(self):
+ return self.size
+
+
+class FakeLayerDrawable(FakeDrawable):
+ def __init__(self, layers):
+ self.layers = list(layers)
+ self.size = None
+
+ def setLayerSize(self, index, width, height):
+ self.size = width
+
+
+class FakeImageView:
+ def __init__(self, drawable):
+ self.drawable = drawable
+
+ def getDrawable(self):
+ return self.drawable
+
+ def setImageDrawable(self, drawable):
+ self.drawable = drawable
+
+
+# --- Telegram fakes ----------------------------------------------------------
+
+
+class JavaObject:
+ def getClass(self):
+ name = type(self).__name__
+ return types.SimpleNamespace(getSimpleName=lambda: name)
+
+
+def _tl(name, **defaults):
+ def __init__(self, **kwargs):
+ self.__dict__.update(defaults)
+ self.__dict__.update(kwargs)
+
+ return type(name, (JavaObject,), {"__init__": __init__})
+
+
+TL_inputPeerUser = _tl("TL_inputPeerUser", user_id=0, chat_id=0, channel_id=0)
+TL_inputPeerChat = _tl("TL_inputPeerChat", user_id=0, chat_id=0, channel_id=0)
+TL_inputPeerChannel = _tl("TL_inputPeerChannel", user_id=0, chat_id=0, channel_id=0)
+TL_inputPeerSelf = _tl("TL_inputPeerSelf", user_id=0, chat_id=0, channel_id=0)
+TL_inputChannel = _tl("TL_inputChannel", channel_id=0)
+TL_inputEncryptedChat = _tl("TL_inputEncryptedChat", chat_id=0)
+
+TLRPC = types.SimpleNamespace(
+ TL_messages_readHistory=_tl("TL_messages_readHistory", peer=None, max_id=0),
+ TL_channels_readHistory=_tl("TL_channels_readHistory", channel=None, max_id=0),
+ TL_account_updateStatus=_tl("TL_account_updateStatus", offline=False),
+ TL_messages_setTyping=_tl("TL_messages_setTyping", peer=None),
+ TL_messages_setEncryptedTyping=_tl("TL_messages_setEncryptedTyping", peer=None),
+ TL_messages_sendMessage=_tl("TL_messages_sendMessage", peer=None),
+ TL_messages_forwardMessages=_tl("TL_messages_forwardMessages", to_peer=None),
+ TL_stories_readStories=_tl("TL_stories_readStories", peer=None),
+ TL_messages_readMessageContents=_tl("TL_messages_readMessageContents"),
+ TL_messages_readEncryptedHistory=_tl("TL_messages_readEncryptedHistory", peer=None),
+ TL_messages_readDiscussion=_tl("TL_messages_readDiscussion", peer=None, msg_id=0, read_max_id=0),
+)
+
+ACCOUNTS = {0: 111, 1: 222} # slot -> Telegram user id
+BOT_ID = 999
+CHANNEL_ID = 777
+FORUM_ID = 888
+FORUM_TOPICS = {1: 500, 10: 610, 20: 720} # topic id -> newest message id
+NOW = 1_700_000_000
+NEW_MESSAGES = 42 # NotificationCenter.didReceiveNewMessages
+SENT: list[tuple[int, object]] = [] # (account, request) sent through ConnectionsManager
+
+
+class FakeUserConfig:
+ MAX_ACCOUNT_COUNT = 4
+ selectedAccount = 0
+
+ def __init__(self, slot):
+ self.slot = slot
+
+ @classmethod
+ def getInstance(cls, slot):
+ return cls(slot)
+
+ def isClientActivated(self):
+ return self.slot in ACCOUNTS
+
+ def getClientUserId(self):
+ return ACCOUNTS.get(self.slot, 0)
+
+ def getCurrentUser(self):
+ uid = ACCOUNTS.get(self.slot)
+ return types.SimpleNamespace(id=uid, first_name=f"User{uid}", username=None) if uid else None
+
+
+class FakeUserObject:
+ @staticmethod
+ def getUserName(user):
+ return getattr(user, "first_name", "") or "user"
+
+ @staticmethod
+ def isUserSelf(user):
+ return user.id in ACCOUNTS.values()
+
+
+class FakeDialogObject:
+ @staticmethod
+ def makeEncryptedDialogId(chat_id):
+ return 0x4000000000000000 | (chat_id & 0xFFFFFFFF)
+
+ @staticmethod
+ def isEncryptedDialog(dialog_id):
+ return bool(dialog_id & 0x4000000000000000) and dialog_id > 0
+
+ @staticmethod
+ def getEncryptedChatId(dialog_id):
+ return dialog_id & 0xFFFFFFFF
+
+
+class FakeSparse(dict):
+ def get(self, key, default=None):
+ return super().get(key, default)
+
+
+class FakeMessagesController:
+ instances: dict[int, "FakeMessagesController"] = {}
+
+ def __init__(self, account):
+ self.account = account
+ self.dialogs_dict = FakeSparse()
+ self.differences = []
+
+ @classmethod
+ def getInstance(cls, account):
+ return cls.instances.setdefault(account, cls(account))
+
+ def getUser(self, user_id):
+ return types.SimpleNamespace(id=user_id, bot=user_id == BOT_ID, first_name=f"U{user_id}", username=None)
+
+ def getChat(self, chat_id):
+ return types.SimpleNamespace(
+ id=chat_id,
+ title=f"Chat {chat_id}",
+ channel=chat_id in (CHANNEL_ID, FORUM_ID),
+ forum=chat_id == FORUM_ID,
+ )
+
+ def getTopicsController(self):
+ topics = {
+ i: types.SimpleNamespace(id=i, top_message=top) for i, top in FORUM_TOPICS.items()
+ }
+ return types.SimpleNamespace(
+ findTopic=lambda chat_id, topic_id: topics.get(topic_id) if chat_id == FORUM_ID else None,
+ getTopics=lambda chat_id: FakeArrayList(topics.values()) if chat_id == FORUM_ID else None,
+ )
+
+ def getInputPeer(self, dialog_id):
+ if dialog_id > 0:
+ return TL_inputPeerUser(user_id=dialog_id)
+ return TL_inputPeerChat(chat_id=-dialog_id)
+
+ def getInputChannel(self, chat_id):
+ return TL_inputChannel(channel_id=chat_id)
+
+ def processNewDifferenceParams(self, *args):
+ self.differences.append(args)
+
+
+class FakeChatObject:
+ @staticmethod
+ def isChannel(chat):
+ return bool(getattr(chat, "channel", False))
+
+
+class FakeConnectionsManager:
+ def __init__(self, account):
+ self.account = account
+
+ @classmethod
+ def getInstance(cls, account):
+ return cls(account)
+
+ def getCurrentTime(self):
+ return NOW
+
+ def sendRequest(self, request, callback):
+ SENT.append((self.account, request))
+ if callback is not None:
+ callback.fn(types.SimpleNamespace(pts=5, pts_count=1), None)
+
+
+class FakeRequestCallback:
+ def __init__(self, fn, account=None):
+ self.fn = fn
+
+
+class FakeArrayList(list):
+ def isEmpty(self):
+ return not self
+
+ def size(self):
+ return len(self)
+
+ def get(self, i):
+ return self[i]
+
+
+class FakeChatActivity(JavaObject):
+ def __init__(self, account, dialog):
+ self.account = account
+ self.dialog = dialog
+ self.panel_hidden = False
+ self.waitingForSendingMessageLoad = True
+
+ def getCurrentAccount(self):
+ return self.account
+
+ def getDialogId(self):
+ return self.dialog
+
+ def getParentActivity(self):
+ return object()
+
+ def hideFieldPanel(self, animated):
+ self.panel_hidden = True
+
+
+ChatActivity = type("ChatActivity", (FakeChatActivity,), {})
+LAST_FRAGMENT = [None]
+
+PLUGINS_ENTRY, ARCHIVE_ENTRY, FEED_ENTRY = 7, 1, 9
+MAIN_MENU_LAYOUT = FakeArrayList([ARCHIVE_ENTRY, FEED_ENTRY])
+GHOST_DRAWABLE = 0x7F0812AB
+
+
+class FakeDrawerMenuItemView:
+ def __init__(self, context):
+ self.menu_item = None
+ self.listener = None
+
+ def setMenuItem(self, layout_id, account, icon, text):
+ self.menu_item = (layout_id, account, icon, text)
+
+ def setOnClickListener(self, listener):
+ self.listener = listener
+
+
+class FakeLinearLayout:
+ def __init__(self):
+ self.children = []
+
+ def getContext(self):
+ return object()
+
+ def addView(self, view):
+ self.children.append(view)
+
+
+class FakeDrawerMenuView:
+ def __init__(self):
+ self.container = FakeLinearLayout()
+ self.closed = 0
+ self.onItemClick = types.SimpleNamespace(run=self._close)
+
+ def _close(self):
+ self.closed += 1
+
+
+class FakeResources:
+ def getIdentifier(self, name, kind, package):
+ return GHOST_DRAWABLE if (name, kind) == ("ghost", "drawable") else 0
+
+
+class FakeParam:
+ def __init__(self, args=(), this=None, result=None):
+ self.args = 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:
- base_plugin_mod = types.ModuleType("base_plugin")
- base_plugin_mod.BasePlugin = BasePlugin
- base_plugin_mod.HookResult = HookResult
- base_plugin_mod.HookStrategy = HookStrategy
- sys.modules["base_plugin"] = base_plugin_mod
-
- android_utils_mod = types.ModuleType("android_utils")
- android_utils_mod.log = fake_log
- sys.modules["android_utils"] = android_utils_mod
-
- ui_mod = types.ModuleType("ui")
- ui_mod.__path__ = []
- sys.modules["ui"] = ui_mod
-
- ui_settings_mod = types.ModuleType("ui.settings")
- for cls_name in ("Header", "Input", "Text", "Switch"):
- 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
+ _module(
+ "base_plugin",
+ BasePlugin=BasePlugin,
+ HookResult=HookResult,
+ HookStrategy=HookStrategy,
+ MethodHook=MethodHook,
+ MenuItemData=MenuItemData,
+ MenuItemType=MenuItemType,
+ )
+ _module(
+ "android_utils",
+ log=lambda *a, **k: LOG_LINES.append(" ".join(map(str, a))),
+ run_on_ui_thread=lambda fn, delay=0: fn(),
+ OnClickListener=lambda fn: types.SimpleNamespace(onClick=fn),
+ )
+ _module(
+ "hook_utils",
+ find_class=FakeJavaClass,
+ get_private_field=lambda obj, name: getattr(obj, name, None),
+ set_private_field=lambda obj, name, value: setattr(obj, name, value) or True,
+ )
+ _module(
+ "client_utils",
+ get_last_fragment=lambda: LAST_FRAGMENT[0],
+ RequestCallback=FakeRequestCallback,
+ )
+ ui = _module("ui", __path__=[])
+ ui.settings = _module(
+ "ui.settings",
+ **{n: type(n, (_SettingsRow,), {}) for n in ("Header", "Input", "Text", "Switch", "Selector", "Divider")},
+ )
+ ui.bulletin = _module(
+ "ui.bulletin",
+ BulletinHelper=types.SimpleNamespace(
+ show_info=lambda text, fragment=None: BULLETINS.append(text),
+ show_error=lambda text, fragment=None: BULLETINS.append("ERROR " + text),
+ ),
+ )
+ ui.alert = _module("ui.alert", AlertDialogBuilder=FakeAlertDialogBuilder)
+ android = _module("android", __path__=[])
+ android.widget = _module("android.widget", LinearLayout=type("LinearLayout", (FakeView,), {"VERTICAL": 1}),
+ ScrollView=FakeView, TextView=FakeView)
+ android.text = _module("android.text", __path__=[], SpannableStringBuilder=FakeSpannable,
+ Spanned=types.SimpleNamespace(SPAN_EXCLUSIVE_EXCLUSIVE=33),
+ TextUtils=types.SimpleNamespace(TruncateAt=types.SimpleNamespace(END=1)))
+ android.text.style = _module("android.text.style",
+ ForegroundColorSpan=lambda color: types.SimpleNamespace(color=color),
+ StyleSpan=lambda style: types.SimpleNamespace(style=style))
+ android.graphics = _module("android.graphics", __path__=[], Typeface=types.SimpleNamespace(BOLD=1))
+ android.graphics.drawable = _module("android.graphics.drawable", LayerDrawable=FakeLayerDrawable)
+ android.util = _module("android.util", TypedValue=types.SimpleNamespace(COMPLEX_UNIT_DIP=1))
+ android.view = _module("android.view", Gravity=types.SimpleNamespace(CENTER_VERTICAL=16, START=0x800003))
+ java = _module("java", __path__=[])
+ java.util = _module("java.util", ArrayList=FakeArrayList)
+ org = _module("org", __path__=[])
+ org.telegram = _module("org.telegram", __path__=[])
+ org.telegram.messenger = _module(
+ "org.telegram.messenger",
+ UserConfig=FakeUserConfig,
+ UserObject=FakeUserObject,
+ DialogObject=FakeDialogObject,
+ MessagesController=FakeMessagesController,
+ ChatObject=FakeChatObject,
+ NotificationCenter=types.SimpleNamespace(didReceiveNewMessages=NEW_MESSAGES),
+ AndroidUtilities=types.SimpleNamespace(dp=lambda v: int(v * 3)),
+ ApplicationLoader=types.SimpleNamespace(
+ applicationContext=types.SimpleNamespace(
+ getResources=lambda: FakeResources(), getPackageName=lambda: "com.exteragram.messenger"
+ )
+ ),
+ )
+ com = _module("com", __path__=[])
+ com.exteragram = _module("com.exteragram", __path__=[])
+ com.exteragram.messenger = _module(
+ "com.exteragram.messenger",
+ __path__=[],
+ ExteraConfig=types.SimpleNamespace(getMainMenuLayout=lambda: MAIN_MENU_LAYOUT),
+ MainMenuItem=types.SimpleNamespace(PLUGINS=types.SimpleNamespace(getId=lambda: PLUGINS_ENTRY)),
+ )
+ com.exteragram.messenger.drawer = _module(
+ "com.exteragram.messenger.drawer", DrawerMenuItemView=FakeDrawerMenuItemView
+ )
+ org.telegram.ui = _module("org.telegram.ui", __path__=[])
+ org.telegram.ui.ActionBar = _module("org.telegram.ui.ActionBar", Theme=FakeTheme)
+ org.telegram.tgnet = _module(
+ "org.telegram.tgnet", TLRPC=TLRPC, ConnectionsManager=FakeConnectionsManager
+ )
KEY_TO_DUNDER = {
@@ -133,6 +675,538 @@ def expected_dunders(meta: dict) -> dict:
return result
+# --- behaviour ---------------------------------------------------------------
+
+
+def reset(plugin):
+ plugin._settings.clear()
+ SENT.clear()
+ BULLETINS.clear()
+ DIALOGS.clear()
+ LAST_FRAGMENT[0] = None
+
+
+def pre(plugin, request, account=0):
+ return plugin.pre_request_hook(type(request).__name__, account, request).strategy
+
+
+def read(dialog):
+ if dialog > 0:
+ return TLRPC.TL_messages_readHistory(peer=TL_inputPeerUser(user_id=dialog), max_id=10)
+ return TLRPC.TL_messages_readHistory(peer=TL_inputPeerChat(chat_id=-dialog), max_id=10)
+
+
+def verify_requests(plugin):
+ D, C = HookStrategy.DEFAULT, HookStrategy.CANCEL
+ reset(plugin)
+ check("reads pass with ghost mode off", pre(plugin, read(555)) == D)
+
+ plugin.set_setting("g_no_read", True)
+ check(
+ "global Don't read messages drops reads on every account",
+ pre(plugin, read(555), 0) == C and pre(plugin, read(555), 1) == C,
+ )
+
+ plugin.set_setting("a_222_no_read", 2) # account 1 (uid 222): Off
+ check(
+ "account override beats the global switch",
+ pre(plugin, read(555), 0) == C and pre(plugin, read(555), 1) == D,
+ )
+
+ plugin.prefs.set_chat_choice(111, 555, plugin_features(plugin).NO_READ, 2)
+ check(
+ "chat override beats the account",
+ pre(plugin, read(555), 0) == D and pre(plugin, read(556), 0) == C,
+ )
+ plugin.prefs.set_chat_choice(222, -42, plugin_features(plugin).NO_READ, 1)
+ check(
+ "chat override can turn ghost on in an account that has it off",
+ pre(plugin, read(-42), 1) == C and pre(plugin, read(555), 1) == D,
+ )
+
+ reset(plugin)
+ plugin.prefs.set_chat_choice(111, -CHANNEL_ID, plugin_features(plugin).NO_READ, 1)
+ channel_read = types.new_class("TL_channels_readHistory", (JavaObject,))()
+ channel_read.channel = TL_inputChannel(channel_id=CHANNEL_ID)
+ check("channel reads resolve to -channel_id", pre(plugin, channel_read) == C)
+
+ self_read = TLRPC.TL_messages_readHistory(peer=TL_inputPeerSelf())
+ plugin.prefs.set_chat_choice(111, 111, plugin_features(plugin).NO_READ, 1)
+ check("inputPeerSelf resolves to the account's own id", pre(plugin, self_read) == C)
+
+ reset(plugin)
+ plugin.set_setting("g_no_read", True)
+ LAST_FRAGMENT[0] = ChatActivity(0, 555)
+ plugin.prefs.set_chat_choice(111, 555, plugin_features(plugin).NO_READ, 2)
+ check(
+ "readMessageContents follows the chat on screen",
+ pre(plugin, TLRPC.TL_messages_readMessageContents()) == D,
+ )
+ LAST_FRAGMENT[0] = None
+ check(
+ "readMessageContents without a chat on screen follows the account",
+ pre(plugin, TLRPC.TL_messages_readMessageContents()) == C,
+ )
+
+ reset(plugin)
+ plugin.prefs.set_chat_choice(111, 555, plugin_features(plugin).NO_TYPING, 1)
+ typing = lambda d: TLRPC.TL_messages_setTyping(peer=TL_inputPeerUser(user_id=d))
+ check("per-chat Don't send typing", pre(plugin, typing(555)) == C and pre(plugin, typing(556)) == D)
+ secret = FakeDialogObject.makeEncryptedDialogId(7)
+ plugin.prefs.set_chat_choice(111, secret, plugin_features(plugin).NO_TYPING, 1)
+ encrypted = TLRPC.TL_messages_setEncryptedTyping(peer=TL_inputEncryptedChat(chat_id=7))
+ check("secret chat typing resolves to its encrypted dialog id", pre(plugin, encrypted) == C)
+
+ stories = lambda d: TLRPC.TL_stories_readStories(peer=TL_inputPeerUser(user_id=d))
+ plugin.set_setting("g_no_read_stories", True)
+ plugin.prefs.set_chat_choice(111, 555, plugin_features(plugin).NO_READ_STORIES, 2)
+ check("per-chat Don't read stories", pre(plugin, stories(555)) == D and pre(plugin, stories(556)) == C)
+
+ # online status
+ reset(plugin)
+ plugin.set_setting("g_no_online", True)
+ status = TLRPC.TL_account_updateStatus(offline=False)
+ result = plugin.pre_request_hook("TL_account_updateStatus", 0, status)
+ check(
+ "Don't send online sends offline instead",
+ result.strategy == HookStrategy.MODIFY and status.offline is True,
+ f"{result.strategy} offline={status.offline}",
+ )
+ plugin.set_setting("a_222_no_online", 2)
+ status = TLRPC.TL_account_updateStatus(offline=False)
+ plugin.pre_request_hook("TL_account_updateStatus", 1, status)
+ check("Don't send online is per account", status.offline is False)
+
+ reset(plugin)
+ plugin.set_setting("g_auto_offline", True)
+ status = TLRPC.TL_account_updateStatus(offline=False)
+ plugin.pre_request_hook("TL_account_updateStatus", 1, status)
+ plugin.post_request_hook("TL_account_updateStatus", 1, object(), None)
+ check(
+ "Go offline automatically follows online with offline on the same account",
+ status.offline is False
+ and len(SENT) == 1
+ and SENT[0][0] == 1
+ and type(SENT[0][1]).__name__ == "TL_account_updateStatus"
+ and SENT[0][1].offline is True,
+ f"{SENT}",
+ )
+ plugin.pre_request_hook("TL_account_updateStatus", 1, SENT[0][1])
+ plugin.post_request_hook("TL_account_updateStatus", 1, object(), None)
+ check("the offline status doesn't trigger another", len(SENT) == 1)
+
+ # read on interact
+ reset(plugin)
+ plugin.set_setting("g_no_read", True)
+ FakeMessagesController.getInstance(0).dialogs_dict[555] = types.SimpleNamespace(top_message=4321)
+ send = TLRPC.TL_messages_sendMessage(peer=TL_inputPeerUser(user_id=555))
+ pre(plugin, send)
+ plugin.post_request_hook("TL_messages_sendMessage", 0, object(), None)
+ readers = [r for a, r in SENT if type(r).__name__ == "TL_messages_readHistory"]
+ check(
+ "Read on interact reads the chat up to its newest message after sending",
+ len(readers) == 1 and readers[0].max_id == 4321 and readers[0].peer.user_id == 555,
+ f"{SENT}",
+ )
+ check("the plugin's own read receipt is let through once", pre(plugin, readers[0]) == D)
+ check("…and only once", pre(plugin, readers[0]) == C)
+
+ SENT.clear()
+ plugin.prefs.set_chat_choice(111, 555, plugin_features(plugin).READ_ON_INTERACT, 2)
+ pre(plugin, send)
+ plugin.post_request_hook("TL_messages_sendMessage", 0, object(), None)
+ check("Read on interact can be turned off per chat", not SENT, f"{SENT}")
+
+ SENT.clear()
+ plugin.prefs.set_chat_choice(111, 555, plugin_features(plugin).READ_ON_INTERACT, 0)
+ pre(plugin, send)
+ plugin.post_request_hook("TL_messages_sendMessage", 0, None, object())
+ check("a failed send doesn't read the chat", not SENT)
+
+ reset(plugin)
+ FakeMessagesController.getInstance(0).dialogs_dict[-CHANNEL_ID] = types.SimpleNamespace(top_message=99)
+ plugin.set_setting("g_no_read", True)
+ forward = TLRPC.TL_messages_forwardMessages(to_peer=TL_inputPeerChannel(channel_id=CHANNEL_ID))
+ pre(plugin, forward)
+ plugin.post_request_hook("TL_messages_forwardMessages", 0, object(), None)
+ check(
+ "Read on interact on a channel uses channels.readHistory",
+ len(SENT) == 1
+ and type(SENT[0][1]).__name__ == "TL_channels_readHistory"
+ and SENT[0][1].channel.channel_id == CHANNEL_ID
+ and SENT[0][1].max_id == 99,
+ f"{SENT}",
+ )
+
+
+def plugin_features(plugin):
+ return type(plugin.prefs).resolve.__globals__["features"]
+
+
+def params(**kwargs):
+ base = dict(peer=555, scheduleDate=0, notify=True, photo=None, document=None)
+ base.update(kwargs)
+ return types.SimpleNamespace(**base)
+
+
+def verify_sending(plugin):
+ reset(plugin)
+ p = params()
+ result = plugin.on_send_message_hook(0, p)
+ check("sending untouched with options off", result.strategy == HookStrategy.DEFAULT and p.scheduleDate == 0)
+
+ plugin.set_setting("g_scheduled", True)
+ p = params()
+ result = plugin.on_send_message_hook(0, p)
+ check(
+ "Use scheduled messages schedules 12 s ahead",
+ result.strategy == HookStrategy.MODIFY and p.scheduleDate == NOW + 12,
+ f"{p.scheduleDate - NOW}",
+ )
+ p = params(peer=556, photo=object())
+ plugin.on_send_message_hook(0, p)
+ check("photos get extra upload time", p.scheduleDate == NOW + 22, f"{p.scheduleDate - NOW}")
+ p2 = params(peer=556, photo=object())
+ plugin.on_send_message_hook(0, p2)
+ check("album parts share one date", p2.scheduleDate == p.scheduleDate)
+
+ skipped = {
+ "bots": params(peer=BOT_ID),
+ "Saved Messages": params(peer=111),
+ "secret chats": params(peer=FakeDialogObject.makeEncryptedDialogId(3)),
+ "already scheduled": params(scheduleDate=NOW + 3600),
+ }
+ for what, p in skipped.items():
+ before = p.scheduleDate
+ plugin.on_send_message_hook(0, p)
+ check(f"no auto-schedule for {what}", p.scheduleDate == before, f"{p.scheduleDate}")
+
+ plugin.prefs.set_chat_choice(111, 557, plugin_features(plugin).SCHEDULED, 2)
+ p = params(peer=557)
+ plugin.on_send_message_hook(0, p)
+ check("Use scheduled messages off per chat", p.scheduleDate == 0)
+
+ reset(plugin)
+ plugin.prefs.set_chat_choice(111, 555, plugin_features(plugin).SILENT, 1)
+ a, b = params(), params(peer=556)
+ plugin.on_send_message_hook(0, a)
+ plugin.on_send_message_hook(0, b)
+ check("Send without sound per chat", a.notify is False and b.notify is True)
+
+ # forwards
+ reset(plugin)
+ plugin.set_setting("g_scheduled", True)
+ plugin.set_setting("g_silent", True)
+ forward = HOOKED[("org.telegram.messenger.SendMessagesHelper", "sendMessage")]
+ helper = types.SimpleNamespace(currentAccount=0)
+ param = FakeParam(args=[FakeArrayList([object()]), 555, False, False, True, 0, None], this=helper)
+ forward.before_hooked_method(param)
+ check(
+ "forwards are scheduled and silent too",
+ param.args[5] == NOW + 12 and param.args[4] is False,
+ f"{param.args}",
+ )
+ param = FakeParam(args=[object()], this=helper)
+ forward.before_hooked_method(param)
+ check("sendMessage(SendMessageParams) overload left alone", param.args == param.args[:1])
+
+ notification = HOOKED[("org.telegram.ui.ChatActivity", "didReceivedNotification")]
+ seen = []
+ for dialog, scheduled in ((555, True), (558, True), (555, False)):
+ chat = ChatActivity(0, dialog)
+ chat.isPaused = False
+ param = FakeParam(args=[NEW_MESSAGES, 0, [dialog, FakeArrayList(), scheduled]], this=chat)
+ notification.before_hooked_method(param)
+ seen.append(chat.isPaused)
+ notification.after_hooked_method(param)
+ seen.append(chat.isPaused)
+ check(
+ "the chat doesn't jump to scheduled messages after auto-scheduling (paused for that call only)",
+ seen == [True, False, False, False, False, False],
+ f"{seen}",
+ )
+ chat = ChatActivity(0, 555)
+ HOOKED[("org.telegram.ui.ChatActivity", "forwardMessages")].after_hooked_method(FakeParam(this=chat))
+ check(
+ "the forward panel closes after a scheduled forward",
+ chat.panel_hidden and chat.waitingForSendingMessageLoad is False,
+ )
+
+
+def verify_ui(plugin):
+ reset(plugin)
+ rows = plugin.create_settings()
+ keys = {getattr(r, "key", None) for r in rows}
+ want = {f"g_{k}" for k in plugin_features(plugin).BY_KEY}
+ check("settings page has a global switch per option", want <= keys, f"{want - keys}")
+ accounts = [r for r in rows if getattr(r, "create_sub_fragment", None)]
+ check("settings page lists every signed-in account", len(accounts) == len(ACCOUNTS))
+
+ master = next(r for r in rows if getattr(r, "key", None) == "g_ghost")
+ master.on_change(True)
+ essentials = plugin_features(plugin).ESSENTIALS
+ check(
+ "master switch turns every essential on",
+ all(plugin.get_setting(f"g_{f.key}") is True for f in essentials),
+ )
+
+ page = accounts[1].create_sub_fragment()
+ selectors = [r for r in page if getattr(r, "items", None)]
+ check(
+ "account page has a selector per option plus Ghost mode",
+ len(selectors) == len(plugin_features(plugin).ALL) + 1,
+ f"{len(selectors)}",
+ )
+ selectors[0].on_change(2) # Ghost mode: Off for account 1
+ check(
+ "account Ghost mode selector sets that account's essentials only",
+ all(plugin.get_setting(f"a_222_{f.key}") == 2 for f in essentials)
+ and all(plugin.get_setting(f"a_111_{f.key}") is None for f in essentials),
+ )
+ check(
+ "…and the account is out of ghost mode while the global one is on",
+ not plugin.prefs.ghost_active(222) and plugin.prefs.ghost_active(111),
+ )
+
+ # the chat dialog
+ reset(plugin)
+ LAST_FRAGMENT[0] = ChatActivity(0, 555)
+ chat_item = next(i for i in MENU_ITEMS if i.menu_type == MenuItemType.CHAT_ACTION_MENU)
+ chat_item.on_click({"account": 0, "dialog_id": 555, "fragment": LAST_FRAGMENT[0]})
+ check("chat menu opens the chat dialog", len(DIALOGS) == 1, f"{len(DIALOGS)}")
+ dialog = DIALOGS[-1]
+ check(
+ "chat rows read ' ', untouched options Inherit",
+ dialog.items[dialog.find("Don't read messages")] == "📨 Inherit Don't read messages"
+ and dialog.items[0] == "👻 Off Ghost mode",
+ f"{dialog.items}",
+ )
+ row = dialog.find("Don't read messages")
+ check("Inherit is gray", dialog.rows[row].text.colored() == [("Inherit", GRAY)], f"{dialog.rows[row].text.colored()}")
+ dialog.click(row)
+ check(
+ "tapping an option sets it for this chat and redraws in place",
+ plugin.prefs.chat_choice(111, 555, plugin_features(plugin).NO_READ) == 1
+ and len(DIALOGS) == 1
+ and dialog.items[row] == "📨 On Don't read messages",
+ f"{plugin.prefs.chat_overrides(111)} dialogs={len(DIALOGS)} {dialog.items}",
+ )
+ check("On is green", dialog.rows[row].text.colored() == [("On", GREEN)], f"{dialog.rows[row].text.colored()}")
+ dialog.click(row)
+ check(
+ "…then Off, in red",
+ dialog.items[row] == "📨 Off Don't read messages" and dialog.rows[row].text.colored() == [("Off", RED)],
+ f"{dialog.items[row]!r}",
+ )
+ dialog.click(row)
+ check("…then back to Inherit", dialog.items[row] == "📨 Inherit Don't read messages", f"{dialog.items[row]!r}")
+ dialog.click(row)
+ check(
+ "the chat now drops reads, other chats don't",
+ pre(plugin, read(555)) == HookStrategy.CANCEL and pre(plugin, read(556)) == HookStrategy.DEFAULT,
+ )
+ dialog.click(0) # Ghost mode in this chat (currently off): turn on
+ chat_essentials = [f for f in essentials if f.chat]
+ check(
+ "chat ghost toggle sets the chat's essentials",
+ all(plugin.prefs.chat_choice(111, 555, f) == 1 for f in chat_essentials)
+ and dialog.items[0] == "👻 On Ghost mode",
+ f"{plugin.prefs.chat_overrides(111)} {dialog.items[0]!r}",
+ )
+ check(
+ "every row has an emoji prefix",
+ all(not text[0].isascii() for text in dialog.items),
+ f"{dialog.items}",
+ )
+ dialog.click(dialog.find("Use account settings"))
+ check(
+ "reset removes the chat's exceptions",
+ not plugin.prefs.chat_overrides(111) and not any(t.endswith("Use account settings") for t in dialog.items),
+ )
+ dialog.click(0) # ghost on again, so reading is blocked and "Read all" shows
+ dialog.click(dialog.find("Read all messages now"))
+ check("Read all messages now closes the popup", dialog.dismissed)
+
+ # message menu
+ reset(plugin)
+ message = types.SimpleNamespace(getDialogId=lambda: 555, getId=lambda: 77, currentAccount=1)
+ item = next(i for i in MENU_ITEMS if i.menu_type == MenuItemType.MESSAGE_CONTEXT_MENU)
+ item.on_click({"message": message})
+ check(
+ "Read until here sends a read receipt up to that message from its account",
+ len(SENT) == 1 and SENT[0][0] == 1 and SENT[0][1].max_id == 77,
+ f"{SENT}",
+ )
+
+ # drawer: the current account's options
+ reset(plugin)
+ drawer = next(i for i in MENU_ITEMS if i.menu_type == MenuItemType.DRAWER_MENU)
+ check("drawer item has the ghost icon", drawer.icon == "ghost", f"{drawer.icon!r}")
+ chat_item = next(i for i in MENU_ITEMS if i.menu_type == MenuItemType.CHAT_ACTION_MENU)
+ check("chat menu item has the ghost icon", chat_item.icon == "ghost", f"{chat_item.icon!r}")
+ FakeUserConfig.selectedAccount = 1
+ LAST_FRAGMENT[0] = ChatActivity(1, 0) # whatever screen is under the drawer
+ drawer.on_click({})
+ check(
+ "drawer opens the current account's options",
+ len(DIALOGS) == 1 and "User222" in DIALOGS[0].title,
+ f"{[d.title for d in DIALOGS]}",
+ )
+ check(
+ "account dialog lists every option",
+ len(DIALOGS[0].items) == 1 + len(plugin_features(plugin).ALL),
+ f"{DIALOGS[0].items}",
+ )
+ dialog = DIALOGS[0]
+ check(
+ "account rows show Global for options the account doesn't set",
+ dialog.items[dialog.find("Send without sound")] == "🔕 Global Send without sound",
+ f"{dialog.items}",
+ )
+ dialog.click(0)
+ check(
+ "account dialog ghost toggle turns ghost mode on for that account only",
+ plugin.prefs.ghost_active(222) and not plugin.prefs.ghost_active(111),
+ )
+ row = dialog.find("Send without sound")
+ dialog.click(row)
+ check(
+ "tapping an option cycles the account's choice",
+ plugin.get_setting("a_222_silent") == 1 and dialog.items[row] == "🔕 On Send without sound",
+ f"{plugin.get_setting('a_222_silent')} {dialog.items[row]!r}",
+ )
+ dialog.click(dialog.find("Use global settings"))
+ check("reset returns the account to the global settings", not plugin.prefs.account_overrides(222))
+ FakeUserConfig.selectedAccount = 0
+
+
+def verify_drawer(plugin):
+ reset(plugin)
+ rebuild = HOOKED[("com.exteragram.messenger.drawer.DrawerMenuView", "rebuildMenu")]
+ menu = FakeDrawerMenuView()
+ rebuild.after_hooked_method(FakeParam(args=[1, None], this=menu))
+ rows = menu.container.children
+ check(
+ "without a Plugins entry the drawer gets a Ghost mode row with the ghost icon",
+ len(rows) == 1 and rows[0].menu_item == (PLUGINS_ENTRY, 1, GHOST_DRAWABLE, "Ghost mode"),
+ f"{[r.menu_item for r in rows]}",
+ )
+ LAST_FRAGMENT[0] = ChatActivity(1, 0)
+ rows[0].listener.onClick(None)
+ check(
+ "the row closes the drawer and opens that account's options",
+ menu.closed == 1 and len(DIALOGS) == 1 and "User222" in DIALOGS[0].title,
+ f"closed={menu.closed} {[d.title for d in DIALOGS]}",
+ )
+ MAIN_MENU_LAYOUT.append(PLUGINS_ENTRY)
+ menu = FakeDrawerMenuView()
+ rebuild.after_hooked_method(FakeParam(args=[0, None], this=menu))
+ check("with a Plugins entry the app's own item is used, no second row", not menu.container.children)
+ MAIN_MENU_LAYOUT.remove(PLUGINS_ENTRY)
+
+
+def verify_icons(plugin):
+ sub_item = HOOKED[("org.telegram.ui.ActionBar.ActionBarMenuSubItem", "setTextAndIcon")]
+ item = types.SimpleNamespace(imageView=FakeImageView(FakeDrawable(96)))
+ sub_item.after_hooked_method(FakeParam(args=["Ghost mode", GHOST_DRAWABLE], this=item))
+ check(
+ "the ghost icon in chat/profile menus is drawn at 24dp",
+ isinstance(item.imageView.drawable, FakeLayerDrawable) and item.imageView.drawable.size == 72,
+ f"{vars(item.imageView.drawable)}",
+ )
+ other = types.SimpleNamespace(imageView=FakeImageView(FakeDrawable(96)))
+ sub_item.after_hooked_method(FakeParam(args=["Forward", 12345], this=other))
+ check("other menu icons are left alone", not isinstance(other.imageView.drawable, FakeLayerDrawable))
+
+ drawer_item = HOOKED[("com.exteragram.messenger.drawer.DrawerMenuItemView", "setMenuItem")]
+ row = types.SimpleNamespace(iconView=FakeImageView(FakeDrawable(96)))
+ drawer_item.after_hooked_method(FakeParam(args=[7, 0, GHOST_DRAWABLE, "Ghost mode"], this=row))
+ check(
+ "the ghost icon in the drawer is drawn at 24dp",
+ isinstance(row.iconView.drawable, FakeLayerDrawable) and row.iconView.drawable.size == 72,
+ )
+ small = types.SimpleNamespace(iconView=FakeImageView(FakeDrawable(72)))
+ drawer_item.after_hooked_method(FakeParam(args=[7, 0, GHOST_DRAWABLE, "Ghost mode"], this=small))
+ check("an icon already 24dp isn't wrapped", not isinstance(small.iconView.drawable, FakeLayerDrawable))
+
+
+def verify_forums(plugin):
+ """A forum is one chat: its settings cover all topics."""
+ D, C = HookStrategy.DEFAULT, HookStrategy.CANCEL
+ reset(plugin)
+ nr = plugin_features(plugin).NO_READ
+ plugin.prefs.set_chat_choice(111, -FORUM_ID, nr, 1)
+ discussion = lambda topic: TLRPC.TL_messages_readDiscussion(
+ peer=TL_inputPeerChannel(channel_id=FORUM_ID), msg_id=topic, read_max_id=600
+ )
+ check(
+ "a forum's setting applies to every topic's read receipts",
+ all(pre(plugin, discussion(t)) == C for t in FORUM_TOPICS),
+ )
+ typing = TLRPC.TL_messages_setTyping(peer=TL_inputPeerChannel(channel_id=FORUM_ID))
+ typing.top_msg_id = 10
+ plugin.prefs.set_chat_choice(111, -FORUM_ID, plugin_features(plugin).NO_TYPING, 1)
+ check("…and to typing in any topic", pre(plugin, typing) == C)
+ check("the forum's settings are stored once, for the group", list(plugin.prefs.chat_overrides(111)) == [-FORUM_ID])
+
+ # read on interact in a topic reads that topic
+ send = TLRPC.TL_messages_sendMessage(peer=TL_inputPeerChannel(channel_id=FORUM_ID))
+ send.reply_to = types.SimpleNamespace(top_msg_id=10, reply_to_msg_id=10)
+ pre(plugin, send)
+ plugin.post_request_hook("TL_messages_sendMessage", 0, object(), None)
+ check(
+ "Read on interact in a topic reads that topic (readDiscussion)",
+ len(SENT) == 1
+ and type(SENT[0][1]).__name__ == "TL_messages_readDiscussion"
+ and SENT[0][1].msg_id == 10
+ and SENT[0][1].read_max_id == FORUM_TOPICS[10],
+ f"{[(type(r).__name__, vars(r)) for _, r in SENT]}",
+ )
+ check("…and it isn't blocked by the forum's own setting", pre(plugin, SENT[0][1]) == D)
+
+ SENT.clear()
+ general = TLRPC.TL_messages_sendMessage(peer=TL_inputPeerChannel(channel_id=FORUM_ID))
+ pre(plugin, general)
+ plugin.post_request_hook("TL_messages_sendMessage", 0, object(), None)
+ check(
+ "a message to General reads the General topic",
+ len(SENT) == 1 and SENT[0][1].msg_id == 1 and SENT[0][1].read_max_id == FORUM_TOPICS[1],
+ f"{[vars(r) for _, r in SENT]}",
+ )
+ pre(plugin, SENT[0][1]) # as the app would, which uses up its allowance
+
+ # Read all messages now
+ SENT.clear()
+ LAST_FRAGMENT[0] = ChatActivity(0, -FORUM_ID)
+ chat_item = next(i for i in MENU_ITEMS if i.menu_type == MenuItemType.CHAT_ACTION_MENU)
+ chat_item.on_click({"account": 0, "dialog_id": -FORUM_ID})
+ DIALOGS[-1].click(DIALOGS[-1].find("Read all messages now"))
+ check(
+ "Read all messages now reads every topic up to its newest message",
+ sorted((r.msg_id, r.read_max_id) for _, r in SENT) == sorted(FORUM_TOPICS.items()),
+ f"{[vars(r) for _, r in SENT]}",
+ )
+ check("…and all of them get through", all(pre(plugin, r) == D for _, r in SENT))
+ check("…once", pre(plugin, SENT[0][1]) == C)
+
+ # Read until here on a topic message
+ SENT.clear()
+ owner = types.SimpleNamespace(
+ reply_to=types.SimpleNamespace(forum_topic=True, reply_to_top_id=20, reply_to_msg_id=715),
+ action=None,
+ )
+ message = types.SimpleNamespace(
+ getDialogId=lambda: -FORUM_ID, getId=lambda: 716, currentAccount=0, messageOwner=owner
+ )
+ item = next(i for i in MENU_ITEMS if i.menu_type == MenuItemType.MESSAGE_CONTEXT_MENU)
+ item.on_click({"message": message})
+ check(
+ "Read until here in a topic reads that topic up to the message",
+ len(SENT) == 1 and SENT[0][1].msg_id == 20 and SENT[0][1].read_max_id == 716,
+ f"{[vars(r) for _, r in SENT]}",
+ )
+
+
def main(argv=None) -> int:
argv = sys.argv[1:] if argv is None else argv
if len(argv) != 1:
@@ -146,12 +1220,12 @@ def main(argv=None) -> int:
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__": "hello_bundle"}
+ ns = {"__name__": meta["id"]}
exec(compile(source, str(bundle_path), "exec"), ns)
new_modules = set(sys.modules) - modules_before
- meta = json.loads(PLUGIN_JSON.read_text(encoding="utf-8"))
expected = expected_dunders(meta)
mismatches = [
f"{dunder}: expected {value!r}, got {ns.get(dunder)!r}"
@@ -179,78 +1253,50 @@ def main(argv=None) -> int:
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)}",
- )
+ 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()
+ if len(plugin_classes) == 1:
+ plugin = plugin_classes[0]()
plugin.on_plugin_load()
+ check("on_plugin_load emits load log line", any("ghost_mode loaded" in l for l in LOG_LINES), f"{LOG_LINES}")
+ check("no errors while loading", not [l for l in LOG_LINES if "loaded" not in l], f"{LOG_LINES}")
+ network = type(plugin.network).pre.__globals__
+ check("every request ghost mode handles is hooked", set(network["NAMES"]) <= set(ADDED_HOOKS))
+ expected_hooks = {
+ ("org.telegram.messenger.SendMessagesHelper", "sendMessage"),
+ ("org.telegram.ui.ChatActivity", "didReceivedNotification"),
+ ("org.telegram.ui.ChatActivity", "forwardMessages"),
+ ("com.exteragram.messenger.drawer.DrawerMenuView", "rebuildMenu"),
+ ("org.telegram.ui.ActionBar.ActionBarMenuSubItem", "setTextAndIcon"),
+ ("com.exteragram.messenger.drawer.DrawerMenuItemView", "setMenuItem"),
+ }
+ check("Xposed hooks installed", expected_hooks <= set(HOOKED), f"{set(HOOKED)}")
check(
- "on_plugin_load emits load log line",
- any("hello_bundle loaded" in line for line in LOG_LINES),
- f"log lines: {LOG_LINES}",
+ "menu items: chat, profile, message, drawer",
+ {i.menu_type for i in MENU_ITEMS}
+ == {
+ MenuItemType.CHAT_ACTION_MENU,
+ MenuItemType.PROFILE_ACTION_MENU,
+ MenuItemType.MESSAGE_CONTEXT_MENU,
+ MenuItemType.DRAWER_MENU,
+ },
)
- params = SimpleParams(".hello alice smith")
- result = plugin.on_send_message_hook(account=0, params=params)
- check(
- "'.hello alice smith' renders greeting",
- params.message == "Hello, Alice Smith!",
- f"got {params.message!r}",
- )
- check(
- "on_send_message_hook returns MODIFY strategy",
- result.strategy == HookStrategy.MODIFY,
- f"got {result.strategy}",
- )
+ for part in (verify_requests, verify_sending, verify_ui, verify_drawer, verify_icons, verify_forums):
+ try:
+ part(plugin)
+ except Exception:
+ import traceback
- plugin.set_setting("template", "{nope}")
- params2 = SimpleParams(".hello someone")
- plugin.on_send_message_hook(account=0, params=params2)
- check(
- "bad template produces 'Bad template:' message",
- params2.message.startswith("Bad template:"),
- f"got {params2.message!r}",
- )
+ check(part.__name__, False, traceback.format_exc())
- # 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}",
- )
+ plugin.on_plugin_unload()
+ check("unload removes the request hooks", not ADDED_HOOKS, f"{ADDED_HOOKS}")
+ errors = [l for l in LOG_LINES if "failed" in l or "hook:" in l]
+ check("no hook errors logged", not errors, f"{errors}")
- check(
- "sys.modules gained no entry for bundled module names",
- not ({"greeting", "textutil", "main"} & new_modules),
- f"leaked: {({'greeting', 'textutil', 'main'} & new_modules)}",
- )
+ bundled = {"main", "features", "prefs", "peers", "network", "sending", "menus", "settings_page", "hooking", "drawer", "icons", "options_dialog"}
+ check("sys.modules gained no entry for bundled module names", not (bundled & new_modules), f"{bundled & new_modules}")
if _FAILURES:
print(f"\n{len(_FAILURES)} check(s) failed: {', '.join(_FAILURES)}", file=sys.stderr)