[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
+3223 -178

No files matched your search

+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`.