generated from exteraGramPlugins/pluginBase
[nobump] Unlocked Restrictions: screenshots, copy-forwarding, protected-chat lock
Publish plugin / publish (push) Successful in 4s
Publish plugin / publish (push) Successful in 4s
Replaces the pluginBase sample with the plugin: - remove FLAG_SECURE (FlagSecureReason + Window.setFlags) for screenshots - unlock forward/copy/save UI in protected chats and re-send protected messages as copies from the user's account (media re-uploaded, albums regrouped, bot-only parts dropped) - red lock before protected chats' names in the chat list, header and info - settings toggles for all three - verify_bundle.py rewritten for the plugin - bundle.py enables the git hooks on first run; README documents them Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
e0586a3423
commit
5405adf485
16 files changed
+1296
-127
No files matched your search
@@ -1,7 +1,56 @@
|
||||
# exteraGram Plugin Base
|
||||
# Unlocked Restrictions
|
||||
|
||||
A build toolchain and ADB automation for writing [exteraGram](https://exteragram.app) plugins
|
||||
as ordinary multi-file Python projects instead of hand-maintaining one giant `.py` file.
|
||||
An [exteraGram](https://exteragram.app) plugin that lifts the "restrict saving content"
|
||||
protection on your own device: screenshots work everywhere, messages from protected chats can
|
||||
be forwarded (as copies), and protected chats are marked with a red lock.
|
||||
|
||||
## Features
|
||||
|
||||
Each feature has its own switch in the plugin settings, all on by default, and toggles take
|
||||
effect without reloading the plugin.
|
||||
|
||||
- **Allow screenshots.** Removes `FLAG_SECURE`, so screenshots and screen recording work in
|
||||
protected chats, secret chats and on the passcode screen.
|
||||
- **Allow forwarding.** The forward, copy and save options appear in protected chats. Forwarding
|
||||
a protected message, from the long-press menu or the side share button, sends it to the chosen
|
||||
chat as a new message from your account, with no "Forwarded from" header. Unprotected
|
||||
messages in the same selection are still forwarded normally.
|
||||
- **Mark protected chats.** A red lock before the chat's name in the chat list, the chat header
|
||||
and the chat info screen.
|
||||
|
||||
### How copying works
|
||||
|
||||
Telegram refuses to forward protected content, and also to re-send its media by file id, so a
|
||||
copy is sent the same way as a message you wrote:
|
||||
|
||||
- text keeps its formatting; photos, videos, files, voice messages and GIFs are downloaded
|
||||
(or taken from the local cache) and uploaded again; stickers, locations and contacts are sent
|
||||
directly;
|
||||
- albums are sent as media groups again (at most 10 items per group);
|
||||
- bot-only parts are dropped: reply and inline keyboards, games, invoices; content that can't
|
||||
be reproduced (polls, dice, stories, giveaways, paid media) is skipped, and only its text is
|
||||
sent if there is any;
|
||||
- the selection is sent once every item is ready, so one large download delays the batch;
|
||||
- items that can't be copied are reported in a bulletin.
|
||||
|
||||
Copied videos have no preview thumbnail. The lock covers groups and channels, not private chats
|
||||
where the other person enabled content protection.
|
||||
|
||||
## Runtime notes
|
||||
|
||||
Things the on-device runtime does differently from the SDK docs, all handled in `src/`:
|
||||
|
||||
- Java lists are not iterable from Python; use `hooking.java_list()`.
|
||||
- `param.setResult()` in a before-hook does not skip the original method, so the forward hook
|
||||
empties the message list instead.
|
||||
- `threading.local` state is not shared between a before-hook and its after-hook.
|
||||
- Java `String` values arrive as Python `str`, other `CharSequence`s as Java objects.
|
||||
- Fields named like `java.lang.Object` methods (`SendMessageParams.notify`) must be set with
|
||||
`hook_utils.set_private_field`.
|
||||
- Exceptions escaping a Python thread vanish silently, so the copy worker catches everything.
|
||||
- Shared TL objects (`TLRPC.Chat` and friends) are never modified for display purposes.
|
||||
|
||||
The rest of this README documents the build toolchain inherited from pluginBase.
|
||||
|
||||
## How exteraGram plugins actually work
|
||||
|
||||
@@ -34,15 +83,19 @@ This repo does not use the official Elyx/ElyxBuilder archive format — see
|
||||
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
|
||||
screenshots.py FLAG_SECURE removal (FlagSecureReason + Window.setFlags hooks)
|
||||
forwarding.py unlocks forward UI and intercepts forwards of protected messages
|
||||
copier.py re-sends protected messages as copies (downloads + re-uploads media, albums)
|
||||
badge.py red lock before protected chats' names (chat list, header, chat info)
|
||||
protection.py shared "does this chat restrict saving content" check
|
||||
hooking.py shared Xposed hook 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)
|
||||
verify_bundle.py offline harness that proves a built bundle behaves correctly, no phone needed
|
||||
release.py publishes a built bundle to a Gitea release (used by CI)
|
||||
.gitea/workflows/ CI that builds, verifies and publishes the .plugin to Releases
|
||||
scripts/ bash automation for the ADB debug loop (see below)
|
||||
scripts/ ADB debug-loop automation and the git hooks (see below)
|
||||
docs/sdk-notes.md condensed SDK API surface gathered from the official docs
|
||||
```
|
||||
|
||||
@@ -61,6 +114,8 @@ a private import hook (scoped to a per-module `__builtins__` dict, never touchin
|
||||
files were still on disk. `--dev` (the default) embeds absolute local paths so a debugger
|
||||
resolves breakpoints against your real source files; `--release` embeds repo-relative paths
|
||||
for distribution. Both modes produce byte-reproducible output for a given `src/` + `plugin.json`.
|
||||
Its first run in a clone also enables the repo's git hooks (see
|
||||
[Git hooks and versioning](#git-hooks-and-versioning)).
|
||||
|
||||
## Device prerequisites
|
||||
|
||||
@@ -93,6 +148,43 @@ standard `ANDROID_SERIAL` env var for device selection.
|
||||
- `scripts/fetch-sdk-stubs.sh [--beta]` — downloads the latest SDK type stubs into `stubs/`
|
||||
for editor autocomplete.
|
||||
|
||||
## Git hooks and versioning
|
||||
|
||||
`scripts/pre-commit` bumps the patch number of `version` in `plugin.json` on every commit
|
||||
(`1.0.0` → `1.0.1`) and stages it into that same commit. `scripts/post-commit` re-stages
|
||||
`plugin.json` afterwards, because commits of a file selection (`git commit -- <paths>`, which
|
||||
some IDEs use) are built from a temporary index and would otherwise leave it looking modified.
|
||||
|
||||
**The hooks enable themselves.** Git never activates hooks from repository content, so a
|
||||
fresh clone, or a repo created from this template, starts without them. The first run of
|
||||
`tools/bundle.py` fixes that by running `git config core.hooksPath scripts`, and every
|
||||
workflow runs it: directly, or through `scripts/deploy.sh` and `scripts/watch.sh`. That
|
||||
includes `--check`, so after cloning run
|
||||
|
||||
```bash
|
||||
python3 tools/bundle.py --check
|
||||
```
|
||||
|
||||
once, before your first commit. A commit made before any build runs without the hooks.
|
||||
`bundle.py` leaves `core.hooksPath` alone when it is already set (so pointing it elsewhere on
|
||||
purpose sticks), skips the step in CI (whenever `CI` is set) and outside a git work tree, and
|
||||
never fails a build over it. To turn the hooks off for a clone, run
|
||||
`git config core.hooksPath .git/hooks`; `git config --unset core.hooksPath` only lasts
|
||||
until the next build.
|
||||
|
||||
The bump is skipped when:
|
||||
|
||||
- the commit message contains `[no bump]`, `[skip bump]`, `[no version bump]` or
|
||||
`[skip version]` (case-insensitive; `nobump`, `no-bump` and `no_bump` also work),
|
||||
- `NO_BUMP=1` (or `GIT_NO_BUMP=1`) is set, e.g. `NO_BUMP=1 git commit`, which is the only way
|
||||
for commits written in an editor, since their message doesn't exist yet when the hook runs,
|
||||
- `version` in `plugin.json` already differs from `HEAD` (you bumped it by hand, e.g. for a
|
||||
minor or major release),
|
||||
- a merge, rebase, cherry-pick or revert is in progress.
|
||||
|
||||
Because every version change publishes a release (see below), a normal commit + push to
|
||||
`main` ships a new patch release. Use a stop word for commits that should not.
|
||||
|
||||
## Publishing a release
|
||||
|
||||
`.gitea/workflows/release.yml` builds, verifies and publishes the `.plugin` to the repo's
|
||||
@@ -108,7 +200,8 @@ The `version` field in `plugin.json` is the single source of truth:
|
||||
| the same `version` | rebuilds the existing release, replacing its attachment |
|
||||
|
||||
So every push leaves a downloadable build attached to the current version, and bumping
|
||||
`version` is what cuts a new release. Pushing a `v*` tag by hand still works and uses that
|
||||
`version` is what cuts a new release. With the git hooks enabled every commit bumps it,
|
||||
so every push of a normal commit cuts a new patch release. Pushing a `v*` tag by hand still works and uses that
|
||||
tag verbatim, as does a manual run (Actions → Publish plugin → Run workflow).
|
||||
|
||||
Every path runs `--check`, builds with `--release`, and gates the upload on
|
||||
@@ -140,9 +233,11 @@ you want when publishing needs to reach beyond this repo.
|
||||
|
||||
## Writing your own plugin
|
||||
|
||||
1. Edit `plugin.json` (id, name, description, etc.).
|
||||
2. Write your plugin under `src/`, with `src/main.py` as the entry module (absolute imports
|
||||
1. Run `python3 tools/bundle.py --check` once. It enables the git hooks that auto-bump the
|
||||
version on commit (see [Git hooks and versioning](#git-hooks-and-versioning)).
|
||||
2. Edit `plugin.json` (id, name, description, etc.).
|
||||
3. Write your plugin under `src/`, with `src/main.py` as the entry module (absolute imports
|
||||
only — it's executed at plugin-module top level, not inside a package).
|
||||
3. `python3 tools/bundle.py --release && python3 tools/verify_bundle.py dist/<id>.plugin`
|
||||
4. `python3 tools/bundle.py --release && python3 tools/verify_bundle.py dist/<id>.plugin`
|
||||
(substitute your `id` from `plugin.json`).
|
||||
4. `bash scripts/deploy.sh --enable` with a device connected.
|
||||
5. `bash scripts/deploy.sh --enable` with a device connected.
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"id": "hello_bundle",
|
||||
"name": "Hello Bundle",
|
||||
"description": "Multi-file exteraGram plugin built by tools/bundle.py",
|
||||
"author": "@yourhandle",
|
||||
"id": "unlocked_restrictions",
|
||||
"name": "Unlocked Restrictions",
|
||||
"description": "Allows screenshots and forwarding (as a copy) in chats with protected content",
|
||||
"author": "lyuksovannyy",
|
||||
"version": "1.0.0",
|
||||
"icon": "exteraPlugins/1",
|
||||
"app_version": ">=12.5.1",
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
#!/bin/sh
|
||||
# Companion to scripts/pre-commit.
|
||||
# Companion to scripts/pre-commit. Both are enabled per clone by tools/bundle.py
|
||||
# (it sets `core.hooksPath scripts` on its first run).
|
||||
#
|
||||
# `git commit -- <pathspec>` (also how some IDEs commit a file selection) builds
|
||||
# the commit from a temporary index. The pre-commit bump therefore lands in the
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Increment the patch component of plugin.json's version as part of the commit.
|
||||
|
||||
Enabled per clone by tools/bundle.py, which sets `core.hooksPath scripts` on its
|
||||
first run (git never enables hooks from repository content by itself).
|
||||
|
||||
Runs as a pre-commit hook, which is the only commit hook whose `git add` is
|
||||
picked up by the commit currently being created:
|
||||
|
||||
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
"""Red lock in front of the names of chats that restrict saving content.
|
||||
|
||||
Shown in the chat list (DialogCell), the chat header (ChatAvatarContainer)
|
||||
and the chat info screen (ProfileActivity). Chat objects themselves are never
|
||||
modified: they are shared app-wide and may be written to the database at any
|
||||
moment, so only the text handed to the views is changed.
|
||||
|
||||
Hook state must not live in threading.local: in this runtime a before-hook
|
||||
and its after-hook do not see the same thread-local storage.
|
||||
"""
|
||||
from base_plugin import MethodHook
|
||||
from hook_utils import get_private_field
|
||||
from hooking import hook_all
|
||||
from protection import chat_is_protected
|
||||
|
||||
# An em space (replaced by the lock) plus a normal space as the gap before the
|
||||
# name. The lock span is exactly as wide as the em space, so text measured
|
||||
# with the placeholder keeps its width once the lock is swapped in.
|
||||
PLACEHOLDER = " "
|
||||
|
||||
# Title of the protected chat whose DialogCell is being laid out right now,
|
||||
# or None. DialogCell.buildLayout() runs on the UI thread and does not nest.
|
||||
_building_title = None
|
||||
|
||||
|
||||
def lock_span(paint):
|
||||
"""An ImageSpan drawing a red lock exactly one em space wide for `paint`."""
|
||||
from android.graphics import PorterDuff, PorterDuffColorFilter
|
||||
from android.text.style import ImageSpan
|
||||
from org.telegram.messenger import ApplicationLoader, R
|
||||
from org.telegram.ui.ActionBar import Theme
|
||||
|
||||
drawable = ApplicationLoader.applicationContext.getResources().getDrawable(R.drawable.list_secret).mutate()
|
||||
drawable.setColorFilter(
|
||||
PorterDuffColorFilter(Theme.getColor(Theme.key_text_RedBold), PorterDuff.Mode.SRC_IN)
|
||||
)
|
||||
box = int(round(paint.measureText(PLACEHOLDER[0])))
|
||||
height = int(round(paint.getTextSize() * 0.75))
|
||||
ratio = drawable.getIntrinsicWidth() / max(1, drawable.getIntrinsicHeight())
|
||||
width = min(box, max(1, round(height * ratio)))
|
||||
# ImageSpan reports its width as bounds.right, so right-aligning the lock
|
||||
# in the box keeps the span exactly `box` wide.
|
||||
drawable.setBounds(box - width, 0, box, height)
|
||||
return ImageSpan(drawable, ImageSpan.ALIGN_BASELINE)
|
||||
|
||||
|
||||
def with_lock(text, paint):
|
||||
"""`text` with the lock in front; a leftover bare placeholder is replaced."""
|
||||
from android.text import SpannableStringBuilder, Spanned
|
||||
|
||||
# Java Strings arrive as Python str, other CharSequences as Java objects.
|
||||
if str(text).startswith(PLACEHOLDER):
|
||||
if isinstance(text, str):
|
||||
text = text[len(PLACEHOLDER):]
|
||||
else:
|
||||
text = text.subSequence(len(PLACEHOLDER), text.length())
|
||||
builder = SpannableStringBuilder(PLACEHOLDER)
|
||||
builder.append(text)
|
||||
builder.setSpan(lock_span(paint), 0, 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
return builder
|
||||
|
||||
|
||||
def has_lock(text):
|
||||
"""True when `text` already starts with the lock span."""
|
||||
from android.text import Spanned
|
||||
from android.text.style import ImageSpan
|
||||
|
||||
if text is None or not str(text).startswith(PLACEHOLDER) or not isinstance(text, Spanned):
|
||||
return False
|
||||
return len(text.getSpans(0, 1, ImageSpan)) > 0
|
||||
|
||||
|
||||
def _protected_chat(account, dialog_id):
|
||||
from org.telegram.messenger import MessagesController
|
||||
|
||||
if account is None or dialog_id is None or dialog_id >= 0:
|
||||
return None
|
||||
controller = MessagesController.getInstance(account)
|
||||
chat = controller.getChat(-dialog_id)
|
||||
return chat if chat_is_protected(controller, chat) else None
|
||||
|
||||
|
||||
class DialogCellNameHook(MethodHook):
|
||||
"""DialogCell.buildLayout(): lay the name out with room for the lock.
|
||||
|
||||
Everything positioned after the name (verified badge, mute icon) is
|
||||
computed inside buildLayout(), so the space has to exist while it runs:
|
||||
EscapeHook prefixes the chat's title with PLACEHOLDER when buildLayout()
|
||||
passes it through AndroidUtilities.escape(). Afterwards the placeholder in
|
||||
the finished single-line layout is swapped for the lock.
|
||||
"""
|
||||
|
||||
def __init__(self, plugin, enabled):
|
||||
self.plugin = plugin
|
||||
self.enabled = enabled
|
||||
|
||||
def before_hooked_method(self, param):
|
||||
global _building_title
|
||||
_building_title = None
|
||||
if not self.enabled():
|
||||
return
|
||||
try:
|
||||
cell = param.thisObject
|
||||
chat = _protected_chat(
|
||||
get_private_field(cell, "currentAccount"),
|
||||
get_private_field(cell, "currentDialogId"),
|
||||
)
|
||||
if chat is not None and chat.title:
|
||||
_building_title = chat.title
|
||||
except Exception as exc:
|
||||
self.plugin.log(f"lock badge: {exc}")
|
||||
|
||||
def after_hooked_method(self, param):
|
||||
global _building_title
|
||||
if _building_title is None:
|
||||
return
|
||||
_building_title = None
|
||||
try:
|
||||
self._swap_in_lock(param.thisObject)
|
||||
except Exception as exc:
|
||||
self.plugin.log(f"lock badge: {exc}")
|
||||
|
||||
@staticmethod
|
||||
def _swap_in_lock(cell):
|
||||
from android.text import Layout, StaticLayout
|
||||
|
||||
layout = cell.nameLayout
|
||||
if layout is None or layout.getLineCount() != 1:
|
||||
return
|
||||
text = layout.getText()
|
||||
if not str(text).startswith(PLACEHOLDER):
|
||||
return
|
||||
paint = layout.getPaint()
|
||||
cell.nameLayout = StaticLayout(
|
||||
with_lock(text, paint),
|
||||
paint,
|
||||
layout.getWidth(),
|
||||
Layout.Alignment.ALIGN_NORMAL,
|
||||
1.0,
|
||||
0.0,
|
||||
False,
|
||||
)
|
||||
|
||||
|
||||
class EscapeHook(MethodHook):
|
||||
"""AndroidUtilities.escape(String): see DialogCellNameHook."""
|
||||
|
||||
def after_hooked_method(self, param):
|
||||
title = _building_title
|
||||
if title is None or param.args[0] != title:
|
||||
return
|
||||
result = param.getResult()
|
||||
if result is not None and not str(result).startswith(PLACEHOLDER):
|
||||
param.setResult(PLACEHOLDER + str(result))
|
||||
|
||||
|
||||
class ChatHeaderTitleHook(MethodHook):
|
||||
"""ChatAvatarContainer.setTitle(CharSequence, boolean scam, boolean fake,
|
||||
boolean verified, boolean premium, EmojiStatus, boolean animated). The
|
||||
one-argument overload delegates here, so only the long one is handled."""
|
||||
|
||||
def __init__(self, plugin, enabled):
|
||||
self.plugin = plugin
|
||||
self.enabled = enabled
|
||||
|
||||
def before_hooked_method(self, param):
|
||||
args = param.args
|
||||
if not self.enabled() or len(args) < 7 or args[0] is None or has_lock(args[0]):
|
||||
return
|
||||
try:
|
||||
container = param.thisObject
|
||||
fragment = get_private_field(container, "parentFragment")
|
||||
if fragment is None:
|
||||
return
|
||||
if _protected_chat(fragment.getCurrentAccount(), fragment.getDialogId()) is None:
|
||||
return
|
||||
paint = get_private_field(container, "titleTextView").getPaint()
|
||||
args[0] = with_lock(args[0], paint)
|
||||
except Exception as exc:
|
||||
self.plugin.log(f"lock badge: {exc}")
|
||||
|
||||
|
||||
class ProfileNameHook(MethodHook):
|
||||
"""ProfileActivity.updateProfileData(boolean): prefix the chat info name."""
|
||||
|
||||
def __init__(self, plugin, enabled):
|
||||
self.plugin = plugin
|
||||
self.enabled = enabled
|
||||
|
||||
def after_hooked_method(self, param):
|
||||
if not self.enabled():
|
||||
return
|
||||
try:
|
||||
profile = param.thisObject
|
||||
chat_id = get_private_field(profile, "chatId")
|
||||
if not chat_id or _protected_chat(profile.getCurrentAccount(), -chat_id) is None:
|
||||
return
|
||||
views = get_private_field(profile, "nameTextView")
|
||||
for i in range(len(views) if views is not None else 0):
|
||||
view = views[i]
|
||||
if view is None:
|
||||
continue
|
||||
text = view.getText()
|
||||
if text is not None and len(str(text)) > 0 and not has_lock(text):
|
||||
view.setText(with_lock(text, view.getPaint()))
|
||||
except Exception as exc:
|
||||
self.plugin.log(f"lock badge: {exc}")
|
||||
|
||||
|
||||
def install(plugin, enabled):
|
||||
hooked = hook_all(plugin, "org.telegram.ui.Cells.DialogCell", "buildLayout", DialogCellNameHook(plugin, enabled))
|
||||
hooked += hook_all(plugin, "org.telegram.messenger.AndroidUtilities", "escape", EscapeHook())
|
||||
hooked += hook_all(
|
||||
plugin, "org.telegram.ui.Components.ChatAvatarContainer", "setTitle", ChatHeaderTitleHook(plugin, enabled)
|
||||
)
|
||||
hooked += hook_all(plugin, "org.telegram.ui.ProfileActivity", "updateProfileData", ProfileNameHook(plugin, enabled))
|
||||
return hooked
|
||||
|
||||
|
||||
def refresh_dialogs():
|
||||
"""Asks the open chat list to rebuild its rows so a toggle shows at once."""
|
||||
from client_utils import get_notification_center
|
||||
from org.telegram.messenger import MessagesController, NotificationCenter
|
||||
|
||||
get_notification_center().postNotificationName(
|
||||
NotificationCenter.updateInterfaces, MessagesController.UPDATE_MASK_NAME
|
||||
)
|
||||
+315
@@ -0,0 +1,315 @@
|
||||
"""Re-sends messages from protected chats as fresh copies.
|
||||
|
||||
Telegram's server refuses to forward protected content, and also refuses to
|
||||
re-send its media by file id, so every file is downloaded (or taken from the
|
||||
local cache) and uploaded again as a new file.
|
||||
"""
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from android_utils import log, run_on_ui_thread
|
||||
from hook_utils import set_private_field
|
||||
from hooking import java_list
|
||||
from protection import chat_is_protected
|
||||
|
||||
DOWNLOAD_TIMEOUT_S = 600
|
||||
DOWNLOAD_POLL_S = 0.5
|
||||
MAX_ALBUM_SIZE = 10
|
||||
|
||||
|
||||
class UnsupportedMessage(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def notify_error(text):
|
||||
from ui.bulletin import BulletinHelper
|
||||
|
||||
BulletinHelper.show_error(text)
|
||||
|
||||
|
||||
def notify_info(text):
|
||||
from ui.bulletin import BulletinHelper
|
||||
|
||||
BulletinHelper.show_info(text)
|
||||
|
||||
|
||||
def is_protected(message):
|
||||
"""True when Telegram would refuse to forward `message`.
|
||||
|
||||
Reads the raw `noforwards` fields instead of calling
|
||||
MessagesController.isChatNoForwards() and friends, since the plugin hooks
|
||||
those to return false.
|
||||
"""
|
||||
from org.telegram.messenger import MessagesController
|
||||
|
||||
owner = message.messageOwner
|
||||
if owner is None:
|
||||
return False
|
||||
if owner.noforwards:
|
||||
return True
|
||||
controller = MessagesController.getInstance(message.currentAccount)
|
||||
dialog_id = message.getDialogId()
|
||||
if dialog_id < 0:
|
||||
return chat_is_protected(controller, controller.getChat(-dialog_id))
|
||||
user_full = controller.getUserFull(dialog_id)
|
||||
if user_full is None:
|
||||
return False
|
||||
return bool(
|
||||
getattr(user_full, "noforwards_peer_enabled", False)
|
||||
or getattr(user_full, "noforwards_my_enabled", False)
|
||||
)
|
||||
|
||||
|
||||
def copy_messages(plugin, messages, peer, hide_caption, notify, schedule_date, top_msg):
|
||||
"""Starts copying `messages` to `peer` in the background. Call on the UI thread."""
|
||||
count = len(messages)
|
||||
notify_info(f"Copying {count} protected message{'s' if count != 1 else ''}…")
|
||||
|
||||
def copy_all():
|
||||
failed = 0
|
||||
built = [] # (message, params, has_media), in chat order
|
||||
for message in sorted(messages, key=lambda m: m.getId()):
|
||||
try:
|
||||
params, has_media = _build_params(message, peer, hide_caption)
|
||||
except UnsupportedMessage as exc:
|
||||
plugin.log(f"skipping message {message.getId()}: {exc}")
|
||||
failed += 1
|
||||
continue
|
||||
except Exception as exc:
|
||||
plugin.log(f"failed to prepare message {message.getId()}: {exc}")
|
||||
failed += 1
|
||||
continue
|
||||
# `notify` clashes with Object.notify() in Chaquopy, so plain
|
||||
# attribute assignment fails; set the field through reflection.
|
||||
set_private_field(params, "notify", notify)
|
||||
params.scheduleDate = schedule_date
|
||||
if top_msg is not None:
|
||||
params.replyToMsg = top_msg
|
||||
params.replyToTopMsg = top_msg
|
||||
built.append((message, params, has_media))
|
||||
|
||||
_group_albums(built)
|
||||
for message, params, _ in built:
|
||||
_send_on_ui_thread(message.currentAccount, params)
|
||||
if failed:
|
||||
notify_error(f"{failed} of {count} message{'s' if count != 1 else ''} could not be copied")
|
||||
|
||||
def worker():
|
||||
# An exception escaping a Python thread is silently lost on device.
|
||||
try:
|
||||
copy_all()
|
||||
except Exception:
|
||||
plugin.log("copy worker crashed: " + traceback.format_exc())
|
||||
notify_error("Copying protected messages failed")
|
||||
|
||||
threading.Thread(target=worker, name="unlocked-restrictions-copy", daemon=True).start()
|
||||
|
||||
|
||||
def album_runs(keys):
|
||||
"""Splits a sequence of album keys into runs of consecutive equal non-zero
|
||||
keys, at most MAX_ALBUM_SIZE long. Returns (start, end) index pairs for
|
||||
runs of two or more; key 0 means "not part of an album"."""
|
||||
runs = []
|
||||
i = 0
|
||||
while i < len(keys):
|
||||
j = i + 1
|
||||
while j < len(keys) and keys[i] != 0 and keys[j] == keys[i] and j - i < MAX_ALBUM_SIZE:
|
||||
j += 1
|
||||
if j - i > 1:
|
||||
runs.append((i, j))
|
||||
i = j
|
||||
return runs
|
||||
|
||||
|
||||
def _group_albums(built):
|
||||
"""Tags copies of album items so they are sent as one media group again.
|
||||
|
||||
SendMessagesHelper groups messages whose params share a "groupId" and
|
||||
sends the whole group once it sees the one marked "final" — the same
|
||||
mechanism the attach menu uses. Items whose media had to be dropped are
|
||||
sent on their own.
|
||||
"""
|
||||
from java.util import HashMap
|
||||
|
||||
keys = [
|
||||
message.messageOwner.grouped_id if has_media else 0 for message, _, has_media in built
|
||||
]
|
||||
for start, end in album_runs(keys):
|
||||
group_id = str(random.getrandbits(62) + 1)
|
||||
for index in range(start, end):
|
||||
params = built[index][1]
|
||||
params.params = HashMap()
|
||||
params.params.put("groupId", group_id)
|
||||
if index == end - 1:
|
||||
params.params.put("final", "1")
|
||||
|
||||
|
||||
def _send_on_ui_thread(account, params):
|
||||
from org.telegram.messenger import SendMessagesHelper
|
||||
|
||||
done = threading.Event()
|
||||
|
||||
def send():
|
||||
try:
|
||||
SendMessagesHelper.getInstance(account).sendMessage(params)
|
||||
except Exception as exc:
|
||||
log(f"[unlocked_restrictions] send failed: {exc}")
|
||||
finally:
|
||||
done.set()
|
||||
|
||||
run_on_ui_thread(send)
|
||||
# Queue sends one at a time so copies keep the original order.
|
||||
done.wait(30)
|
||||
|
||||
|
||||
def _new_params(text, peer):
|
||||
from org.telegram.messenger import SendMessagesHelper
|
||||
|
||||
return SendMessagesHelper.SendMessageParams.of(text, peer)
|
||||
|
||||
|
||||
def _build_params(message, peer, hide_caption):
|
||||
"""Builds a plain user-account send of `message`'s content.
|
||||
|
||||
Only what a regular client can send is copied. Bot-only parts (reply and
|
||||
inline keyboards, games, invoices) and content that cannot be reproduced
|
||||
(polls, dice, stories, giveaways, paid media) are dropped; if the message
|
||||
has text, that text is still sent on its own.
|
||||
"""
|
||||
from org.telegram.messenger import MessageObject
|
||||
from org.telegram.tgnet import TLRPC
|
||||
|
||||
owner = message.messageOwner
|
||||
media = MessageObject.getMedia(owner)
|
||||
text = owner.message or ""
|
||||
entities = owner.entities
|
||||
|
||||
params = _new_params(None, peer)
|
||||
has_media = True
|
||||
if isinstance(media, TLRPC.TL_messageMediaPhoto) and media.photo is not None:
|
||||
params.photo = _reupload_photo(message, media.photo)
|
||||
elif isinstance(media, TLRPC.TL_messageMediaDocument) and media.document is not None:
|
||||
if message.isAnyKindOfSticker():
|
||||
# Stickers belong to public sets, so the original file id is fine.
|
||||
params.document = media.document
|
||||
else:
|
||||
params.document, params.path = _reupload_document(message, media.document)
|
||||
elif isinstance(media, (TLRPC.TL_messageMediaGeo, TLRPC.TL_messageMediaVenue)):
|
||||
params.location = media
|
||||
elif isinstance(media, TLRPC.TL_messageMediaContact):
|
||||
params.user = _contact_user(media)
|
||||
else:
|
||||
# No media, a link preview (regenerated from the text), or media a
|
||||
# user account cannot send.
|
||||
has_media = False
|
||||
|
||||
if has_media:
|
||||
if text and not hide_caption:
|
||||
params.caption = text
|
||||
params.entities = entities
|
||||
params.invert_media = bool(getattr(owner, "invert_media", False))
|
||||
params.hasMediaSpoilers = bool(getattr(media, "spoiler", False))
|
||||
else:
|
||||
if not text:
|
||||
kind = media.getClass().getSimpleName() if media is not None else "empty message"
|
||||
raise UnsupportedMessage(f"nothing a user account can send ({kind})")
|
||||
params = _new_params(text, peer)
|
||||
params.entities = entities
|
||||
|
||||
# Never carry over bot-only fields.
|
||||
params.replyMarkup = None
|
||||
params.params = None
|
||||
params.webPage = None
|
||||
return params, has_media
|
||||
|
||||
|
||||
def _contact_user(media):
|
||||
from org.telegram.tgnet import TLRPC
|
||||
|
||||
user = TLRPC.TL_user()
|
||||
user.id = media.user_id
|
||||
user.phone = media.phone_number
|
||||
user.first_name = media.first_name or ""
|
||||
user.last_name = media.last_name or ""
|
||||
return user
|
||||
|
||||
|
||||
def _reupload_photo(message, photo):
|
||||
from org.telegram.messenger import FileLoader, ImageLocation, SendMessagesHelper
|
||||
|
||||
if photo is None:
|
||||
raise UnsupportedMessage("photo is missing")
|
||||
best = None
|
||||
for size in java_list(photo.sizes):
|
||||
if size.location is None or size.w <= 0 or size.h <= 0:
|
||||
continue
|
||||
if best is None or size.w * size.h > best.w * best.h:
|
||||
best = size
|
||||
if best is None:
|
||||
raise UnsupportedMessage("photo has no downloadable size")
|
||||
|
||||
account = message.currentAccount
|
||||
loader = FileLoader.getInstance(account)
|
||||
candidates = [loader.getPathToAttach(best, True), loader.getPathToAttach(best, False)]
|
||||
path = _existing(candidates)
|
||||
if path is None:
|
||||
loader.loadFile(
|
||||
ImageLocation.getForPhoto(best, photo), message, "jpg", FileLoader.PRIORITY_HIGH, 0
|
||||
)
|
||||
path = _wait_for_file(candidates)
|
||||
|
||||
new_photo = SendMessagesHelper.getInstance(account).generatePhotoSizes(path, None)
|
||||
if new_photo is None:
|
||||
raise UnsupportedMessage("could not re-encode photo")
|
||||
return new_photo
|
||||
|
||||
|
||||
def _reupload_document(message, document):
|
||||
from java.util import ArrayList
|
||||
from org.telegram.messenger import FileLoader
|
||||
from org.telegram.tgnet import ConnectionsManager, TLRPC
|
||||
|
||||
account = message.currentAccount
|
||||
loader = FileLoader.getInstance(account)
|
||||
candidates = [loader.getPathToAttach(document, True), loader.getPathToAttach(document, False)]
|
||||
attach_path = message.messageOwner.attachPath
|
||||
if attach_path:
|
||||
from java.io import File
|
||||
|
||||
candidates.insert(0, File(attach_path))
|
||||
path = _existing(candidates)
|
||||
if path is None:
|
||||
loader.loadFile(document, message, FileLoader.PRIORITY_HIGH, 0)
|
||||
path = _wait_for_file(candidates)
|
||||
|
||||
# id == 0 and access_hash == 0 make SendMessagesHelper upload `path` as a
|
||||
# new file instead of referencing the protected original.
|
||||
copy = TLRPC.TL_document()
|
||||
copy.id = 0
|
||||
copy.access_hash = 0
|
||||
copy.file_reference = bytes()
|
||||
copy.date = ConnectionsManager.getInstance(account).getCurrentTime()
|
||||
copy.mime_type = document.mime_type
|
||||
copy.size = document.size
|
||||
copy.dc_id = 0
|
||||
copy.attributes = ArrayList(document.attributes)
|
||||
return copy, path
|
||||
|
||||
|
||||
def _existing(files):
|
||||
for f in files:
|
||||
if f is not None and f.exists() and f.length() > 0:
|
||||
return f.getAbsolutePath()
|
||||
return None
|
||||
|
||||
|
||||
def _wait_for_file(files):
|
||||
deadline = time.monotonic() + DOWNLOAD_TIMEOUT_S
|
||||
while time.monotonic() < deadline:
|
||||
path = _existing(files)
|
||||
if path is not None:
|
||||
return path
|
||||
time.sleep(DOWNLOAD_POLL_S)
|
||||
raise UnsupportedMessage("download timed out")
|
||||
@@ -0,0 +1,122 @@
|
||||
from base_plugin import MethodHook
|
||||
from hooking import ReturnFalseHook, hook_all, java_list
|
||||
import copier
|
||||
|
||||
|
||||
class CanForwardHook(MethodHook):
|
||||
"""MessageObject.canForwardMessage(): evaluate as if `noforwards` were unset.
|
||||
|
||||
When the original says no and the message is flagged `noforwards`, the
|
||||
method is called again with the flag cleared and the flag is restored
|
||||
straight away, because copier.is_protected() still needs to see it. (A
|
||||
before/after pair can't carry the message between the two callbacks in
|
||||
this runtime.) The nested call sees `noforwards` unset, so it does not
|
||||
recurse further.
|
||||
"""
|
||||
|
||||
def __init__(self, enabled):
|
||||
self.enabled = enabled
|
||||
|
||||
def after_hooked_method(self, param):
|
||||
if param.getResult() or not self.enabled():
|
||||
return
|
||||
message = param.thisObject
|
||||
owner = getattr(message, "messageOwner", None)
|
||||
if owner is None or not owner.noforwards:
|
||||
return
|
||||
owner.noforwards = False
|
||||
try:
|
||||
param.setResult(bool(param.method.invoke(message)))
|
||||
finally:
|
||||
owner.noforwards = True
|
||||
|
||||
|
||||
class ForwardInterceptHook(MethodHook):
|
||||
"""SendMessagesHelper.sendMessage(ArrayList<MessageObject> messages, long peer,
|
||||
boolean forwardFromMyName, boolean hideCaption, boolean notify, int scheduleDate, ...)
|
||||
|
||||
Protected messages are never forwarded: they are pulled out of the list
|
||||
and re-sent as ordinary messages from the user's account (see copier);
|
||||
the rest are forwarded as usual. Every overload shares the leading six
|
||||
parameters, and the shorter ones delegate to the longest, so the outermost
|
||||
call does the split and the inner calls see only unprotected messages.
|
||||
"""
|
||||
|
||||
def __init__(self, plugin, enabled):
|
||||
self.plugin = plugin
|
||||
self.enabled = enabled
|
||||
|
||||
def before_hooked_method(self, param):
|
||||
if not self.enabled():
|
||||
return
|
||||
from java.util import ArrayList
|
||||
|
||||
args = param.args
|
||||
if len(args) < 6 or not isinstance(args[0], ArrayList) or args[0].isEmpty():
|
||||
return
|
||||
flagged = [(m, self._is_protected(m)) for m in java_list(args[0])]
|
||||
protected = [m for m, is_protected in flagged if is_protected]
|
||||
if not protected:
|
||||
return
|
||||
|
||||
# Removing the protected messages must happen even if copying fails,
|
||||
# otherwise the original sendMessage() would forward them. setResult()
|
||||
# does not reliably skip the original here, so the list is replaced
|
||||
# instead: with nothing left, sendMessage() returns early on its own
|
||||
# isEmpty() check, and the overloads it delegates to get the same list.
|
||||
remaining = ArrayList()
|
||||
for m, is_protected in flagged:
|
||||
if not is_protected:
|
||||
remaining.add(m)
|
||||
args[0] = remaining
|
||||
|
||||
try:
|
||||
copier.copy_messages(
|
||||
plugin=self.plugin,
|
||||
messages=protected,
|
||||
peer=int(args[1]),
|
||||
hide_caption=bool(args[3]),
|
||||
notify=bool(args[4]),
|
||||
schedule_date=int(args[5]),
|
||||
top_msg=self._topic_root(args),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.plugin.log(f"copy failed: {exc}")
|
||||
copier.notify_error(f"Could not copy protected messages: {exc}")
|
||||
|
||||
def _is_protected(self, message):
|
||||
try:
|
||||
return copier.is_protected(message)
|
||||
except Exception as exc:
|
||||
# When in doubt, copy: a copy always works, a forward of a
|
||||
# protected message never does.
|
||||
self.plugin.log(f"protection check failed, copying instead: {exc}")
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _topic_root(args):
|
||||
"""Forwarding into a forum topic passes the topic's root message."""
|
||||
from org.telegram.messenger import MessageObject
|
||||
|
||||
for i in range(6, len(args)):
|
||||
if isinstance(args[i], MessageObject):
|
||||
return args[i]
|
||||
return None
|
||||
|
||||
|
||||
def install(plugin, enabled):
|
||||
no_forwards = ReturnFalseHook(enabled)
|
||||
hooked = 0
|
||||
for method in ("isChatNoForwards", "isUserNoForwards", "isPeerNoForwards"):
|
||||
hooked += hook_all(plugin, "org.telegram.messenger.MessagesController", method, no_forwards)
|
||||
hooked += hook_all(plugin, "org.telegram.ui.ChatActivity", "hasSelectedNoforwardsMessage", no_forwards)
|
||||
hooked += hook_all(
|
||||
plugin, "org.telegram.messenger.MessageObject", "canForwardMessage", CanForwardHook(enabled)
|
||||
)
|
||||
hooked += hook_all(
|
||||
plugin,
|
||||
"org.telegram.messenger.SendMessagesHelper",
|
||||
"sendMessage",
|
||||
ForwardInterceptHook(plugin, enabled),
|
||||
)
|
||||
return hooked
|
||||
@@ -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,37 @@
|
||||
from base_plugin import MethodHook
|
||||
from hook_utils import find_class
|
||||
|
||||
|
||||
def java_list(items):
|
||||
"""Copies a java.util.List into a Python list (Java lists aren't iterable here)."""
|
||||
if items is None:
|
||||
return []
|
||||
return [items.get(i) for i in range(items.size())]
|
||||
|
||||
|
||||
class ReturnFalseHook(MethodHook):
|
||||
"""Forces a boolean method to return false while `enabled()` is true."""
|
||||
|
||||
def __init__(self, enabled):
|
||||
self.enabled = enabled
|
||||
|
||||
def after_hooked_method(self, param):
|
||||
if self.enabled():
|
||||
param.setResult(False)
|
||||
|
||||
|
||||
def hook_all(plugin, class_name, method_name, hook):
|
||||
"""Hooks every overload of `class_name.method_name`; returns how many were hooked."""
|
||||
clazz = find_class(class_name)
|
||||
if clazz is None:
|
||||
plugin.log(f"class not found: {class_name}")
|
||||
return 0
|
||||
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 0
|
||||
count = len(unhooks) if unhooks else 0
|
||||
if count == 0:
|
||||
plugin.log(f"no overloads hooked for {class_name}.{method_name}")
|
||||
return count
|
||||
+70
-36
@@ -1,50 +1,84 @@
|
||||
from base_plugin import BasePlugin, HookResult, HookStrategy
|
||||
from android_utils import log
|
||||
from ui.settings import Header, Input, Text
|
||||
from greeting import render, TemplateError
|
||||
from base_plugin import BasePlugin
|
||||
from ui.settings import Header, Switch, Text
|
||||
import badge
|
||||
import forwarding
|
||||
import screenshots
|
||||
|
||||
DEFAULT_TEMPLATE = "Hello, {name}!"
|
||||
SCREENSHOTS_KEY = "allow_screenshots"
|
||||
FORWARDING_KEY = "allow_forwarding"
|
||||
LOCK_BADGE_KEY = "protected_lock_badge"
|
||||
|
||||
|
||||
class HelloPlugin(BasePlugin):
|
||||
class UnlockedRestrictionsPlugin(BasePlugin):
|
||||
def on_plugin_load(self):
|
||||
log("hello_bundle loaded")
|
||||
self.add_on_send_message_hook()
|
||||
# Hooks stay installed for the plugin's lifetime and check the
|
||||
# settings on every call, so toggles apply without a reload.
|
||||
screenshots_hooked = screenshots.install(self, self._screenshots_enabled)
|
||||
forwarding_hooked = forwarding.install(self, self._forwarding_enabled)
|
||||
badge_hooked = badge.install(self, self._lock_badge_enabled)
|
||||
self.log(
|
||||
f"unlocked_restrictions loaded: {screenshots_hooked} screenshot hook(s), "
|
||||
f"{forwarding_hooked} forwarding hook(s), {badge_hooked} lock badge hook(s)"
|
||||
)
|
||||
if self._screenshots_enabled():
|
||||
self._clear_current_window()
|
||||
|
||||
def on_plugin_unload(self):
|
||||
log("hello_bundle unloaded")
|
||||
self.log("unlocked_restrictions unloaded")
|
||||
|
||||
def _screenshots_enabled(self):
|
||||
return bool(self.get_setting(SCREENSHOTS_KEY, True))
|
||||
|
||||
def _forwarding_enabled(self):
|
||||
return bool(self.get_setting(FORWARDING_KEY, True))
|
||||
|
||||
def _lock_badge_enabled(self):
|
||||
return bool(self.get_setting(LOCK_BADGE_KEY, True))
|
||||
|
||||
def _on_lock_badge_change(self, enabled):
|
||||
try:
|
||||
badge.refresh_dialogs()
|
||||
except Exception as exc:
|
||||
self.log(f"could not refresh the chat list: {exc}")
|
||||
|
||||
def _on_screenshots_change(self, enabled):
|
||||
if enabled:
|
||||
self._clear_current_window()
|
||||
|
||||
def _clear_current_window(self):
|
||||
try:
|
||||
screenshots.clear_current_window()
|
||||
except Exception as exc:
|
||||
self.log(f"could not clear FLAG_SECURE on the current window: {exc}")
|
||||
|
||||
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",
|
||||
Header(text="Unlocked Restrictions"),
|
||||
Switch(
|
||||
key=SCREENSHOTS_KEY,
|
||||
text="Allow screenshots",
|
||||
default=True,
|
||||
subtext="Screenshots and screen recording in protected chats, secret chats and the passcode screen.",
|
||||
icon="msg_photos",
|
||||
on_change=self._on_screenshots_change,
|
||||
),
|
||||
Switch(
|
||||
key=FORWARDING_KEY,
|
||||
text="Allow forwarding",
|
||||
default=True,
|
||||
subtext="Forwarding from protected chats sends a copy of the message instead.",
|
||||
icon="msg_forward",
|
||||
),
|
||||
Switch(
|
||||
key=LOCK_BADGE_KEY,
|
||||
text="Mark protected chats",
|
||||
default=True,
|
||||
subtext="Shows a red lock before the names of chats that restrict saving content.",
|
||||
icon="msg_secret",
|
||||
on_change=self._on_lock_badge_change,
|
||||
),
|
||||
Text(
|
||||
text="Example",
|
||||
subtext=".hello alice smith -> Hello, Alice Smith!",
|
||||
text="Copies have no \"Forwarded from\" header. Media is downloaded and uploaded again, so large files take a while.",
|
||||
icon="msg_info",
|
||||
),
|
||||
]
|
||||
|
||||
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)
|
||||
return HookResult(strategy=HookStrategy.MODIFY, params=params)
|
||||
@@ -0,0 +1,14 @@
|
||||
def chat_is_protected(controller, chat):
|
||||
"""True when `chat` (or the supergroup it migrated to) restricts saving content.
|
||||
|
||||
Reads the raw `noforwards` field instead of calling
|
||||
MessagesController.isChatNoForwards(), since the plugin hooks that to
|
||||
return false.
|
||||
"""
|
||||
if chat is None:
|
||||
return False
|
||||
if chat.migrated_to is not None:
|
||||
migrated = controller.getChat(chat.migrated_to.channel_id)
|
||||
if migrated is not None:
|
||||
return bool(migrated.noforwards)
|
||||
return bool(chat.noforwards)
|
||||
@@ -0,0 +1,51 @@
|
||||
from base_plugin import MethodHook
|
||||
from hooking import ReturnFalseHook, hook_all
|
||||
|
||||
# android.view.WindowManager.LayoutParams.FLAG_SECURE
|
||||
FLAG_SECURE = 0x2000
|
||||
|
||||
|
||||
class StripFlagSecureHook(MethodHook):
|
||||
"""Window.setFlags(flags, mask): drop FLAG_SECURE from the flags being set.
|
||||
|
||||
Window.addFlags() delegates to setFlags(), so this catches every path that
|
||||
tries to secure a window, including ones outside FlagSecureReason.
|
||||
"""
|
||||
|
||||
def __init__(self, enabled):
|
||||
self.enabled = enabled
|
||||
|
||||
def before_hooked_method(self, param):
|
||||
if not self.enabled():
|
||||
return
|
||||
args = param.args
|
||||
if len(args) != 2:
|
||||
return
|
||||
flags = int(args[0])
|
||||
if flags & FLAG_SECURE:
|
||||
args[0] = flags & ~FLAG_SECURE
|
||||
|
||||
|
||||
def install(plugin, enabled):
|
||||
hooked = hook_all(
|
||||
plugin,
|
||||
"org.telegram.messenger.FlagSecureReason",
|
||||
"isSecuredNow",
|
||||
ReturnFalseHook(enabled),
|
||||
)
|
||||
hooked += hook_all(plugin, "android.view.Window", "setFlags", StripFlagSecureHook(enabled))
|
||||
return hooked
|
||||
|
||||
|
||||
def clear_current_window():
|
||||
"""Drops FLAG_SECURE from the window that is already on screen."""
|
||||
from android_utils import run_on_ui_thread
|
||||
from client_utils import get_last_fragment
|
||||
|
||||
def clear():
|
||||
fragment = get_last_fragment()
|
||||
activity = fragment.getParentActivity() if fragment is not None else None
|
||||
if activity is not None:
|
||||
activity.getWindow().clearFlags(FLAG_SECURE)
|
||||
|
||||
run_on_ui_thread(clear)
|
||||
@@ -1,2 +0,0 @@
|
||||
def normalize(name: str) -> str:
|
||||
return " ".join(w[:1].upper() + w[1:] for w in name.split())
|
||||
@@ -2,6 +2,9 @@
|
||||
"""Bundle a multi-file exteraGram plugin (plugin.json + src/) into a single .plugin file.
|
||||
|
||||
Usage: python3 tools/bundle.py [--dev|--release] [--out DIR] [--check]
|
||||
|
||||
Also enables the repo's git hooks (scripts/pre-commit, scripts/post-commit) on
|
||||
first run; see ensure_git_hooks().
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -9,13 +12,18 @@ import argparse
|
||||
import ast
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SRC_DIR = REPO_ROOT / "src"
|
||||
HOOKS_DIR = "scripts"
|
||||
HOOK_NAMES = ("pre-commit", "post-commit")
|
||||
PLUGIN_JSON = REPO_ROOT / "plugin.json"
|
||||
|
||||
RECOGNIZED_KEYS = {
|
||||
@@ -300,6 +308,43 @@ def assemble_bundle(data: dict, modules: dict, release: bool) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def ensure_git_hooks() -> None:
|
||||
"""Point git at scripts/ for hooks, once per clone.
|
||||
|
||||
Git never enables hooks from repository content, so a fresh clone (or a
|
||||
repo created from the template) starts without them. Every workflow runs
|
||||
this bundler first, directly or via scripts/deploy.sh and scripts/watch.sh,
|
||||
which makes it the one place that can switch them on without an extra
|
||||
setup step. Skipped in CI, outside a git work tree, and when
|
||||
core.hooksPath is already set (including to something else on purpose).
|
||||
Never fails the build.
|
||||
"""
|
||||
if os.environ.get("CI"):
|
||||
return
|
||||
hooks = [REPO_ROOT / HOOKS_DIR / name for name in HOOK_NAMES]
|
||||
if not any(h.is_file() for h in hooks):
|
||||
return
|
||||
|
||||
def git(*args):
|
||||
return subprocess.run(["git", *args], cwd=REPO_ROOT, capture_output=True, text=True)
|
||||
|
||||
try:
|
||||
top = git("rev-parse", "--show-toplevel")
|
||||
if top.returncode != 0 or Path(top.stdout.strip()).resolve() != REPO_ROOT:
|
||||
return
|
||||
if git("config", "--get", "core.hooksPath").stdout.strip():
|
||||
return
|
||||
# Git silently skips hooks that are not executable.
|
||||
for hook in hooks:
|
||||
if hook.is_file():
|
||||
mode = hook.stat().st_mode
|
||||
hook.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
if git("config", "core.hooksPath", HOOKS_DIR).returncode == 0:
|
||||
print(f"enabled git hooks from {HOOKS_DIR}/ (git config core.hooksPath {HOOKS_DIR})", file=sys.stderr)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
|
||||
def parse_args(argv):
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Bundle a multi-file exteraGram plugin into a single .plugin file."
|
||||
@@ -318,6 +363,7 @@ def parse_args(argv):
|
||||
|
||||
def main(argv=None) -> int:
|
||||
args = parse_args(sys.argv[1:] if argv is None else argv)
|
||||
ensure_git_hooks()
|
||||
release = bool(args.release)
|
||||
try:
|
||||
data = load_metadata()
|
||||
|
||||
+299
-59
@@ -45,6 +45,43 @@ class HookResult:
|
||||
|
||||
|
||||
LOG_LINES: list[str] = []
|
||||
HOOKED: list[tuple[str, str, object]] = []
|
||||
|
||||
# Classes the plugin is expected to hook, mapped to the hooked method names.
|
||||
EXPECTED_HOOKS = {
|
||||
("org.telegram.messenger.FlagSecureReason", "isSecuredNow"),
|
||||
("android.view.Window", "setFlags"),
|
||||
("org.telegram.messenger.MessagesController", "isChatNoForwards"),
|
||||
("org.telegram.messenger.MessagesController", "isUserNoForwards"),
|
||||
("org.telegram.messenger.MessagesController", "isPeerNoForwards"),
|
||||
("org.telegram.ui.ChatActivity", "hasSelectedNoforwardsMessage"),
|
||||
("org.telegram.messenger.MessageObject", "canForwardMessage"),
|
||||
("org.telegram.messenger.SendMessagesHelper", "sendMessage"),
|
||||
("org.telegram.ui.Cells.DialogCell", "buildLayout"),
|
||||
("org.telegram.ui.Components.ChatAvatarContainer", "setTitle"),
|
||||
("org.telegram.messenger.AndroidUtilities", "escape"),
|
||||
("org.telegram.ui.ProfileActivity", "updateProfileData"),
|
||||
}
|
||||
|
||||
|
||||
class MethodHook:
|
||||
def before_hooked_method(self, param):
|
||||
pass
|
||||
|
||||
def after_hooked_method(self, param):
|
||||
pass
|
||||
|
||||
|
||||
class MethodReplacement:
|
||||
pass
|
||||
|
||||
|
||||
class FakeJavaClass:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
def getName(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class BasePlugin:
|
||||
@@ -54,12 +91,19 @@ class BasePlugin:
|
||||
def get_setting(self, key, default=None):
|
||||
return self._settings.get(key, default)
|
||||
|
||||
def set_setting(self, key, value):
|
||||
def set_setting(self, key, value, reload_settings=False):
|
||||
self._settings[key] = value
|
||||
|
||||
def add_on_send_message_hook(self):
|
||||
def add_on_send_message_hook(self, priority=0):
|
||||
pass
|
||||
|
||||
def hook_all_methods(self, hook_class, method_name, xposed_hook=None, priority=None, **kwargs):
|
||||
HOOKED.append((hook_class.getName(), method_name, xposed_hook))
|
||||
return [object()]
|
||||
|
||||
def log(self, message):
|
||||
LOG_LINES.append(str(message))
|
||||
|
||||
def on_plugin_load(self):
|
||||
pass
|
||||
|
||||
@@ -74,36 +118,87 @@ def fake_log(*args, **kwargs):
|
||||
LOG_LINES.append(" ".join(str(a) for a in args))
|
||||
|
||||
|
||||
def fake_find_class(name):
|
||||
return FakeJavaClass(name)
|
||||
|
||||
|
||||
class _SettingsRow:
|
||||
def __init__(self, **kwargs):
|
||||
self.__dict__.update(kwargs)
|
||||
|
||||
|
||||
class FakeArrayList(list):
|
||||
def isEmpty(self):
|
||||
return not self
|
||||
|
||||
def add(self, item):
|
||||
self.append(item)
|
||||
|
||||
def size(self):
|
||||
return len(self)
|
||||
|
||||
def get(self, i):
|
||||
return self[i]
|
||||
|
||||
|
||||
class FakeMessageObject:
|
||||
def __init__(self, protected=False, noforwards=False):
|
||||
self.protected = protected
|
||||
self.messageOwner = types.SimpleNamespace(noforwards=noforwards)
|
||||
|
||||
|
||||
class FakeParam:
|
||||
def __init__(self, args=(), this=None, result=None):
|
||||
self.args = None if args is None else list(args)
|
||||
self.thisObject = this
|
||||
self._result = result
|
||||
self.result_set = False
|
||||
|
||||
def getResult(self):
|
||||
return self._result
|
||||
|
||||
def setResult(self, value):
|
||||
self._result = value
|
||||
self.result_set = True
|
||||
|
||||
|
||||
def _module(name, **attrs):
|
||||
mod = types.ModuleType(name)
|
||||
mod.__dict__.update(attrs)
|
||||
sys.modules[name] = mod
|
||||
return mod
|
||||
|
||||
|
||||
def install_fakes() -> None:
|
||||
base_plugin_mod = types.ModuleType("base_plugin")
|
||||
base_plugin_mod.BasePlugin = BasePlugin
|
||||
base_plugin_mod.HookResult = HookResult
|
||||
base_plugin_mod.HookStrategy = HookStrategy
|
||||
sys.modules["base_plugin"] = base_plugin_mod
|
||||
|
||||
android_utils_mod = types.ModuleType("android_utils")
|
||||
android_utils_mod.log = fake_log
|
||||
sys.modules["android_utils"] = android_utils_mod
|
||||
|
||||
ui_mod = types.ModuleType("ui")
|
||||
ui_mod.__path__ = []
|
||||
sys.modules["ui"] = ui_mod
|
||||
|
||||
ui_settings_mod = types.ModuleType("ui.settings")
|
||||
for cls_name in ("Header", "Input", "Text", "Switch"):
|
||||
setattr(ui_settings_mod, cls_name, type(cls_name, (_SettingsRow,), {}))
|
||||
sys.modules["ui.settings"] = ui_settings_mod
|
||||
ui_mod.settings = ui_settings_mod
|
||||
|
||||
|
||||
class SimpleParams:
|
||||
def __init__(self, message):
|
||||
self.message = message
|
||||
_module(
|
||||
"base_plugin",
|
||||
BasePlugin=BasePlugin,
|
||||
HookResult=HookResult,
|
||||
HookStrategy=HookStrategy,
|
||||
MethodHook=MethodHook,
|
||||
MethodReplacement=MethodReplacement,
|
||||
)
|
||||
_module("android_utils", log=fake_log, run_on_ui_thread=lambda fn, delay=0: fn())
|
||||
_module(
|
||||
"hook_utils",
|
||||
find_class=fake_find_class,
|
||||
set_private_field=lambda obj, name, value: setattr(obj, name, value) or True,
|
||||
get_private_field=lambda obj, name: getattr(obj, name, None),
|
||||
)
|
||||
_module("client_utils", get_last_fragment=lambda: None)
|
||||
ui_mod = _module("ui", __path__=[])
|
||||
ui_mod.settings = _module(
|
||||
"ui.settings",
|
||||
**{n: type(n, (_SettingsRow,), {}) for n in ("Header", "Input", "Text", "Switch", "Divider")},
|
||||
)
|
||||
ui_mod.bulletin = _module("ui.bulletin", BulletinHelper=types.SimpleNamespace(
|
||||
show_info=fake_log, show_error=fake_log, show_success=fake_log,
|
||||
))
|
||||
java_mod = _module("java", __path__=[])
|
||||
java_mod.util = _module("java.util", ArrayList=FakeArrayList)
|
||||
org = _module("org", __path__=[])
|
||||
org.telegram = _module("org.telegram", __path__=[])
|
||||
org.telegram.messenger = _module("org.telegram.messenger", MessageObject=FakeMessageObject)
|
||||
|
||||
|
||||
KEY_TO_DUNDER = {
|
||||
@@ -133,6 +228,154 @@ def expected_dunders(meta: dict) -> dict:
|
||||
return result
|
||||
|
||||
|
||||
def verify_hooks(plugin, hooks) -> None:
|
||||
flag_secure = 0x2000
|
||||
strip = hooks[("android.view.Window", "setFlags")]
|
||||
for enabled in (True, False):
|
||||
plugin.set_setting("allow_screenshots", enabled)
|
||||
param = FakeParam(args=[flag_secure | 0x80, flag_secure | 0x80])
|
||||
strip.before_hooked_method(param)
|
||||
want = 0x80 if enabled else flag_secure | 0x80
|
||||
check(
|
||||
f"Window.setFlags strips FLAG_SECURE only when enabled (enabled={enabled})",
|
||||
param.args[0] == want,
|
||||
f"flags={param.args[0]:#x}",
|
||||
)
|
||||
|
||||
plugin.set_setting("allow_screenshots", True)
|
||||
param = FakeParam(result=True)
|
||||
hooks[("org.telegram.messenger.FlagSecureReason", "isSecuredNow")].after_hooked_method(param)
|
||||
check("FlagSecureReason.isSecuredNow forced false", param.getResult() is False)
|
||||
|
||||
no_forwards = hooks[("org.telegram.messenger.MessagesController", "isChatNoForwards")]
|
||||
for enabled in (True, False):
|
||||
plugin.set_setting("allow_forwarding", enabled)
|
||||
param = FakeParam(result=True)
|
||||
no_forwards.after_hooked_method(param)
|
||||
check(
|
||||
f"isChatNoForwards forced false only when enabled (enabled={enabled})",
|
||||
param.getResult() is (not enabled),
|
||||
f"got {param.getResult()!r}",
|
||||
)
|
||||
|
||||
plugin.set_setting("allow_forwarding", True)
|
||||
can_forward = hooks[("org.telegram.messenger.MessageObject", "canForwardMessage")]
|
||||
message = FakeMessageObject(noforwards=True)
|
||||
seen = []
|
||||
# The re-invoked original answers "forwardable" only if noforwards is unset.
|
||||
method = types.SimpleNamespace(
|
||||
invoke=lambda obj: seen.append(obj.messageOwner.noforwards) or not obj.messageOwner.noforwards
|
||||
)
|
||||
param = FakeParam(this=message, result=False)
|
||||
param.method = method
|
||||
can_forward.after_hooked_method(param)
|
||||
check(
|
||||
"canForwardMessage re-evaluates without noforwards and restores the flag",
|
||||
param.getResult() is True and seen == [False] and message.messageOwner.noforwards is True,
|
||||
f"result={param.getResult()!r} seen={seen} flag={message.messageOwner.noforwards!r}",
|
||||
)
|
||||
param = FakeParam(this=FakeMessageObject(noforwards=False), result=False)
|
||||
param.method = method
|
||||
can_forward.after_hooked_method(param)
|
||||
check("canForwardMessage keeps a refusal unrelated to noforwards", param.getResult() is False)
|
||||
|
||||
escape = hooks[("org.telegram.messenger.AndroidUtilities", "escape")]
|
||||
badge = type(escape).after_hooked_method.__globals__
|
||||
badge["_building_title"] = "Chat"
|
||||
results = []
|
||||
for arg in ("Chat", "Other"):
|
||||
param = FakeParam(args=[arg], result=arg)
|
||||
escape.after_hooked_method(param)
|
||||
results.append(param.getResult())
|
||||
badge["_building_title"] = None
|
||||
param = FakeParam(args=["Chat"], result="Chat")
|
||||
escape.after_hooked_method(param)
|
||||
results.append(param.getResult())
|
||||
check(
|
||||
"escape() prefixes only the title being laid out",
|
||||
results == ["\u2003 Chat", "Other", "Chat"],
|
||||
f"{results}",
|
||||
)
|
||||
|
||||
intercept = hooks[("org.telegram.messenger.SendMessagesHelper", "sendMessage")]
|
||||
copier = type(intercept).before_hooked_method.__globals__["copier"]
|
||||
|
||||
cases = [
|
||||
([], []),
|
||||
([0, 0, 0], []),
|
||||
([7], []),
|
||||
([7, 7, 7], [(0, 3)]),
|
||||
([0, 7, 7, 0, 9, 9, 9], [(1, 3), (4, 7)]),
|
||||
([7, 7, 9, 9], [(0, 2), (2, 4)]),
|
||||
([7, 0, 7], []),
|
||||
([5] * 12, [(0, 10), (10, 12)]),
|
||||
([5] * 11, [(0, 10)]),
|
||||
]
|
||||
bad = [(k, copier.album_runs(k), want) for k, want in cases if copier.album_runs(k) != want]
|
||||
check("album_runs groups consecutive album items, max 10 per group", not bad, f"{bad}")
|
||||
|
||||
# Stub out the Java-facing parts of the bundled copier module.
|
||||
copied = []
|
||||
copier.is_protected = lambda m: m.protected
|
||||
copier.copy_messages = lambda **kwargs: copied.append(kwargs)
|
||||
|
||||
free, locked = FakeMessageObject(), FakeMessageObject(protected=True)
|
||||
param = FakeParam(args=[FakeArrayList([free, locked]), -100, False, True, True, 0, 0])
|
||||
intercept.before_hooked_method(param)
|
||||
check(
|
||||
"mixed forward: protected message copied, the rest forwarded",
|
||||
len(copied) == 1
|
||||
and copied[0]["messages"] == [locked]
|
||||
and copied[0]["peer"] == -100
|
||||
and copied[0]["hide_caption"] is True
|
||||
and list(param.args[0]) == [free]
|
||||
and not param.result_set,
|
||||
f"copied={copied} args0={list(param.args[0])} result_set={param.result_set}",
|
||||
)
|
||||
|
||||
copied.clear()
|
||||
param = FakeParam(args=[FakeArrayList([locked]), 5, False, False, True, 0, 0])
|
||||
intercept.before_hooked_method(param)
|
||||
check(
|
||||
"all-protected forward: nothing left for the original sendMessage to forward",
|
||||
len(copied) == 1 and list(param.args[0]) == [],
|
||||
f"copied={copied} args0={list(param.args[0])}",
|
||||
)
|
||||
|
||||
copied.clear()
|
||||
|
||||
def broken_check(m):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
copier.is_protected = broken_check
|
||||
param = FakeParam(args=[FakeArrayList([free]), 5, False, False, True, 0, 0])
|
||||
intercept.before_hooked_method(param)
|
||||
check(
|
||||
"failed protection check copies instead of forwarding",
|
||||
len(copied) == 1 and list(param.args[0]) == [],
|
||||
f"copied={copied} args0={list(param.args[0])}",
|
||||
)
|
||||
copier.is_protected = lambda m: m.protected
|
||||
|
||||
copied.clear()
|
||||
param = FakeParam(args=[FakeArrayList([free]), 5, False, False, True, 0, 0])
|
||||
intercept.before_hooked_method(param)
|
||||
check(
|
||||
"unprotected forward passes through untouched",
|
||||
not copied and not param.result_set and list(param.args[0]) == [free],
|
||||
)
|
||||
|
||||
plugin.set_setting("allow_forwarding", False)
|
||||
param = FakeParam(args=[FakeArrayList([locked]), 5, False, False, True, 0, 0])
|
||||
intercept.before_hooked_method(param)
|
||||
check("forward interception off when setting disabled", not copied and not param.result_set)
|
||||
|
||||
plugin.set_setting("allow_forwarding", True)
|
||||
param = FakeParam(args=[object()])
|
||||
intercept.before_hooked_method(param)
|
||||
check("sendMessage(SendMessageParams) overload ignored", not copied and not param.result_set)
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
argv = sys.argv[1:] if argv is None else argv
|
||||
if len(argv) != 1:
|
||||
@@ -146,12 +389,12 @@ def main(argv=None) -> int:
|
||||
install_fakes()
|
||||
source = bundle_path.read_text(encoding="utf-8")
|
||||
|
||||
meta = json.loads(PLUGIN_JSON.read_text(encoding="utf-8"))
|
||||
modules_before = set(sys.modules)
|
||||
ns = {"__name__": "hello_bundle"}
|
||||
ns = {"__name__": meta["id"]}
|
||||
exec(compile(source, str(bundle_path), "exec"), ns)
|
||||
new_modules = set(sys.modules) - modules_before
|
||||
|
||||
meta = json.loads(PLUGIN_JSON.read_text(encoding="utf-8"))
|
||||
expected = expected_dunders(meta)
|
||||
mismatches = [
|
||||
f"{dunder}: expected {value!r}, got {ns.get(dunder)!r}"
|
||||
@@ -198,58 +441,55 @@ def main(argv=None) -> int:
|
||||
plugin.on_plugin_load()
|
||||
check(
|
||||
"on_plugin_load emits load log line",
|
||||
any("hello_bundle loaded" in line for line in LOG_LINES),
|
||||
any("unlocked_restrictions loaded" in line for line in LOG_LINES),
|
||||
f"log lines: {LOG_LINES}",
|
||||
)
|
||||
|
||||
params = SimpleParams(".hello alice smith")
|
||||
result = plugin.on_send_message_hook(account=0, params=params)
|
||||
hooked = {(cls, method) for cls, method, _ in HOOKED}
|
||||
check(
|
||||
"'.hello alice smith' renders greeting",
|
||||
params.message == "Hello, Alice Smith!",
|
||||
f"got {params.message!r}",
|
||||
)
|
||||
check(
|
||||
"on_send_message_hook returns MODIFY strategy",
|
||||
result.strategy == HookStrategy.MODIFY,
|
||||
f"got {result.strategy}",
|
||||
"on_plugin_load hooks every target method",
|
||||
EXPECTED_HOOKS <= hooked,
|
||||
f"missing: {EXPECTED_HOOKS - hooked}",
|
||||
)
|
||||
|
||||
plugin.set_setting("template", "{nope}")
|
||||
params2 = SimpleParams(".hello someone")
|
||||
plugin.on_send_message_hook(account=0, params=params2)
|
||||
switches = [row for row in plugin.create_settings() if type(row).__name__ == "Switch"]
|
||||
check(
|
||||
"bad template produces 'Bad template:' message",
|
||||
params2.message.startswith("Bad template:"),
|
||||
f"got {params2.message!r}",
|
||||
"settings expose all toggles, enabled by default",
|
||||
{(row.key, row.default) for row in switches}
|
||||
== {
|
||||
("allow_screenshots", True),
|
||||
("allow_forwarding", True),
|
||||
("protected_lock_badge", True),
|
||||
},
|
||||
f"got {[(row.key, row.default) for row in switches]}",
|
||||
)
|
||||
|
||||
# main.py imports `render`/`TemplateError` by name, so they live directly
|
||||
# in the executed namespace — call render() directly to inspect the raised
|
||||
# traceback (proves linecache registration for bundled, non-file modules).
|
||||
render = ns.get("render")
|
||||
template_error = ns.get("TemplateError")
|
||||
verify_hooks(plugin, {(cls, method): hook for cls, method, hook in HOOKED})
|
||||
|
||||
# Raise inside a bundled module to prove linecache registration: the
|
||||
# traceback must name the original source file and show the failing line.
|
||||
screenshots = ns.get("screenshots")
|
||||
traceback_text = ""
|
||||
if render is not None and template_error is not None:
|
||||
if screenshots is not None:
|
||||
try:
|
||||
render("{nope}", "x")
|
||||
except template_error:
|
||||
screenshots.StripFlagSecureHook(lambda: True).before_hooked_method(FakeParam(args=None))
|
||||
except TypeError:
|
||||
traceback_text = traceback.format_exc()
|
||||
check(
|
||||
"render() traceback names src/greeting/template.py",
|
||||
"src/greeting/template.py" in traceback_text,
|
||||
"hook traceback names src/screenshots.py",
|
||||
"src/screenshots.py" in traceback_text,
|
||||
f"traceback:\n{traceback_text}",
|
||||
)
|
||||
check(
|
||||
"traceback includes the raising source line text",
|
||||
"unsupported placeholder" in traceback_text,
|
||||
"if len(args) != 2" in traceback_text,
|
||||
f"traceback:\n{traceback_text}",
|
||||
)
|
||||
|
||||
bundled = {"main", "hooking", "screenshots", "forwarding", "copier", "badge", "protection"}
|
||||
check(
|
||||
"sys.modules gained no entry for bundled module names",
|
||||
not ({"greeting", "textutil", "main"} & new_modules),
|
||||
f"leaked: {({'greeting', 'textutil', 'main'} & new_modules)}",
|
||||
not (bundled & new_modules),
|
||||
f"leaked: {bundled & new_modules}",
|
||||
)
|
||||
|
||||
if _FAILURES:
|
||||
|
||||
Reference in new issue
Block a user