generated from exteraGramPlugins/pluginBase
[no bump] Ghost Mode: AyuGram's ghost mode with per-account and per-chat settings
Publish plugin / publish (push) Successful in 5s
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:
1 parent
dfd1f659fe
commit
00553970b7
20 files changed
+3223
-178
No files matched your search
@@ -1,38 +1,72 @@
|
|||||||
<p align="center">
|
<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>
|
</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
|
**TL;DR:** AyuGram's ghost mode as an [exteraGram](https://exteragram.app) plugin: read,
|
||||||
multi-file Python projects. Write code in `src/`, run one command, get a single `.plugin`
|
browse and type without anyone seeing it. You can set every option globally, per account
|
||||||
file deployed to your phone or published to Releases.
|
and per chat.
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
exteraGram loads every plugin from exactly one Python file. This template lets you split a
|
Ghost Mode stops the app from sending the signals that give you away: read receipts, story
|
||||||
plugin into ordinary modules and packages anyway: a bundler compiles `src/` and `plugin.json`
|
views, typing statuses and your online status. It also adds AyuGram's companion options:
|
||||||
into that single file, an offline harness verifies the result without a device, ADB scripts
|
read on interact, scheduled sending and silent sending. Nothing is sent and nothing changes
|
||||||
push it to a connected phone and reload it, and CI publishes every version to the repo's
|
until you turn something on.
|
||||||
Releases tab.
|
|
||||||
|
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
|
## Features
|
||||||
|
|
||||||
None for the end user. The bundled sample plugin (`Hello Bundle`) only proves that the
|
**Ghost essentials** (the *Ghost mode* switch turns all five on or off)
|
||||||
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.
|
- **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
|
## 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
|
- [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
|
- [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
|
- [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
|
- [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
|
- [SDK notes](docs/sdk-notes.md): condensed exteraGram SDK API surface
|
||||||
|
|
||||||
## Quick start
|
## Credits
|
||||||
|
|
||||||
```bash
|
Ghost mode's behaviour follows [AyuGram](https://github.com/AyuGram/AyuGram4A) by Radolyn.
|
||||||
python3 tools/bundle.py --check # once after cloning: enables the git hooks
|
|
||||||
bash scripts/deploy.sh --enable # build + push to a connected device + enable
|
|
||||||
```
|
|
||||||
+9
-2
@@ -17,8 +17,15 @@
|
|||||||
plugin.json single source of truth for plugin metadata
|
plugin.json single source of truth for plugin metadata
|
||||||
src/ the plugin, written as an ordinary Python package
|
src/ the plugin, written as an ordinary Python package
|
||||||
main.py entry module — exactly one BasePlugin subclass, absolute imports only
|
main.py entry module — exactly one BasePlugin subclass, absolute imports only
|
||||||
greeting/ sample package (proves relative + cross-module imports survive bundling)
|
features.py the ghost mode options and their defaults
|
||||||
textutil.py sample top-level sibling module
|
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/
|
tools/
|
||||||
bundle.py compiles plugin.json + src/ into dist/<id>.plugin
|
bundle.py compiles plugin.json + src/ into dist/<id>.plugin
|
||||||
devclient.py speaks the on-device dev-server protocol (127.0.0.1:42690)
|
devclient.py speaks the on-device dev-server protocol (127.0.0.1:42690)
|
||||||
|
|||||||
@@ -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
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "hello_bundle",
|
"id": "ghost_mode",
|
||||||
"name": "Hello Bundle",
|
"name": "Ghost Mode",
|
||||||
"description": "Multi-file exteraGram plugin built by tools/bundle.py",
|
"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",
|
"author": "@lyuksovannyy",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"icon": "exteraGramPluginIcons/5",
|
"icon": "exteraGramPluginIcons/5",
|
||||||
|
|||||||
@@ -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
@@ -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 +0,0 @@
|
|||||||
from .template import render, TemplateError
|
|
||||||
@@ -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}")
|
|
||||||
@@ -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)
|
||||||
@@ -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}")
|
||||||
+54
-39
@@ -1,50 +1,65 @@
|
|||||||
from base_plugin import BasePlugin, HookResult, HookStrategy
|
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):
|
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.add_on_send_message_hook()
|
||||||
|
self.log("ghost_mode loaded")
|
||||||
|
|
||||||
def on_plugin_unload(self):
|
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):
|
def pre_request_hook(self, request_name, account, request):
|
||||||
return [
|
try:
|
||||||
Header(text="Hello Bundle"),
|
return self.network.pre(request_name, account, request)
|
||||||
Input(
|
except Exception as exc:
|
||||||
key="template",
|
self.log(f"pre_request_hook {request_name}: {exc}")
|
||||||
text="Greeting template",
|
return HookResult()
|
||||||
default=DEFAULT_TEMPLATE,
|
|
||||||
subtext="Use {name} as placeholder.",
|
def post_request_hook(self, request_name, account, response, error):
|
||||||
icon="msg_edit",
|
try:
|
||||||
),
|
return self.network.post(request_name, account, response, error)
|
||||||
Text(
|
except Exception as exc:
|
||||||
text="Example",
|
self.log(f"post_request_hook {request_name}: {exc}")
|
||||||
subtext=".hello alice smith -> Hello, Alice Smith!",
|
return HookResult()
|
||||||
icon="msg_info",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
def on_send_message_hook(self, account, params):
|
def on_send_message_hook(self, account, params):
|
||||||
message = getattr(params, "message", None)
|
try:
|
||||||
if not isinstance(message, str):
|
if self.sending.on_send_message(account, params):
|
||||||
return HookResult()
|
return HookResult(strategy=HookStrategy.MODIFY, params=params)
|
||||||
stripped = message.strip()
|
except Exception as exc:
|
||||||
if not stripped.startswith(".hello"):
|
self.log(f"on_send_message_hook: {exc}")
|
||||||
return HookResult()
|
return HookResult()
|
||||||
parts = stripped.split(" ", 1)
|
|
||||||
name = parts[1] if len(parts) > 1 else ""
|
def create_settings(self):
|
||||||
if not name.strip():
|
return self.page.build()
|
||||||
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)
|
|
||||||
return HookResult(strategy=HookStrategy.MODIFY, params=params)
|
|
||||||
+256
@@ -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
@@ -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))
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
def normalize(name: str) -> str:
|
|
||||||
return " ".join(w[:1].upper() + w[1:] for w in name.split())
|
|
||||||
+1146
-100
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user