[no bump] Ghost Mode: AyuGram's ghost mode with per-account and per-chat settings
Publish plugin / publish (push) Successful in 5s

Read receipts, story views, typing and online status are held back per
the options; read on interact, scheduled and silent sending as in AyuGram.
Every option resolves chat -> account -> global; forum topics follow
their group. Settings live in the plugin page, a chat/profile menu popup,
and a drawer row for the current account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lyuksovannyyandClaude Opus 5.5 committed 2026-10-09 15:54:41 +02:00
1 parent dfd1f659fe
commit 00553970b7
20 files changed
+3222 -177

No files matched your search

+52 -18
View File
@@ -1,38 +1,72 @@
<p align="center">
<img src="images/icon.svg" width="128" height="128" alt="exteraGram Plugin Base icon">
<img src="images/icon.svg" width="128" height="128" alt="Ghost Mode icon">
</p>
<h1 align="center">exteraGram Plugin Base</h1>
<h1 align="center">Ghost Mode</h1>
**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.
+9 -2
View File
@@ -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/<id>.plugin
devclient.py speaks the on-device dev-server protocol (127.0.0.1:42690)
+75
View File
@@ -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<MessageObject>, …)` 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_<option>` (bool), the switches on the main settings page.
- Account: `a_<user id>_<option>` (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_<user id>`, a JSON object `{"<dialog id>": {"<option>": 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`.
+3 -3
View File
@@ -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",
+74
View File
@@ -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)
+115
View File
@@ -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}
-1
View File
@@ -1 +0,0 @@
from .template import render, TemplateError
-13
View File
@@ -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}")
+56
View File
@@ -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)
+90
View File
@@ -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}")
+52 -37
View File
@@ -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 <name>"
else:
try:
params.message = render(self.get_setting("template", DEFAULT_TEMPLATE), name)
except TemplateError as exc:
params.message = "Bad template: " + str(exc)
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()
+256
View File
@@ -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)
+306
View File
@@ -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))
+135
View File
@@ -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
<emoji> <State> <title>
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
+237
View File
@@ -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__
+186
View File
@@ -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_<feature> bool global switch
a_<uid>_<feature> int account choice: INHERIT / ON / OFF
(the index of a settings Selector)
c_<uid> JSON string {"<dialog id>": {"<feature>": 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_<uid> 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)
+243
View File
@@ -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<MessageObject>, 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
+186
View File
@@ -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)
-2
View File
@@ -1,2 +0,0 @@
def normalize(name: str) -> str:
return " ".join(w[:1].upper() + w[1:] for w in name.split())
+1147 -101
View File
File diff suppressed because it is too large. Load diff