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
+3222
-177
No files matched your search
@@ -1,38 +1,72 @@
|
||||
<p align="center">
|
||||
<img src="images/icon.svg" width="128" height="128" alt="exteraGram Plugin Base icon">
|
||||
<img src="images/icon.svg" width="128" height="128" alt="Ghost Mode icon">
|
||||
</p>
|
||||
|
||||
<h1 align="center">exteraGram Plugin Base</h1>
|
||||
<h1 align="center">Ghost Mode</h1>
|
||||
|
||||
**TL;DR:** a template repo for writing [exteraGram](https://exteragram.app) plugins as normal
|
||||
multi-file Python projects. Write code in `src/`, run one command, get a single `.plugin`
|
||||
file deployed to your phone or published to Releases.
|
||||
**TL;DR:** AyuGram's ghost mode as an [exteraGram](https://exteragram.app) plugin: read,
|
||||
browse and type without anyone seeing it. You can set every option globally, per account
|
||||
and per chat.
|
||||
|
||||
## Description
|
||||
|
||||
exteraGram loads every plugin from exactly one Python file. This template lets you split a
|
||||
plugin into ordinary modules and packages anyway: a bundler compiles `src/` and `plugin.json`
|
||||
into that single file, an offline harness verifies the result without a device, ADB scripts
|
||||
push it to a connected phone and reload it, and CI publishes every version to the repo's
|
||||
Releases tab.
|
||||
Ghost Mode stops the app from sending the signals that give you away: read receipts, story
|
||||
views, typing statuses and your online status. It also adds AyuGram's companion options:
|
||||
read on interact, scheduled sending and silent sending. Nothing is sent and nothing changes
|
||||
until you turn something on.
|
||||
|
||||
Every option works on three levels, and the most specific one with a value wins:
|
||||
|
||||
1. **Chat**: open a chat or profile, tap ⋮ → *Ghost mode*.
|
||||
2. **Account**: plugin settings → *Accounts* → pick an account.
|
||||
3. **Global**: the switches on the plugin's main settings page.
|
||||
|
||||
So you can stay a ghost everywhere except in one chat, or only on a second account. A forum
|
||||
(a group with topics) is one chat: its settings cover every topic, and there's nothing per
|
||||
topic.
|
||||
|
||||
## Features
|
||||
|
||||
None for the end user. The bundled sample plugin (`Hello Bundle`) only proves that the
|
||||
toolchain works. This repo is meant to be used as a **template** for creating other plugins:
|
||||
click "Use this template" (or clone it), then replace `plugin.json` and `src/` with your own.
|
||||
**Ghost essentials** (the *Ghost mode* switch turns all five on or off)
|
||||
|
||||
- **Don't read messages.** Read receipts aren't sent. Chats still look read on your side.
|
||||
- **Don't read stories.** Story views aren't sent.
|
||||
- **Don't send online.** Your online status is sent as offline. *Account level only.*
|
||||
- **Don't send typing.** Typing, recording and uploading statuses aren't sent.
|
||||
- **Go offline automatically.** Every online status is followed straight away by an
|
||||
offline one. *Account level only.*
|
||||
|
||||
**Options**
|
||||
|
||||
- **Read on interact** (on by default). Sending a message to a chat where messages aren't
|
||||
read marks that chat as read, as AyuGram does.
|
||||
- **Use scheduled messages.** Messages and forwards are scheduled 12 seconds ahead (media
|
||||
gets more time to upload). Telegram's servers then deliver them, so sending doesn't put you
|
||||
online. Secret chats, bots and Saved Messages are never scheduled.
|
||||
- **Send without sound.** Messages and forwards are sent silently.
|
||||
|
||||
**In the app**
|
||||
|
||||
- Chat and profile ⋮ menu → **Ghost mode**: this chat's options. Each one shows its value
|
||||
and whether it comes from the chat, the account or the global settings. Tapping an option
|
||||
cycles *Default → On → Off*. The menu also has a ghost toggle for the chat, *Read all
|
||||
messages now* and a reset.
|
||||
- Message menu → **Read until here**: sends a read receipt up to that message.
|
||||
- Side menu → **Ghost mode**: the options of the account in use. It has the same list and
|
||||
*Global → On → Off* cycling as its settings page, plus a ghost toggle and a reset.
|
||||
- Plugin settings → **Accounts**: each account's options (*Global / On / Off*) and its list
|
||||
of chat exceptions.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [How Ghost Mode works](docs/how-it-works.md): what is intercepted, how settings resolve,
|
||||
runtime notes
|
||||
- [Development](docs/development.md): getting started, repo layout, building and verifying, icon export
|
||||
- [How exteraGram plugins work](docs/how-plugins-work.md): the plugin format and lifecycle
|
||||
- [Device setup and ADB automation](docs/device-and-adb.md): deploy, watch, logs, debugger
|
||||
- [Versioning and releases](docs/versioning-and-releases.md): git hooks, auto-bump, CI publishing
|
||||
- [SDK notes](docs/sdk-notes.md): condensed exteraGram SDK API surface
|
||||
|
||||
## Quick start
|
||||
## Credits
|
||||
|
||||
```bash
|
||||
python3 tools/bundle.py --check # once after cloning: enables the git hooks
|
||||
bash scripts/deploy.sh --enable # build + push to a connected device + enable
|
||||
```
|
||||
Ghost mode's behaviour follows [AyuGram](https://github.com/AyuGram/AyuGram4A) by Radolyn.
|
||||
+9
-2
@@ -17,8 +17,15 @@
|
||||
plugin.json single source of truth for plugin metadata
|
||||
src/ the plugin, written as an ordinary Python package
|
||||
main.py entry module — exactly one BasePlugin subclass, absolute imports only
|
||||
greeting/ sample package (proves relative + cross-module imports survive bundling)
|
||||
textutil.py sample top-level sibling module
|
||||
features.py the ghost mode options and their defaults
|
||||
prefs.py storage and resolution: chat -> account -> global
|
||||
network.py request hooks: reads, stories, typing, online status, read on interact
|
||||
sending.py scheduled and silent sending, forward hook, scheduled-screen guard
|
||||
menus.py chat/profile/message/drawer menu items, the chat and account dialogs
|
||||
drawer.py the drawer row for drawer layouts without a Plugins entry
|
||||
settings_page.py the plugin settings screen and the per-account pages
|
||||
peers.py accounts, dialog ids and names
|
||||
hooking.py Xposed and UI-thread helpers
|
||||
tools/
|
||||
bundle.py compiles plugin.json + src/ into dist/<id>.plugin
|
||||
devclient.py speaks the on-device dev-server protocol (127.0.0.1:42690)
|
||||
|
||||
@@ -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",
|
||||
"name": "Hello Bundle",
|
||||
"description": "Multi-file exteraGram plugin built by tools/bundle.py",
|
||||
"id": "ghost_mode",
|
||||
"name": "Ghost Mode",
|
||||
"description": "AyuGram's ghost mode: don't read messages or stories, don't send online or typing, go offline automatically, read on interact, scheduled and silent sending. Every option can be set globally, per account and per chat.",
|
||||
"author": "@lyuksovannyy",
|
||||
"version": "1.0.0",
|
||||
"icon": "exteraGramPluginIcons/5",
|
||||
|
||||
@@ -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}")
|
||||
+52
-37
@@ -1,50 +1,65 @@
|
||||
from base_plugin import BasePlugin, HookResult, HookStrategy
|
||||
from android_utils import log
|
||||
from ui.settings import Header, Input, Text
|
||||
from greeting import render, TemplateError
|
||||
|
||||
DEFAULT_TEMPLATE = "Hello, {name}!"
|
||||
import peers
|
||||
from drawer import Drawer
|
||||
from icons import Icons
|
||||
from menus import Menus
|
||||
from network import Network
|
||||
from prefs import Prefs
|
||||
from sending import Sending
|
||||
from settings_page import SettingsPage
|
||||
|
||||
|
||||
class HelloPlugin(BasePlugin):
|
||||
class GhostModePlugin(BasePlugin):
|
||||
def on_plugin_load(self):
|
||||
log("hello_bundle loaded")
|
||||
self.prefs = Prefs(self, peers.uid_of_slot)
|
||||
self.network = Network(self, self.prefs)
|
||||
self.sending = Sending(self, self.prefs)
|
||||
self.menus = Menus(self, self.prefs, self.network)
|
||||
self.drawer = Drawer(self, self.menus.open_account_dialog)
|
||||
self.icons = Icons(self)
|
||||
self.page = SettingsPage(self, self.prefs, self.menus)
|
||||
# The drawer hook goes in before the menu items, so a drawer rebuild
|
||||
# they cause already has the row.
|
||||
for part in (self.network, self.sending, self.icons, self.drawer, self.menus):
|
||||
try:
|
||||
part.install()
|
||||
except Exception as exc:
|
||||
self.log(f"install {type(part).__name__}: {exc}")
|
||||
self.add_on_send_message_hook()
|
||||
self.log("ghost_mode loaded")
|
||||
|
||||
def on_plugin_unload(self):
|
||||
log("hello_bundle unloaded")
|
||||
for name in ("network", "sending", "icons", "drawer", "menus"):
|
||||
part = getattr(self, name, None)
|
||||
if part is not None:
|
||||
try:
|
||||
part.uninstall()
|
||||
except Exception as exc:
|
||||
self.log(f"uninstall {name}: {exc}")
|
||||
self.log("ghost_mode unloaded")
|
||||
|
||||
def create_settings(self):
|
||||
return [
|
||||
Header(text="Hello Bundle"),
|
||||
Input(
|
||||
key="template",
|
||||
text="Greeting template",
|
||||
default=DEFAULT_TEMPLATE,
|
||||
subtext="Use {name} as placeholder.",
|
||||
icon="msg_edit",
|
||||
),
|
||||
Text(
|
||||
text="Example",
|
||||
subtext=".hello alice smith -> Hello, Alice Smith!",
|
||||
icon="msg_info",
|
||||
),
|
||||
]
|
||||
def pre_request_hook(self, request_name, account, request):
|
||||
try:
|
||||
return self.network.pre(request_name, account, request)
|
||||
except Exception as exc:
|
||||
self.log(f"pre_request_hook {request_name}: {exc}")
|
||||
return HookResult()
|
||||
|
||||
def post_request_hook(self, request_name, account, response, error):
|
||||
try:
|
||||
return self.network.post(request_name, account, response, error)
|
||||
except Exception as exc:
|
||||
self.log(f"post_request_hook {request_name}: {exc}")
|
||||
return HookResult()
|
||||
|
||||
def on_send_message_hook(self, account, params):
|
||||
message = getattr(params, "message", None)
|
||||
if not isinstance(message, str):
|
||||
return HookResult()
|
||||
stripped = message.strip()
|
||||
if not stripped.startswith(".hello"):
|
||||
return HookResult()
|
||||
parts = stripped.split(" ", 1)
|
||||
name = parts[1] if len(parts) > 1 else ""
|
||||
if not name.strip():
|
||||
params.message = "Usage: .hello <name>"
|
||||
else:
|
||||
try:
|
||||
params.message = render(self.get_setting("template", DEFAULT_TEMPLATE), name)
|
||||
except TemplateError as exc:
|
||||
params.message = "Bad template: " + str(exc)
|
||||
if self.sending.on_send_message(account, params):
|
||||
return HookResult(strategy=HookStrategy.MODIFY, params=params)
|
||||
except Exception as exc:
|
||||
self.log(f"on_send_message_hook: {exc}")
|
||||
return HookResult()
|
||||
|
||||
def create_settings(self):
|
||||
return self.page.build()
|
||||
+256
@@ -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())
|
||||
+1147
-101
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user