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>
4.3 KiB
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, sodataclassescan'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 onedidReceivedNotificationcall.SendMessageParams.notifyclashes withObject.notify(), so it is read and written withhook_utils.get_private_field/set_private_field.- A Python
intput into a hooked method'sObject[]arguments becomes aLong, so the forward hook writes a boxedIntegerandBoolean.