[no bump] Implement telegra.ph media forward
Publish plugin / publish (push) Successful in 3s

Adds a share action to the Instant View photo viewer that downloads the
media and re-sends it from the user's own account with a text_url caption
linking back to the source article.

- src/main.py: hooks PhotoViewer.onPhotoShow, injects the toolbar item
- src/ivmedia.py: resolves the viewer's page block to a TL media object
- src/picker.py: share sheet + comment field (restores focus after the
  keyboard animation, which PhotoViewer.showShareAlert does itself)
- src/sender.py: FileLoader download, cache reuse, re-send with caption
- src/icons.py: IconManager-aware drawable lookup so icon packs apply
- tools/verify_bundle.py: offline checks for URL acceptance, caption
  entity shape and bundled-module isolation

Drops the sample greeting/textutil modules.
This commit is contained in:
lyuksovannyy committed 2026-09-25 00:49:58 +02:00
1 parent bfd0f80c6f
commit 05c5212a8a
10 files changed
+757 -95

No files matched your search

+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"id": "hello_bundle", "id": "telegraph_media_forward",
"name": "Hello Bundle", "name": "Telegraph Media Forward",
"description": "Multi-file exteraGram plugin built by tools/bundle.py", "description": "Adds a Forward action to media opened from telegra.ph pages: downloads the media and re-sends it from your account with a link back to the article.",
"author": "@yourhandle", "author": "@lyuksovannyy",
"version": "1.0.0", "version": "1.0.0",
"icon": "exteraPlugins/1", "icon": "exteraPlugins/1",
"app_version": ">=12.5.1", "app_version": ">=12.5.1",
-1
View File
@@ -1 +0,0 @@
from .template import render, TemplateError
-13
View File
@@ -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}")
+60
View File
@@ -0,0 +1,60 @@
"""Resolves drawables the way exteraGram's own views do.
`LaunchActivity.getResources()` returns an `ExteraResources` wrapper whose
`getDrawableForDensity` consults `IconManager` first, so every stock icon is
replaced by the active icon pack. Views we create from a plugin do not always
sit on a context whose `getResources()` is that wrapper, so this module repeats
the same lookup explicitly: IconManager bitmap -> IconManager resId remap ->
plain resources.
"""
from android_utils import log
def icon_manager():
try:
from com.exteragram.messenger.icons import IconManager
return IconManager.INSTANCE
except Exception:
return None
def _density():
try:
from org.telegram.messenger import AndroidUtilities
return AndroidUtilities.displayMetrics.densityDpi
except Exception:
return 0
def res_id(context, name):
"""Looks up a drawable id by name.
The app's `R` class is not importable from a plugin under Chaquopy, so the
id has to come from the runtime resource table instead.
"""
return context.getResources().getIdentifier(name, "drawable", context.getPackageName())
def drawable(context, res_id):
"""The icon-pack drawable for `res_id`, or the stock one when no pack owns it."""
theme = context.getTheme()
density = _density()
manager = icon_manager()
if manager is not None:
try:
packed = manager.getDrawable(res_id, density, theme)
if packed is not None:
return packed
except Exception as exc:
log("[telegraph_media_forward] icon pack lookup failed: %s" % exc)
try:
res_id = manager.getIcon(res_id)
except Exception as exc:
log("[telegraph_media_forward] icon remap failed: %s" % exc)
resources = context.getResources()
if density:
return resources.getDrawableForDensity(res_id, density, theme)
return resources.getDrawable(res_id, theme)
+93
View File
@@ -0,0 +1,93 @@
"""Reads media out of the PhotoViewer instance that Instant View opened.
When ArticleViewer opens a telegra.ph photo/video, PhotoViewer runs with
`currentMessageObject == null` and a `PageBlocksAdapter` instead. That adapter
is the only handle to the underlying `TLRPC.Photo` / `TLRPC.Document` and to
the `TLRPC.WebPage` the article came from, so everything here goes through it.
"""
from hook_utils import get_private_field
TELEGRAPH_HOSTS = ("telegra.ph", "graph.org", "te.legra.ph", "telegra.ph.")
class NoMedia(Exception):
pass
def photo_viewer():
from org.telegram.ui import PhotoViewer
if not PhotoViewer.hasInstance():
return None
return PhotoViewer.getInstance()
def adapter_of(viewer):
"""The PageBlocksAdapter of `viewer`, or None when it shows normal messages."""
if viewer is None:
return None
return get_private_field(viewer, "pageBlocksAdapter")
def current_index(viewer):
index = get_private_field(viewer, "currentIndex")
return int(index) if index is not None else 0
def parent_activity(viewer):
return get_private_field(viewer, "parentActivity")
def resources_provider(viewer):
"""PhotoViewer's Theme.ResourcesProvider, so sheets we open match its theme."""
return get_private_field(viewer, "resourcesProvider")
def menu_item(viewer):
"""PhotoViewer's ⋮ overflow ActionBarMenuItem."""
return get_private_field(viewer, "menuItem")
def menu_bar(viewer):
"""The ActionBarMenu (a LinearLayout) holding the viewer's toolbar items."""
return get_private_field(viewer, "menu")
def page_of(adapter):
"""The `TLRPC.WebPage` the adapter was built from, or None."""
from org.telegram.tgnet import TLRPC
parent = adapter.getParentObject()
if isinstance(parent, TLRPC.WebPage):
return parent
return None
def page_url(adapter):
page = page_of(adapter)
if page is None:
return None
return page.url or page.display_url or None
def is_telegraph(adapter):
"""True when the open article is hosted on telegra.ph (or its mirrors)."""
url = page_url(adapter)
if not url:
return False
rest = url.split("://", 1)[-1]
host = rest.split("/", 1)[0].split("@")[-1].split(":")[0].lower()
return any(host == h or host.endswith("." + h) for h in TELEGRAPH_HOSTS)
def media_at(adapter, index):
"""`(media, existing_file_path_or_None, is_video)` for the shown block."""
media = adapter.getMedia(index)
if media is None:
raise NoMedia("this block has no downloadable media")
existing = adapter.getFile(index)
path = None
if existing is not None and existing.exists() and existing.length() > 0:
path = existing.getAbsolutePath()
return media, path, bool(adapter.isVideo(index))
+157 -35
View File
@@ -1,50 +1,172 @@
from base_plugin import BasePlugin, HookResult, HookStrategy from android_utils import log, run_on_ui_thread
from android_utils import log from base_plugin import BasePlugin, MethodHook
from ui.settings import Header, Input, Text from client_utils import get_selected_account
from greeting import render, TemplateError from hook_utils import find_class
from ui.settings import Header, Switch, Text
DEFAULT_TEMPLATE = "Hello, {name}!" import icons
import ivmedia
import picker
import sender
MENU_TAG = 0x7EE6A00
MENU_TEXT = "Forward"
_BUTTON_SIZE_DP = 48.0
# Distinguishes buttons built by this module instance from ones a previous load
# left behind in a PhotoViewer that outlived it.
_LOAD_TOKEN = "tmf-%d" % id(object())
class HelloPlugin(BasePlugin): class TelegraphMediaForwardPlugin(BasePlugin):
def on_plugin_load(self): def on_plugin_load(self):
log("hello_bundle loaded") viewer = find_class("org.telegram.ui.PhotoViewer")
self.add_on_send_message_hook() if viewer is None:
self.log("PhotoViewer class not found; plugin inactive")
return
self.hook_all_methods(viewer, "onPhotoShow", _OnPhotoShow(self))
self.log("loaded")
def on_plugin_unload(self): def on_plugin_unload(self):
log("hello_bundle unloaded") self.log("unloaded")
def create_settings(self): def create_settings(self):
return [ return [
Header(text="Hello Bundle"), Header(text="Telegraph Media Forward"),
Input( Switch(
key="template", key="any_article",
text="Greeting template", text="Allow on any Instant View article",
default=DEFAULT_TEMPLATE, default=False,
subtext="Use {name} as placeholder.", subtext="Off: only telegra.ph and graph.org pages.",
icon="msg_edit", icon="msg_link",
), ),
Text( Text(
text="Example", text="How it works",
subtext=".hello alice smith -> Hello, Alice Smith!", subtext=(
"Open a photo or video from an article, tap the ⋮ menu and choose "
"Forward. The file is downloaded and sent from your account with "
"an Original link to the page."
),
icon="msg_info", icon="msg_info",
), ),
] ]
def on_send_message_hook(self, account, params): def allows(self, adapter):
message = getattr(params, "message", None) if self.get_setting("any_article", False):
if not isinstance(message, str): return ivmedia.page_url(adapter) is not None
return HookResult() return ivmedia.is_telegraph(adapter)
stripped = message.strip()
if not stripped.startswith(".hello"): def on_forward_clicked(self):
return HookResult() viewer = ivmedia.photo_viewer()
parts = stripped.split(" ", 1) adapter = ivmedia.adapter_of(viewer)
name = parts[1] if len(parts) > 1 else "" if adapter is None:
if not name.strip(): return
params.message = "Usage: .hello <name>" url = ivmedia.page_url(adapter)
else: index = ivmedia.current_index(viewer)
try: try:
params.message = render(self.get_setting("template", DEFAULT_TEMPLATE), name) media, path, is_video = ivmedia.media_at(adapter, index)
except TemplateError as exc: except ivmedia.NoMedia as exc:
params.message = "Bad template: " + str(exc) sender.notify_error(str(exc))
return HookResult(strategy=HookStrategy.MODIFY, params=params) return
account = get_selected_account()
context = ivmedia.parent_activity(viewer)
if context is None:
return
def picked(peers, comment):
if not peers:
return
# The sheet sits above the viewer's window; drop it so the sent
# message is what the user lands on.
viewer.closePhoto(False, False)
sender.send_media(account, media, path, is_video, url, peers, comment)
try:
# ShareAlert owns its own window, so it shows over PhotoViewer just
# like the stock share button does - no need to close the viewer.
picker.pick_chats(context, picked, ivmedia.resources_provider(viewer))
except Exception as exc:
log("[telegraph_media_forward] picker failed: %s" % exc)
sender.notify_error(str(exc))
class _OnPhotoShow(MethodHook):
"""Adds (or re-shows) the Forward item every time the viewer opens media."""
def __init__(self, plugin):
self.plugin = plugin
def after_hooked_method(self, param):
viewer = param.thisObject
run_on_ui_thread(lambda: _sync_menu(self.plugin, viewer))
def _sync_menu(plugin, viewer):
from android.view import View
try:
menu = ivmedia.menu_bar(viewer)
if menu is None:
return
adapter = ivmedia.adapter_of(viewer)
visible = adapter is not None and plugin.allows(adapter)
button = _find_button(menu)
if button is not None and button.getContentDescription() != _LOAD_TOKEN:
# Left over from a previous plugin load: its click listener points
# at a dead module, so drop it and build a fresh one.
menu.removeView(button)
button = None
if button is None:
if not visible:
return
button = _add_button(plugin, menu, viewer)
button.setVisibility(View.VISIBLE if visible else View.GONE)
except Exception as exc:
log("[telegraph_media_forward] menu sync failed: %s" % exc)
def _find_button(menu):
for i in range(menu.getChildCount()):
child = menu.getChildAt(i)
tag = child.getTag()
# Stock toolbar children carry their own tags of assorted types; only an
# Integer tag can be ours, and int() on anything else would throw.
if isinstance(tag, int) and tag == MENU_TAG:
return child
return None
def _add_button(plugin, menu, viewer):
"""Inserts an icon button directly left of PhotoViewer's ⋮ overflow item."""
from android_utils import OnClickListener
from org.telegram.messenger import AndroidUtilities
overflow = ivmedia.menu_item(viewer)
index = menu.indexOfChild(overflow) if overflow is not None else -1
res_id = icons.res_id(menu.getContext(), "msg_header_share")
# Resolve through IconManager so the active icon pack wins, exactly like
# the stock toolbar items do via LaunchActivity's ExteraResources.
icon = icons.drawable(menu.getContext(), res_id)
button = menu.addItemAt(
index,
MENU_TAG,
0 if icon is not None else res_id,
None,
0,
icon,
AndroidUtilities.dp(_BUTTON_SIZE_DP),
MENU_TEXT,
None,
)
if button is None:
return None
button.setContentDescription(_LOAD_TOKEN)
# addItemAt() builds the item with color 0, so it never installs the
# tint ActionBar applies to stock items via setItemsColor(-1).
button.setIconColor(-1)
# addItemAt() installs ActionBarMenu's own listener, which would route the
# tag through PhotoViewer's onItemClick; replace it with ours.
button.setOnClickListener(OnClickListener(lambda _v=None: plugin.on_forward_clicked()))
return button
+92
View File
@@ -0,0 +1,92 @@
"""Opens the client's own "Share / Forward to" sheet and reports the chosen chats.
`ShareAlert` is the sheet the app itself uses for forwarding, so reusing it keeps
the picker pixel-identical to the stock one. It is constructed with no payload
(`sendingMessageObjects`, `sendingText` and `sendingFile` all null), which makes
its send path a no-op: it selects chats, fires `ShareAlertDelegate.didShare()`
and dismisses. The selection is read back off the (protected) `selectedDialogs`
field and the comment off `commentTextView`.
"""
from android_utils import log, run_on_ui_thread
from hook_utils import get_private_field
_delegate_class = None
# ShareAlert's own constant for "keep the keyboard out of the way".
_SOFT_INPUT_ADJUST_NOTHING = 48
# PhotoViewer hands focus over after AdjustPanLayoutHelper.keyboardDuration,
# which is 250ms in the app's animation constants.
_FOCUS_DELAY_MS = 250
def _make_delegate_class():
"""Builds the ShareAlertDelegate proxy lazily: the Java interface can only
be resolved once the app classes are loadable."""
from java import dynamic_proxy
from org.telegram.ui.Components import ShareAlert
class Delegate(dynamic_proxy(ShareAlert.ShareAlertDelegate)):
def __init__(self, alert, on_picked):
super().__init__()
self.alert = alert
self.on_picked = on_picked
def didCopy(self):
return False
def didShare(self):
self.on_picked(_selected_peers(self.alert), _comment(self.alert))
return Delegate
def _selected_peers(alert):
dialogs = get_private_field(alert, "selectedDialogs")
if dialogs is None:
return []
return [dialogs.keyAt(i) for i in range(dialogs.size())]
def _comment(alert):
field = get_private_field(alert, "commentTextView")
if field is None:
return ""
text = field.getText()
return str(text).strip() if text is not None else ""
def pick_chats(context, on_picked, resources_provider=None):
"""Shows the stock share sheet. `on_picked(dialog_ids, comment)` runs on the
UI thread once the user confirms."""
global _delegate_class
from org.telegram.ui.Components import ShareAlert
alert = ShareAlert(context, None, None, False, None, False, resources_provider)
if _delegate_class is None:
_delegate_class = _make_delegate_class()
# Keep a reference on the module so the proxy is not collected early.
delegate = _delegate_class(alert, on_picked)
alert.setDelegate(delegate)
pick_chats._alive = delegate
# ShareAlert opens over PhotoViewer's window. Taking focus immediately
# fights the viewer's own window animation, so the stock code in
# PhotoViewer.showShareAlert() starts unfocused and hands focus over once
# the keyboard-transition window has passed; without that second step the
# comment field can be tapped but never receives key events.
alert.setFocusable(False)
alert.getWindow().setSoftInputMode(_SOFT_INPUT_ADJUST_NOTHING)
run_on_ui_thread(lambda: _enable_focus(alert), _FOCUS_DELAY_MS)
alert.show()
return alert
def _enable_focus(alert):
try:
if alert.getWindow() is not None:
alert.setFocusable(True)
except Exception as exc:
log("[telegraph_media_forward] focus handoff failed: %s" % exc)
+177
View File
@@ -0,0 +1,177 @@
"""Downloads telegra.ph media and re-sends it from the user's own account.
Instant View media lives on the article's WebPage, not in any message, so it
cannot be forwarded. Instead the file is fetched through FileLoader (or reused
if already cached) and uploaded as a fresh photo/document, exactly like picking
the file from the gallery. Caption is a single markdown link to the article.
"""
import threading
import time
from android_utils import log, run_on_ui_thread
from client_utils import get_send_messages_helper
DOWNLOAD_TIMEOUT_S = 600
DOWNLOAD_POLL_S = 0.5
class SendFailed(Exception):
pass
def notify_error(text):
from ui.bulletin import BulletinHelper
run_on_ui_thread(lambda: BulletinHelper.show_error(text))
def notify_success(text):
from ui.bulletin import BulletinHelper
run_on_ui_thread(lambda: BulletinHelper.show_success(text))
CAPTION_TEXT = "telegra.ph source"
def send_media(account, media, cached_path, is_video, page_url, peers, comment):
"""Starts the download+send in the background. Safe to call on the UI thread."""
def worker():
try:
path = cached_path or _download(account, media, is_video)
for peer in peers:
if comment:
_send(account, _text_params(comment, peer))
_send(account, _media_params(account, media, path, is_video, page_url, peer))
except Exception as exc:
log("[telegraph_media_forward] send failed: %s" % exc)
notify_error(str(exc))
return
notify_success("Sent")
threading.Thread(target=worker, name="telegraph-media-forward", daemon=True).start()
def _text_params(text, peer):
from org.telegram.messenger import SendMessagesHelper
return SendMessagesHelper.SendMessageParams.of(text, peer)
def _media_params(account, media, path, is_video, page_url, peer):
from org.telegram.messenger import SendMessagesHelper
params = SendMessagesHelper.SendMessageParams.of(None, peer)
params.caption = CAPTION_TEXT
params.entities = _link_entities(page_url)
params.path = path
if is_video:
params.document = _fresh_document(account, media, path)
else:
photo = SendMessagesHelper.getInstance(account).generatePhotoSizes(path, None)
if photo is None:
raise SendFailed("could not re-encode the photo")
params.photo = photo
return params
def _link_entities(url):
"""Whole caption as one text_url entity, so it renders as a link."""
from java.util import ArrayList
from org.telegram.tgnet import TLRPC
entity = TLRPC.TL_messageEntityTextUrl()
entity.offset = 0
entity.length = len(CAPTION_TEXT)
entity.url = url
entities = ArrayList()
entities.add(entity)
return entities
def _fresh_document(account, media, path):
"""A TL_document with id/access_hash zeroed, which makes SendMessagesHelper
upload `path` as a new file instead of referencing the article's copy."""
from java.util import ArrayList
from org.telegram.tgnet import ConnectionsManager, TLRPC
copy = TLRPC.TL_document()
copy.id = 0
copy.access_hash = 0
copy.file_reference = bytes()
copy.date = ConnectionsManager.getInstance(account).getCurrentTime()
copy.mime_type = getattr(media, "mime_type", None) or "video/mp4"
copy.size = getattr(media, "size", 0)
copy.dc_id = 0
if getattr(media, "attributes", None) is not None:
copy.attributes = ArrayList(media.attributes)
if getattr(media, "thumbs", None) is not None:
copy.thumbs = ArrayList(media.thumbs)
return copy
def _send(account, params):
done = threading.Event()
def run():
try:
get_send_messages_helper(account).sendMessage(params)
except Exception as exc:
log("[telegraph_media_forward] sendMessage failed: %s" % exc)
finally:
done.set()
run_on_ui_thread(run)
# One at a time, so the caption message keeps its place before the media.
done.wait(30)
def _download(account, media, is_video):
"""Loads the file through FileLoader and returns its on-disk path."""
from org.telegram.messenger import FileLoader, ImageLocation
from org.telegram.tgnet import TLRPC
loader = FileLoader.getInstance(account)
if is_video or isinstance(media, TLRPC.Document):
target = media
loader.loadFile(media, None, FileLoader.PRIORITY_HIGH, 0)
else:
target = _best_size(media)
loader.loadFile(
ImageLocation.getForPhoto(target, media), None, "jpg", FileLoader.PRIORITY_HIGH, 0
)
candidates = [loader.getPathToAttach(target, True), loader.getPathToAttach(target, False)]
return _wait_for_file(candidates)
def _best_size(photo):
best = None
sizes = photo.sizes
for i in range(sizes.size()):
size = sizes.get(i)
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 SendFailed("this photo has no downloadable size")
return best
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 SendFailed("download timed out")
-2
View File
@@ -1,2 +0,0 @@
def normalize(name: str) -> str:
return " ".join(w[:1].upper() + w[1:] for w in name.split())
+174 -40
View File
@@ -50,6 +50,7 @@ LOG_LINES: list[str] = []
class BasePlugin: class BasePlugin:
def __init__(self): def __init__(self):
self._settings = {} self._settings = {}
self.hooked = []
def get_setting(self, key, default=None): def get_setting(self, key, default=None):
return self._settings.get(key, default) return self._settings.get(key, default)
@@ -57,8 +58,12 @@ class BasePlugin:
def set_setting(self, key, value): def set_setting(self, key, value):
self._settings[key] = value self._settings[key] = value
def add_on_send_message_hook(self): def hook_all_methods(self, clazz, name, hook):
pass self.hooked.append((clazz, name, hook))
return [object()]
def log(self, message):
fake_log(message)
def on_plugin_load(self): def on_plugin_load(self):
pass pass
@@ -70,6 +75,10 @@ class BasePlugin:
return [] return []
class MethodHook:
pass
def fake_log(*args, **kwargs): def fake_log(*args, **kwargs):
LOG_LINES.append(" ".join(str(a) for a in args)) LOG_LINES.append(" ".join(str(a) for a in args))
@@ -79,17 +88,96 @@ class _SettingsRow:
self.__dict__.update(kwargs) self.__dict__.update(kwargs)
class FakeAdapter:
"""Stand-in for PhotoViewer.PageBlocksAdapter."""
def __init__(self, page, media=None, file=None, video=False):
self.page = page
self.media = media
self.file = file
self.video = video
def getParentObject(self):
return self.page
def getMedia(self, index):
return self.media
def getFile(self, index):
return self.file
def isVideo(self, index):
return self.video
class FakeWebPage:
def __init__(self, url, display_url=None):
self.url = url
self.display_url = display_url
def install_fakes() -> None: def install_fakes() -> None:
base_plugin_mod = types.ModuleType("base_plugin") base_plugin_mod = types.ModuleType("base_plugin")
base_plugin_mod.BasePlugin = BasePlugin base_plugin_mod.BasePlugin = BasePlugin
base_plugin_mod.MethodHook = MethodHook
base_plugin_mod.HookResult = HookResult base_plugin_mod.HookResult = HookResult
base_plugin_mod.HookStrategy = HookStrategy base_plugin_mod.HookStrategy = HookStrategy
sys.modules["base_plugin"] = base_plugin_mod sys.modules["base_plugin"] = base_plugin_mod
android_utils_mod = types.ModuleType("android_utils") android_utils_mod = types.ModuleType("android_utils")
android_utils_mod.log = fake_log android_utils_mod.log = fake_log
android_utils_mod.run_on_ui_thread = lambda fn, delay=0: fn()
class OnClickListener:
def __init__(self, fn):
self.fn = fn
android_utils_mod.OnClickListener = OnClickListener
sys.modules["android_utils"] = android_utils_mod sys.modules["android_utils"] = android_utils_mod
hook_utils_mod = types.ModuleType("hook_utils")
hook_utils_mod.find_class = lambda name: FAKE_CLASSES.get(name)
hook_utils_mod.get_private_field = lambda obj, field: getattr(obj, field, None)
sys.modules["hook_utils"] = hook_utils_mod
client_utils_mod = types.ModuleType("client_utils")
client_utils_mod.get_selected_account = lambda: 0
client_utils_mod.get_last_fragment = lambda: None
client_utils_mod.get_send_messages_helper = lambda account=None: None
sys.modules["client_utils"] = client_utils_mod
# `TLRPC.WebPage` is only used for an isinstance() narrowing, so a fake
# class hierarchy is enough to exercise ivmedia.page_of().
tgnet_mod = types.ModuleType("org.telegram.tgnet")
tlrpc = types.SimpleNamespace(
WebPage=FakeWebPage,
# `_link_entities` fills these in as plain attributes, so a bare
# attribute holder reproduces the real TL object closely enough.
TL_messageEntityTextUrl=lambda: types.SimpleNamespace(
offset=0, length=0, url=None
),
)
tgnet_mod.TLRPC = tlrpc
# `java.util.ArrayList` only ever gets add()ed to and iterated.
java_mod = types.ModuleType("java")
java_mod.__path__ = []
java_util_mod = types.ModuleType("java.util")
class ArrayList(list):
def add(self, item):
self.append(item)
return True
java_util_mod.ArrayList = ArrayList
java_mod.util = java_util_mod
sys.modules.setdefault("java", java_mod)
sys.modules["java.util"] = java_util_mod
sys.modules["org.telegram.tgnet"] = tgnet_mod
org_mod = types.ModuleType("org")
org_mod.__path__ = []
sys.modules.setdefault("org", org_mod)
ui_mod = types.ModuleType("ui") ui_mod = types.ModuleType("ui")
ui_mod.__path__ = [] ui_mod.__path__ = []
sys.modules["ui"] = ui_mod sys.modules["ui"] = ui_mod
@@ -100,6 +188,26 @@ def install_fakes() -> None:
sys.modules["ui.settings"] = ui_settings_mod sys.modules["ui.settings"] = ui_settings_mod
ui_mod.settings = ui_settings_mod ui_mod.settings = ui_settings_mod
ui_bulletin_mod = types.ModuleType("ui.bulletin")
class BulletinHelper:
shown = []
@classmethod
def show_error(cls, message, fragment=None):
cls.shown.append(("error", message))
@classmethod
def show_success(cls, message, fragment=None):
cls.shown.append(("success", message))
ui_bulletin_mod.BulletinHelper = BulletinHelper
sys.modules["ui.bulletin"] = ui_bulletin_mod
ui_mod.bulletin = ui_bulletin_mod
FAKE_CLASSES = {"org.telegram.ui.PhotoViewer": object()}
class SimpleParams: class SimpleParams:
def __init__(self, message): def __init__(self, message):
@@ -147,7 +255,7 @@ def main(argv=None) -> int:
source = bundle_path.read_text(encoding="utf-8") source = bundle_path.read_text(encoding="utf-8")
modules_before = set(sys.modules) modules_before = set(sys.modules)
ns = {"__name__": "hello_bundle"} ns = {"__name__": json.loads(PLUGIN_JSON.read_text(encoding="utf-8"))["id"]}
exec(compile(source, str(bundle_path), "exec"), ns) exec(compile(source, str(bundle_path), "exec"), ns)
new_modules = set(sys.modules) - modules_before new_modules = set(sys.modules) - modules_before
@@ -197,59 +305,85 @@ def main(argv=None) -> int:
LOG_LINES.clear() LOG_LINES.clear()
plugin.on_plugin_load() plugin.on_plugin_load()
check( check(
"on_plugin_load emits load log line", "on_plugin_load hooks PhotoViewer.onPhotoShow",
any("hello_bundle loaded" in line for line in LOG_LINES), [name for _c, name, _h in plugin.hooked] == ["onPhotoShow"],
f"log lines: {LOG_LINES}", f"hooked: {[n for _c, n, _h in plugin.hooked]}",
) )
params = SimpleParams(".hello alice smith") rows = plugin.create_settings()
result = plugin.on_send_message_hook(account=0, params=params)
check( check(
"'.hello alice smith' renders greeting", "settings expose the any_article switch",
params.message == "Hello, Alice Smith!", any(getattr(row, "key", None) == "any_article" for row in rows),
f"got {params.message!r}", f"rows: {[getattr(r, 'key', None) for r in rows]}",
)
check(
"on_send_message_hook returns MODIFY strategy",
result.strategy == HookStrategy.MODIFY,
f"got {result.strategy}",
) )
plugin.set_setting("template", "{nope}") telegraph = FakeAdapter(FakeWebPage("https://telegra.ph/Some-Article-01-01"))
params2 = SimpleParams(".hello someone") graph_org = FakeAdapter(FakeWebPage("http://graph.org/x"))
plugin.on_send_message_hook(account=0, params=params2) other = FakeAdapter(FakeWebPage("https://example.com/post"))
lookalike = FakeAdapter(FakeWebPage("https://telegra.ph.evil.com/x"))
no_page = FakeAdapter(None)
check( check(
"bad template produces 'Bad template:' message", "telegra.ph and graph.org articles are accepted by default",
params2.message.startswith("Bad template:"), plugin.allows(telegraph) and plugin.allows(graph_org),
f"got {params2.message!r}", )
check(
"non-telegraph article is rejected by default",
not plugin.allows(other),
)
check(
"host that merely starts with telegra.ph is rejected",
not plugin.allows(lookalike),
)
check(
"adapter without a WebPage parent is rejected",
not plugin.allows(no_page),
) )
# main.py imports `render`/`TemplateError` by name, so they live directly plugin.set_setting("any_article", True)
# in the executed namespace — call render() directly to inspect the raised check(
# traceback (proves linecache registration for bundled, non-file modules). "any_article setting accepts other articles",
render = ns.get("render") plugin.allows(other) and not plugin.allows(no_page),
template_error = ns.get("TemplateError") )
traceback_text = ""
if render is not None and template_error is not None: ivmedia = ns.get("ivmedia")
try: sender = ns.get("sender")
render("{nope}", "x") caption = getattr(sender, "CAPTION_TEXT", None)
except template_error: link_entities = getattr(sender, "_link_entities", None)
traceback_text = traceback.format_exc() entities = link_entities("https://telegra.ph/x") if link_entities else []
entity = entities[0] if entities else None
check( check(
"render() traceback names src/greeting/template.py", "caption is a single text_url entity covering the whole caption",
"src/greeting/template.py" in traceback_text, isinstance(caption, str)
f"traceback:\n{traceback_text}", and len(caption) > 0
and entity is not None
and entity.offset == 0
and entity.length == len(caption)
and entity.url == "https://telegra.ph/x",
f"got caption={caption!r} entities={entities!r}",
) )
media_at = getattr(ivmedia, "media_at", None)
no_media = getattr(ivmedia, "NoMedia", None)
raised = False
traceback_text = ""
if media_at is not None and no_media is not None:
try:
media_at(FakeAdapter(FakeWebPage("https://telegra.ph/x"), media=None), 0)
except no_media:
raised = True
traceback_text = traceback.format_exc()
check("media_at raises NoMedia when the block has no media", raised)
check( check(
"traceback includes the raising source line text", "traceback names src/ivmedia.py with its source line",
"unsupported placeholder" in traceback_text, "src/ivmedia.py" in traceback_text and "no downloadable media" in traceback_text,
f"traceback:\n{traceback_text}", f"traceback:\n{traceback_text}",
) )
check( check(
"sys.modules gained no entry for bundled module names", "sys.modules gained no entry for bundled module names",
not ({"greeting", "textutil", "main"} & new_modules), not ({"icons", "ivmedia", "sender", "picker", "main"} & new_modules),
f"leaked: {({'greeting', 'textutil', 'main'} & new_modules)}", f"leaked: {({'icons', 'ivmedia', 'sender', 'picker', 'main'} & new_modules)}",
) )
if _FAILURES: if _FAILURES: