1 Commits
Author SHA1 Message Date
lyuksovannyyandClaude Opus 5.5 46503f24a0 Rename to Extended Media Forwards and forward rich message media
Publish plugin / publish (push) Successful in 5s
Rename the plugin (id extended_media_forwards) and add the Forward button
to media opened from rich messages, both single media and carousels.

Rich message media opens PhotoViewer through ChatActivity's
ChatArticlePageBlocksAdapter, whose parent object is the MessageObject
rather than a WebPage, so the article-only check hid the button. The
re-sent media links back to the message when it lives in a channel or
supergroup; elsewhere it is sent without a caption. FileLoader now gets
the parent object so expired file references can be refreshed.

Instant View carousels already worked: slideshow items are flattened
into the article's photo list. Findings are in docs/media-viewer-notes.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 00:51:58 +02:00
9 changed files with 317 additions and 36 deletions

No files matched your search

+1
View File
@@ -44,6 +44,7 @@ tools/
.gitea/workflows/ CI that builds, verifies and publishes the .plugin to Releases
scripts/ ADB debug-loop automation and the git hooks (see below)
docs/sdk-notes.md condensed SDK API surface gathered from the official docs
docs/media-viewer-notes.md app internals behind the Forward button (page-block adapters, rich messages)
```
## The three commands that matter
+100
View File
@@ -0,0 +1,100 @@
# Media viewer internals — findings
Notes on how exteraGram opens page-block media, gathered by decompiling the
12.10.6 APK (versionCode 71129) with jadx and testing on a device. Class and
field names are from that build. They are app internals, not SDK API, so check
them again after app updates.
## Two adapters, one PhotoViewer
Page-block media never opens with a `MessageObject` list. PhotoViewer runs on a
`PhotoViewer.PageBlocksAdapter` instead, and `onPhotoShow` fires the same way
for both callers below, so one hook covers them.
| Opened from | Adapter | `getParentObject()` |
|---|---|---|
| Instant View article (`ArticleViewer.openPhoto`) | `ArticleViewer.RealPageBlocksAdapter` | `TLRPC.WebPage` |
| Rich message in a chat (`ChatActivity.openArticlePhoto`) | `ChatActivity.ChatArticlePageBlocksAdapter` | the message's `MessageObject`, or the bare `TL_iv.RichMessage` when constructed without one |
Both adapters expose the same `getMedia(i)` / `getFile(i)` / `isVideo(i)`. Those
methods resolve through `ArticleViewer.WebPageUtils`, which has overloads for
`WebPage`, `TL_iv.Page` and `TL_iv.RichMessage`. That is why `ivmedia.media_at`
works unchanged for both. Only the parent object tells them apart.
A rich message is recognised by `MessageObject.messageOwner.rich_message != null`.
## Carousels (slideshows)
- **Articles:** `WebpageAdapter.addAllMediaFromBlock` flattens every
`pageBlockSlideshow` / `pageBlockCollage` item into `photoBlocks`. Tapping a
slideshow item therefore goes through `RealPageBlocksAdapter` like any other
article photo, and needed no special handling.
- **Rich messages:** `RichMessageLayout.collectMediaBlocks` flattens single
media and slideshow items into one list for `ChatArticlePageBlocksAdapter`.
Swiping in the viewer walks through all of them, and
`updateSlideshowCell` keeps the in-chat carousel in sync.
## telegra.ph cannot produce a carousel
Telegraph has no gallery/slideshow node. The editor has no such block, and
`createPage` keeps only the first `<img>` of a `<figure>` holding several, so
consecutive images render as separate `pageBlockPhoto`s. Carousels only reach
the viewer from other Instant View templates (sites other than telegra.ph,
covered by the `any_article` setting) and from rich messages.
`telegra.ph/upload` no longer accepts files ("Unknown error"). Test pages have
to reference external image URLs, e.g. on `upload.wikimedia.org`.
## Rich messages need Premium to send media
The rich composer is the attach menu's "Article" tab (`ChatAttachAlertRichLayout`).
Sending is locked unless `MessagesController.richEditorAllowed()` or
`UserConfig.isPremium()`, whenever the content is "lossy"
(`RichMessageConvert.isLossy`). Lossy means anything a plain message can't
represent, and every media block is lossy. A non-Premium account therefore
cannot send a test rich message with media.
To test without one, build the message locally and open it the same way
`ChatActivity.openArticlePhoto` does:
```python
rm = TL_iv.RichMessage() # fill rm.photos and rm.blocks
msg = TLRPC.TL_message() # peer_id/id/dialog_id of any chat
msg.rich_message = rm
mo = MessageObject(account, msg, False, False)
adapter = ChatActivity.ChatArticlePageBlocksAdapter(rm, flat_blocks, mo)
viewer = PhotoViewer.getInstance()
viewer.setParentActivity(chat_fragment)
viewer.openPhoto(index, adapter, PhotoViewer.EmptyPhotoViewerProvider())
```
Real `TLRPC.Photo` objects with server locations can be borrowed from the
`cached_page.photos` of any loaded article. Pointing `peer_id` at a channel
exercises the message-link caption.
`ArticleViewer.open(TL_webPage, anchor)` re-requests `messages.getWebPage` and
replaces the page with the server copy. Copy the original `hash` onto the
injected `TL_webPage` to keep a locally modified `cached_page`.
## Message links
Only channels and supergroups (`ChatObject.isChannel`) have message links:
`https://t.me/<username>/<id>` when public, `https://t.me/c/<chat id>/<id>`
otherwise. On Android the dialog id of a channel is `-chat.id`, with no `-100`
prefix. DMs, basic groups and Saved Messages have no link, so media forwarded
from rich messages there is sent without a caption.
## File references
Pass the adapter's parent object (WebPage or MessageObject) as `parentObject`
to `FileLoader.loadFile`. `FileRefController` uses it to refresh an expired
file reference, and it handles rich messages explicitly.
## Device debugging
Logcat can be completely silent when the device's log buffer is disabled in
developer options, and `scripts/logs.sh` then shows nothing. A plugin can
still write diagnostics to
`ApplicationLoader.applicationContext.getExternalFilesDir(None)`, which maps
to `/sdcard/Android/data/com.exteragram.messenger/files/` and is readable with
`adb shell cat` without `run-as`.
+4 -4
View File
@@ -1,9 +1,9 @@
{
"id": "telegraph_media_forward",
"name": "Telegraph Media Forward",
"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.",
"id": "extended_media_forwards",
"name": "Extended Media Forwards",
"description": "Adds a Forward action to media opened from telegra.ph pages and rich messages (single media and carousels): downloads the media and re-sends it from your account with a link back to its source.",
"author": "@lyuksovannyy",
"version": "1.0.0",
"version": "1.1.1",
"icon": "exteraPlugins/1",
"app_version": ">=12.5.1",
"sdk_version": ">=1.4.4.3",
+2 -2
View File
@@ -49,11 +49,11 @@ def drawable(context, res_id):
if packed is not None:
return packed
except Exception as exc:
log("[telegraph_media_forward] icon pack lookup failed: %s" % exc)
log("[extended_media_forwards] 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)
log("[extended_media_forwards] icon remap failed: %s" % exc)
resources = context.getResources()
if density:
return resources.getDrawableForDensity(res_id, density, theme)
+51 -1
View File
@@ -1,9 +1,13 @@
"""Reads media out of the PhotoViewer instance that Instant View opened.
"""Reads media out of a PhotoViewer that runs on a `PageBlocksAdapter`.
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.
Rich messages (page blocks sent as a chat message) open their single media and
carousels the same way, through ChatActivity's `ChatArticlePageBlocksAdapter`,
whose parent object is the message's `MessageObject` instead of a WebPage.
"""
from hook_utils import get_private_field
@@ -81,6 +85,52 @@ def is_telegraph(adapter):
return any(host == h or host.endswith("." + h) for h in TELEGRAPH_HOSTS)
def rich_parent(adapter):
"""For media opened from a rich message: the `MessageObject` carrying it,
or the bare `TL_iv.RichMessage` when the adapter has no message. None for
anything else, articles included."""
from org.telegram.messenger import MessageObject
from org.telegram.tgnet.tl import TL_iv
parent = adapter.getParentObject()
if isinstance(parent, MessageObject):
owner = parent.messageOwner
if owner is not None and owner.rich_message is not None:
return parent
return None
if isinstance(parent, TL_iv.RichMessage):
return parent
return None
def is_rich_message(adapter):
return rich_parent(adapter) is not None
def message_link(account, message):
"""t.me link to `message`, or None when it has none.
Only channels and supergroups have message links: public ones by username,
private ones through t.me/c/. DMs, basic groups and unsent local messages
cannot be linked to.
"""
from org.telegram.messenger import ChatObject, MessageObject, MessagesController
if not isinstance(message, MessageObject):
return None
msg_id = message.getId()
dialog_id = message.getDialogId()
if msg_id <= 0 or dialog_id >= 0:
return None
chat = MessagesController.getInstance(account).getChat(-dialog_id)
if chat is None or not ChatObject.isChannel(chat):
return None
username = ChatObject.getPublicUsername(chat)
if username:
return "https://t.me/%s/%d" % (username, msg_id)
return "https://t.me/c/%d/%d" % (chat.id, msg_id)
def media_at(adapter, index):
"""`(media, existing_file_path_or_None, is_video)` for the shown block."""
media = adapter.getMedia(index)
+28 -8
View File
@@ -17,7 +17,7 @@ _BUTTON_SIZE_DP = 48.0
_LOAD_TOKEN = "tmf-%d" % id(object())
class TelegraphMediaForwardPlugin(BasePlugin):
class ExtendedMediaForwardsPlugin(BasePlugin):
def on_plugin_load(self):
viewer = find_class("org.telegram.ui.PhotoViewer")
if viewer is None:
@@ -31,7 +31,7 @@ class TelegraphMediaForwardPlugin(BasePlugin):
def create_settings(self):
return [
Header(text="Telegraph Media Forward"),
Header(text="Extended Media Forwards"),
Switch(
key="any_article",
text="Allow on any Instant View article",
@@ -39,18 +39,27 @@ class TelegraphMediaForwardPlugin(BasePlugin):
subtext="Off: only telegra.ph and graph.org pages.",
icon="msg_link",
),
Switch(
key="rich_messages",
text="Allow on rich messages",
default=True,
subtext="Single media and carousels inside rich (article) messages.",
icon="msg_media",
),
Text(
text="How it works",
subtext=(
"Open a photo or video from an article, tap the ⋮ menu and choose "
"Open a photo or video from an article or a rich message and tap "
"Forward. The file is downloaded and sent from your account with "
"an Original link to the page."
"a link back to its source when it has one."
),
icon="msg_info",
),
]
def allows(self, adapter):
if ivmedia.is_rich_message(adapter):
return self.get_setting("rich_messages", True)
if self.get_setting("any_article", False):
return ivmedia.page_url(adapter) is not None
return ivmedia.is_telegraph(adapter)
@@ -60,7 +69,6 @@ class TelegraphMediaForwardPlugin(BasePlugin):
adapter = ivmedia.adapter_of(viewer)
if adapter is None:
return
url = ivmedia.page_url(adapter)
index = ivmedia.current_index(viewer)
try:
media, path, is_video = ivmedia.media_at(adapter, index)
@@ -68,6 +76,8 @@ class TelegraphMediaForwardPlugin(BasePlugin):
sender.notify_error(str(exc))
return
account = get_selected_account()
caption = _caption_for(account, adapter)
parent = adapter.getParentObject()
context = ivmedia.parent_activity(viewer)
if context is None:
return
@@ -78,17 +88,27 @@ class TelegraphMediaForwardPlugin(BasePlugin):
# 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)
sender.send_media(account, media, path, is_video, caption, peers, comment, parent)
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)
log("[extended_media_forwards] picker failed: %s" % exc)
sender.notify_error(str(exc))
def _caption_for(account, adapter):
"""`(text, url)` linking back to where the media came from, or None."""
rich = ivmedia.rich_parent(adapter)
if rich is not None:
url = ivmedia.message_link(account, rich)
return (sender.RICH_CAPTION_TEXT, url) if url else None
url = ivmedia.page_url(adapter)
return (sender.CAPTION_TEXT, url) if url else None
class _OnPhotoShow(MethodHook):
"""Adds (or re-shows) the Forward item every time the viewer opens media."""
@@ -122,7 +142,7 @@ def _sync_menu(plugin, viewer):
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)
log("[extended_media_forwards] menu sync failed: %s" % exc)
def _find_button(menu):
+1 -1
View File
@@ -89,4 +89,4 @@ def _enable_focus(alert):
if alert.getWindow() is not None:
alert.setFocusable(True)
except Exception as exc:
log("[telegraph_media_forward] focus handoff failed: %s" % exc)
log("[extended_media_forwards] focus handoff failed: %s" % exc)
+29 -19
View File
@@ -1,9 +1,11 @@
"""Downloads telegra.ph media and re-sends it from the user's own account.
"""Downloads page-block 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.
cannot be forwarded. Rich-message media is part of a message, but only as one
block of it, so forwarding would carry the whole message along. 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. The
caption, when there is a source to link to, is a single link back to it.
"""
import threading
@@ -33,25 +35,31 @@ def notify_success(text):
CAPTION_TEXT = "telegra.ph source"
RICH_CAPTION_TEXT = "message 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 send_media(account, media, cached_path, is_video, caption, peers, comment, parent=None):
"""Starts the download+send in the background. Safe to call on the UI thread.
`caption` is `(text, url)` for a linked caption or None for none. `parent`
is the object the media belongs to (WebPage or MessageObject); FileLoader
uses it to refresh an expired file reference.
"""
def worker():
try:
path = cached_path or _download(account, media, is_video)
path = cached_path or _download(account, media, is_video, parent)
for peer in peers:
if comment:
_send(account, _text_params(comment, peer))
_send(account, _media_params(account, media, path, is_video, page_url, peer))
_send(account, _media_params(account, media, path, is_video, caption, peer))
except Exception as exc:
log("[telegraph_media_forward] send failed: %s" % exc)
log("[extended_media_forwards] send failed: %s" % exc)
notify_error(str(exc))
return
notify_success("Sent")
threading.Thread(target=worker, name="telegraph-media-forward", daemon=True).start()
threading.Thread(target=worker, name="extended-media-forwards", daemon=True).start()
def _text_params(text, peer):
@@ -60,12 +68,14 @@ def _text_params(text, peer):
return SendMessagesHelper.SendMessageParams.of(text, peer)
def _media_params(account, media, path, is_video, page_url, peer):
def _media_params(account, media, path, is_video, caption, peer):
from org.telegram.messenger import SendMessagesHelper
params = SendMessagesHelper.SendMessageParams.of(None, peer)
params.caption = CAPTION_TEXT
params.entities = _link_entities(page_url)
if caption is not None:
text, url = caption
params.caption = text
params.entities = _link_entities(text, url)
params.path = path
if is_video:
params.document = _fresh_document(account, media, path)
@@ -77,14 +87,14 @@ def _media_params(account, media, path, is_video, page_url, peer):
return params
def _link_entities(url):
def _link_entities(text, 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.length = len(text)
entity.url = url
entities = ArrayList()
entities.add(entity)
@@ -119,7 +129,7 @@ def _send(account, params):
try:
get_send_messages_helper(account).sendMessage(params)
except Exception as exc:
log("[telegraph_media_forward] sendMessage failed: %s" % exc)
log("[extended_media_forwards] sendMessage failed: %s" % exc)
finally:
done.set()
@@ -128,7 +138,7 @@ def _send(account, params):
done.wait(30)
def _download(account, media, is_video):
def _download(account, media, is_video, parent=None):
"""Loads the file through FileLoader and returns its on-disk path."""
from org.telegram.messenger import FileLoader, ImageLocation
from org.telegram.tgnet import TLRPC
@@ -136,11 +146,11 @@ def _download(account, media, is_video):
loader = FileLoader.getInstance(account)
if is_video or isinstance(media, TLRPC.Document):
target = media
loader.loadFile(media, None, FileLoader.PRIORITY_HIGH, 0)
loader.loadFile(media, parent, FileLoader.PRIORITY_HIGH, 0)
else:
target = _best_size(media)
loader.loadFile(
ImageLocation.getForPhoto(target, media), None, "jpg", FileLoader.PRIORITY_HIGH, 0
ImageLocation.getForPhoto(target, media), parent, "jpg", FileLoader.PRIORITY_HIGH, 0
)
candidates = [loader.getPathToAttach(target, True), loader.getPathToAttach(target, False)]
return _wait_for_file(candidates)
+101 -1
View File
@@ -116,6 +116,42 @@ class FakeWebPage:
self.display_url = display_url
class FakeRichMessage:
"""Stand-in for TL_iv.RichMessage."""
class FakeMessage:
def __init__(self, rich_message=None):
self.rich_message = rich_message
class FakeMessageObject:
def __init__(self, msg_id, dialog_id, rich_message=None):
self.messageOwner = FakeMessage(rich_message)
self._id = msg_id
self._dialog_id = dialog_id
def getId(self):
return self._id
def getDialogId(self):
return self._dialog_id
class FakeChat:
def __init__(self, chat_id, channel, username=None):
self.id = chat_id
self.channel = channel
self.username = username
FAKE_CHATS = {
100: FakeChat(100, channel=True, username="publicchan"),
200: FakeChat(200, channel=True),
300: FakeChat(300, channel=False),
}
def install_fakes() -> None:
base_plugin_mod = types.ModuleType("base_plugin")
base_plugin_mod.BasePlugin = BasePlugin
@@ -158,6 +194,22 @@ def install_fakes() -> None:
),
)
tgnet_mod.TLRPC = tlrpc
tgnet_mod.__path__ = []
tgnet_tl_mod = types.ModuleType("org.telegram.tgnet.tl")
tgnet_tl_mod.TL_iv = types.SimpleNamespace(RichMessage=FakeRichMessage)
tgnet_mod.tl = tgnet_tl_mod
sys.modules["org.telegram.tgnet.tl"] = tgnet_tl_mod
messenger_mod = types.ModuleType("org.telegram.messenger")
messenger_mod.MessageObject = FakeMessageObject
messenger_mod.ChatObject = types.SimpleNamespace(
isChannel=lambda chat: chat.channel,
getPublicUsername=lambda chat: chat.username,
)
messenger_mod.MessagesController = types.SimpleNamespace(
getInstance=lambda account: types.SimpleNamespace(getChat=FAKE_CHATS.get)
)
sys.modules["org.telegram.messenger"] = messenger_mod
# `java.util.ArrayList` only ever gets add()ed to and iterated.
java_mod = types.ModuleType("java")
@@ -346,11 +398,32 @@ def main(argv=None) -> int:
plugin.allows(other) and not plugin.allows(no_page),
)
rich_msg = FakeAdapter(FakeMessageObject(5, -1000000000200, FakeRichMessage()))
rich_bare = FakeAdapter(FakeRichMessage())
plain_msg = FakeAdapter(FakeMessageObject(5, -1000000000200, None))
check(
"settings expose the rich_messages switch",
any(getattr(row, "key", None) == "rich_messages" for row in rows),
)
check(
"rich message media is accepted by default (with or without a message)",
plugin.allows(rich_msg) and plugin.allows(rich_bare),
)
check(
"message parent without a rich message is rejected",
not plugin.allows(plain_msg),
)
plugin.set_setting("rich_messages", False)
check(
"rich_messages setting off rejects rich message media",
not plugin.allows(rich_msg) and not plugin.allows(rich_bare),
)
ivmedia = ns.get("ivmedia")
sender = ns.get("sender")
caption = getattr(sender, "CAPTION_TEXT", None)
link_entities = getattr(sender, "_link_entities", None)
entities = link_entities("https://telegra.ph/x") if link_entities else []
entities = link_entities(caption, "https://telegra.ph/x") if link_entities and caption else []
entity = entities[0] if entities else None
check(
"caption is a single text_url entity covering the whole caption",
@@ -363,6 +436,33 @@ def main(argv=None) -> int:
f"got caption={caption!r} entities={entities!r}",
)
message_link = getattr(ivmedia, "message_link", None)
if message_link is not None:
# Channel dialog ids are -(chat id) in this fake, like getDialogId().
links = (
message_link(0, FakeMessageObject(7, -100)),
message_link(0, FakeMessageObject(8, -200)),
message_link(0, FakeMessageObject(9, -300)),
message_link(0, FakeMessageObject(9, 12345)),
message_link(0, FakeMessageObject(-1, -100)),
message_link(0, FakeRichMessage()),
)
else:
links = None
check(
"message_link: public/private channels linked; groups, DMs, local and bare rich messages not",
links
== (
"https://t.me/publicchan/7",
"https://t.me/c/200/8",
None,
None,
None,
None,
),
f"got {links!r}",
)
media_at = getattr(ivmedia, "media_at", None)
no_media = getattr(ivmedia, "NoMedia", None)
raised = False