2 Commits
Author SHA1 Message Date
lyuksovannyyandClaude Opus 5.5 1f0a9a1a7a Send forwarded media to the forum topic picked in the share sheet
Publish plugin / publish (push) Successful in 3s
The picker only read the selected dialog ids, so media always landed in
a forum's General topic. Read ShareAlert's selectedDialogTopics as well
and address each target the way ShareAlert.sendInternal() does: reply to
the topic's start message in forums, or set monoForumPeer in a channel's
direct-messages chat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:50:03 +02:00
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 461 additions and 53 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 .gitea/workflows/ CI that builds, verifies and publishes the .plugin to Releases
scripts/ ADB debug-loop automation and the git hooks (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 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 ## The three commands that matter
+119
View File
@@ -0,0 +1,119 @@
# 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.
## Forum topics in the share sheet
When a forum is picked in `ShareAlert`, the topic lands in the protected
`selectedDialogTopics` map. That map is keyed by the `TLRPC.Dialog` values of
`selectedDialogs`, not by dialog id. `ShareAlert.sendInternal()` then addresses
each chat in one of two ways:
- **Forum topic:** wraps `topic.topicStartMessage` in a `MessageObject`, sets
`isTopicMainMessage = true`, and passes it as both `replyToMsg` and
`replyToTopMsg`. `SendMessagesHelper` sends its id as `top_msg_id`. A topic's
id is its start message's id.
- **Mono forum** (a channel's direct-messages chat,
`MessagesController.isMonoForum`): no reply message. Instead
`monoForumPeer = DialogObject.getPeerDialogId(topic.from_id)`.
A plugin that only reads the `selectedDialogs` keys sends to the chat's
General topic. `picker._selected_targets` reproduces the logic above inside
`didShare()`, which runs before the sheet clears its selection.
## 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", "id": "extended_media_forwards",
"name": "Telegraph Media Forward", "name": "Extended Media Forwards",
"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.", "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", "author": "@lyuksovannyy",
"version": "1.0.0", "version": "1.1.2",
"icon": "exteraPlugins/1", "icon": "exteraPlugins/1",
"app_version": ">=12.5.1", "app_version": ">=12.5.1",
"sdk_version": ">=1.4.4.3", "sdk_version": ">=1.4.4.3",
+2 -2
View File
@@ -49,11 +49,11 @@ def drawable(context, res_id):
if packed is not None: if packed is not None:
return packed return packed
except Exception as exc: 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: try:
res_id = manager.getIcon(res_id) res_id = manager.getIcon(res_id)
except Exception as exc: 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() resources = context.getResources()
if density: if density:
return resources.getDrawableForDensity(res_id, density, theme) 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 When ArticleViewer opens a telegra.ph photo/video, PhotoViewer runs with
`currentMessageObject == null` and a `PageBlocksAdapter` instead. That adapter `currentMessageObject == null` and a `PageBlocksAdapter` instead. That adapter
is the only handle to the underlying `TLRPC.Photo` / `TLRPC.Document` and to 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. 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 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) 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): def media_at(adapter, index):
"""`(media, existing_file_path_or_None, is_video)` for the shown block.""" """`(media, existing_file_path_or_None, is_video)` for the shown block."""
media = adapter.getMedia(index) media = adapter.getMedia(index)
+31 -11
View File
@@ -17,7 +17,7 @@ _BUTTON_SIZE_DP = 48.0
_LOAD_TOKEN = "tmf-%d" % id(object()) _LOAD_TOKEN = "tmf-%d" % id(object())
class TelegraphMediaForwardPlugin(BasePlugin): class ExtendedMediaForwardsPlugin(BasePlugin):
def on_plugin_load(self): def on_plugin_load(self):
viewer = find_class("org.telegram.ui.PhotoViewer") viewer = find_class("org.telegram.ui.PhotoViewer")
if viewer is None: if viewer is None:
@@ -31,7 +31,7 @@ class TelegraphMediaForwardPlugin(BasePlugin):
def create_settings(self): def create_settings(self):
return [ return [
Header(text="Telegraph Media Forward"), Header(text="Extended Media Forwards"),
Switch( Switch(
key="any_article", key="any_article",
text="Allow on any Instant View article", text="Allow on any Instant View article",
@@ -39,18 +39,27 @@ class TelegraphMediaForwardPlugin(BasePlugin):
subtext="Off: only telegra.ph and graph.org pages.", subtext="Off: only telegra.ph and graph.org pages.",
icon="msg_link", 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(
text="How it works", text="How it works",
subtext=( 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 " "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", icon="msg_info",
), ),
] ]
def allows(self, adapter): def allows(self, adapter):
if ivmedia.is_rich_message(adapter):
return self.get_setting("rich_messages", True)
if self.get_setting("any_article", False): if self.get_setting("any_article", False):
return ivmedia.page_url(adapter) is not None return ivmedia.page_url(adapter) is not None
return ivmedia.is_telegraph(adapter) return ivmedia.is_telegraph(adapter)
@@ -60,7 +69,6 @@ class TelegraphMediaForwardPlugin(BasePlugin):
adapter = ivmedia.adapter_of(viewer) adapter = ivmedia.adapter_of(viewer)
if adapter is None: if adapter is None:
return return
url = ivmedia.page_url(adapter)
index = ivmedia.current_index(viewer) index = ivmedia.current_index(viewer)
try: try:
media, path, is_video = ivmedia.media_at(adapter, index) media, path, is_video = ivmedia.media_at(adapter, index)
@@ -68,27 +76,39 @@ class TelegraphMediaForwardPlugin(BasePlugin):
sender.notify_error(str(exc)) sender.notify_error(str(exc))
return return
account = get_selected_account() account = get_selected_account()
caption = _caption_for(account, adapter)
parent = adapter.getParentObject()
context = ivmedia.parent_activity(viewer) context = ivmedia.parent_activity(viewer)
if context is None: if context is None:
return return
def picked(peers, comment): def picked(targets, comment):
if not peers: if not targets:
return return
# The sheet sits above the viewer's window; drop it so the sent # The sheet sits above the viewer's window; drop it so the sent
# message is what the user lands on. # message is what the user lands on.
viewer.closePhoto(False, False) viewer.closePhoto(False, False)
sender.send_media(account, media, path, is_video, url, peers, comment) sender.send_media(account, media, path, is_video, caption, targets, comment, parent)
try: try:
# ShareAlert owns its own window, so it shows over PhotoViewer just # ShareAlert owns its own window, so it shows over PhotoViewer just
# like the stock share button does - no need to close the viewer. # like the stock share button does - no need to close the viewer.
picker.pick_chats(context, picked, ivmedia.resources_provider(viewer)) picker.pick_chats(context, account, picked, ivmedia.resources_provider(viewer))
except Exception as exc: 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)) 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): class _OnPhotoShow(MethodHook):
"""Adds (or re-shows) the Forward item every time the viewer opens media.""" """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 = _add_button(plugin, menu, viewer)
button.setVisibility(View.VISIBLE if visible else View.GONE) button.setVisibility(View.VISIBLE if visible else View.GONE)
except Exception as exc: 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): def _find_button(menu):
+40 -10
View File
@@ -5,12 +5,21 @@ the picker pixel-identical to the stock one. It is constructed with no payload
(`sendingMessageObjects`, `sendingText` and `sendingFile` all null), which makes (`sendingMessageObjects`, `sendingText` and `sendingFile` all null), which makes
its send path a no-op: it selects chats, fires `ShareAlertDelegate.didShare()` its send path a no-op: it selects chats, fires `ShareAlertDelegate.didShare()`
and dismisses. The selection is read back off the (protected) `selectedDialogs` and dismisses. The selection is read back off the (protected) `selectedDialogs`
field and the comment off `commentTextView`. field, the topic picked in each forum off `selectedDialogTopics`, and the
comment off `commentTextView`.
""" """
from collections import namedtuple
from android_utils import log, run_on_ui_thread from android_utils import log, run_on_ui_thread
from hook_utils import get_private_field from hook_utils import get_private_field
# Where to send: `peer` is the dialog id. In a forum `top_message` is the
# topic's start message, which the stock sheet replies to so the message lands
# in that topic. In a channel's direct-messages chat (a "mono forum") each
# topic is one user instead, addressed through `mono_forum_peer`.
Target = namedtuple("Target", "peer top_message mono_forum_peer")
_delegate_class = None _delegate_class = None
# ShareAlert's own constant for "keep the keyboard out of the way". # ShareAlert's own constant for "keep the keyboard out of the way".
@@ -28,25 +37,46 @@ def _make_delegate_class():
from org.telegram.ui.Components import ShareAlert from org.telegram.ui.Components import ShareAlert
class Delegate(dynamic_proxy(ShareAlert.ShareAlertDelegate)): class Delegate(dynamic_proxy(ShareAlert.ShareAlertDelegate)):
def __init__(self, alert, on_picked): def __init__(self, alert, account, on_picked):
super().__init__() super().__init__()
self.alert = alert self.alert = alert
self.account = account
self.on_picked = on_picked self.on_picked = on_picked
def didCopy(self): def didCopy(self):
return False return False
def didShare(self): def didShare(self):
self.on_picked(_selected_peers(self.alert), _comment(self.alert)) self.on_picked(_selected_targets(self.alert, self.account), _comment(self.alert))
return Delegate return Delegate
def _selected_peers(alert): def _selected_targets(alert, account):
"""One Target per chosen chat, resolved the way ShareAlert.sendInternal()
resolves them. Must run on the UI thread, before the sheet clears its
selection."""
from org.telegram.messenger import DialogObject, MessageObject, MessagesController
dialogs = get_private_field(alert, "selectedDialogs") dialogs = get_private_field(alert, "selectedDialogs")
if dialogs is None: if dialogs is None:
return [] return []
return [dialogs.keyAt(i) for i in range(dialogs.size())] # Keyed by the TLRPC.Dialog values of selectedDialogs, not by dialog id.
topics = get_private_field(alert, "selectedDialogTopics")
targets = []
for i in range(dialogs.size()):
peer = dialogs.keyAt(i)
topic = topics.get(dialogs.valueAt(i)) if topics is not None else None
top_message = None
mono_forum_peer = 0
if topic is not None:
if MessagesController.getInstance(account).isMonoForum(peer):
mono_forum_peer = DialogObject.getPeerDialogId(topic.from_id)
elif topic.topicStartMessage is not None:
top_message = MessageObject(account, topic.topicStartMessage, False, False)
top_message.isTopicMainMessage = True
targets.append(Target(peer, top_message, mono_forum_peer))
return targets
def _comment(alert): def _comment(alert):
@@ -57,9 +87,9 @@ def _comment(alert):
return str(text).strip() if text is not None else "" return str(text).strip() if text is not None else ""
def pick_chats(context, on_picked, resources_provider=None): def pick_chats(context, account, on_picked, resources_provider=None):
"""Shows the stock share sheet. `on_picked(dialog_ids, comment)` runs on the """Shows the stock share sheet. `on_picked(targets, comment)` runs on the
UI thread once the user confirms.""" UI thread once the user confirms, with one `Target` per chosen chat."""
global _delegate_class global _delegate_class
from org.telegram.ui.Components import ShareAlert from org.telegram.ui.Components import ShareAlert
@@ -68,7 +98,7 @@ def pick_chats(context, on_picked, resources_provider=None):
if _delegate_class is None: if _delegate_class is None:
_delegate_class = _make_delegate_class() _delegate_class = _make_delegate_class()
# Keep a reference on the module so the proxy is not collected early. # Keep a reference on the module so the proxy is not collected early.
delegate = _delegate_class(alert, on_picked) delegate = _delegate_class(alert, account, on_picked)
alert.setDelegate(delegate) alert.setDelegate(delegate)
pick_chats._alive = delegate pick_chats._alive = delegate
@@ -89,4 +119,4 @@ def _enable_focus(alert):
if alert.getWindow() is not None: if alert.getWindow() is not None:
alert.setFocusable(True) alert.setFocusable(True)
except Exception as exc: except Exception as exc:
log("[telegraph_media_forward] focus handoff failed: %s" % exc) log("[extended_media_forwards] focus handoff failed: %s" % exc)
+44 -24
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 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 cannot be forwarded. Rich-message media is part of a message, but only as one
if already cached) and uploaded as a fresh photo/document, exactly like picking block of it, so forwarding would carry the whole message along. Instead the
the file from the gallery. Caption is a single markdown link to the article. 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 import threading
@@ -33,39 +35,57 @@ def notify_success(text):
CAPTION_TEXT = "telegra.ph source" CAPTION_TEXT = "telegra.ph source"
RICH_CAPTION_TEXT = "message source"
def send_media(account, media, cached_path, is_video, page_url, peers, comment): def send_media(account, media, cached_path, is_video, caption, targets, comment, parent=None):
"""Starts the download+send in the background. Safe to call on the UI thread.""" """Starts the download+send in the background. Safe to call on the UI thread.
`targets` are `picker.Target`s. `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(): def worker():
try: try:
path = cached_path or _download(account, media, is_video) path = cached_path or _download(account, media, is_video, parent)
for peer in peers: for target in targets:
if comment: if comment:
_send(account, _text_params(comment, peer)) _send(account, _text_params(comment, target))
_send(account, _media_params(account, media, path, is_video, page_url, peer)) _send(account, _media_params(account, media, path, is_video, caption, target))
except Exception as exc: except Exception as exc:
log("[telegraph_media_forward] send failed: %s" % exc) log("[extended_media_forwards] send failed: %s" % exc)
notify_error(str(exc)) notify_error(str(exc))
return return
notify_success("Sent") 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): def _address(params, target):
"""Points `params` at the chosen forum topic, as ShareAlert does."""
if target.top_message is not None:
params.replyToMsg = target.top_message
params.replyToTopMsg = target.top_message
if target.mono_forum_peer:
params.monoForumPeer = target.mono_forum_peer
return params
def _text_params(text, target):
from org.telegram.messenger import SendMessagesHelper from org.telegram.messenger import SendMessagesHelper
return SendMessagesHelper.SendMessageParams.of(text, peer) return _address(SendMessagesHelper.SendMessageParams.of(text, target.peer), target)
def _media_params(account, media, path, is_video, page_url, peer): def _media_params(account, media, path, is_video, caption, target):
from org.telegram.messenger import SendMessagesHelper from org.telegram.messenger import SendMessagesHelper
params = SendMessagesHelper.SendMessageParams.of(None, peer) params = _address(SendMessagesHelper.SendMessageParams.of(None, target.peer), target)
params.caption = CAPTION_TEXT if caption is not None:
params.entities = _link_entities(page_url) text, url = caption
params.caption = text
params.entities = _link_entities(text, url)
params.path = path params.path = path
if is_video: if is_video:
params.document = _fresh_document(account, media, path) params.document = _fresh_document(account, media, path)
@@ -77,14 +97,14 @@ def _media_params(account, media, path, is_video, page_url, peer):
return params return params
def _link_entities(url): def _link_entities(text, url):
"""Whole caption as one text_url entity, so it renders as a link.""" """Whole caption as one text_url entity, so it renders as a link."""
from java.util import ArrayList from java.util import ArrayList
from org.telegram.tgnet import TLRPC from org.telegram.tgnet import TLRPC
entity = TLRPC.TL_messageEntityTextUrl() entity = TLRPC.TL_messageEntityTextUrl()
entity.offset = 0 entity.offset = 0
entity.length = len(CAPTION_TEXT) entity.length = len(text)
entity.url = url entity.url = url
entities = ArrayList() entities = ArrayList()
entities.add(entity) entities.add(entity)
@@ -119,7 +139,7 @@ def _send(account, params):
try: try:
get_send_messages_helper(account).sendMessage(params) get_send_messages_helper(account).sendMessage(params)
except Exception as exc: except Exception as exc:
log("[telegraph_media_forward] sendMessage failed: %s" % exc) log("[extended_media_forwards] sendMessage failed: %s" % exc)
finally: finally:
done.set() done.set()
@@ -128,7 +148,7 @@ def _send(account, params):
done.wait(30) 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.""" """Loads the file through FileLoader and returns its on-disk path."""
from org.telegram.messenger import FileLoader, ImageLocation from org.telegram.messenger import FileLoader, ImageLocation
from org.telegram.tgnet import TLRPC from org.telegram.tgnet import TLRPC
@@ -136,11 +156,11 @@ def _download(account, media, is_video):
loader = FileLoader.getInstance(account) loader = FileLoader.getInstance(account)
if is_video or isinstance(media, TLRPC.Document): if is_video or isinstance(media, TLRPC.Document):
target = media target = media
loader.loadFile(media, None, FileLoader.PRIORITY_HIGH, 0) loader.loadFile(media, parent, FileLoader.PRIORITY_HIGH, 0)
else: else:
target = _best_size(media) target = _best_size(media)
loader.loadFile( 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)] candidates = [loader.getPathToAttach(target, True), loader.getPathToAttach(target, False)]
return _wait_for_file(candidates) return _wait_for_file(candidates)
+169 -1
View File
@@ -116,6 +116,42 @@ class FakeWebPage:
self.display_url = display_url 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: 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
@@ -158,6 +194,22 @@ def install_fakes() -> None:
), ),
) )
tgnet_mod.TLRPC = tlrpc 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.util.ArrayList` only ever gets add()ed to and iterated.
java_mod = types.ModuleType("java") java_mod = types.ModuleType("java")
@@ -346,11 +398,32 @@ def main(argv=None) -> int:
plugin.allows(other) and not plugin.allows(no_page), 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") ivmedia = ns.get("ivmedia")
sender = ns.get("sender") sender = ns.get("sender")
caption = getattr(sender, "CAPTION_TEXT", None) caption = getattr(sender, "CAPTION_TEXT", None)
link_entities = getattr(sender, "_link_entities", 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 entity = entities[0] if entities else None
check( check(
"caption is a single text_url entity covering the whole caption", "caption is a single text_url entity covering the whole caption",
@@ -363,6 +436,101 @@ def main(argv=None) -> int:
f"got caption={caption!r} entities={entities!r}", 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}",
)
picker = ns.get("picker")
selected_targets = getattr(picker, "_selected_targets", None)
address = getattr(sender, "_address", None)
targets = None
addressed = None
if selected_targets is not None and address is not None:
class SparseArray:
def __init__(self, pairs):
self.pairs = pairs
def size(self):
return len(self.pairs)
def keyAt(self, i):
return self.pairs[i][0]
def valueAt(self, i):
return self.pairs[i][1]
class TopicMessageObject:
def __init__(self, account, message, generate_layout, check_media):
self.message = message
self.isTopicMainMessage = False
plain, forum, mono = object(), object(), object()
topic = types.SimpleNamespace(topicStartMessage="start-msg", from_id="user-peer")
mono_topic = types.SimpleNamespace(topicStartMessage="unused", from_id="dm-user")
alert = types.SimpleNamespace(
selectedDialogs=SparseArray([(11, plain), (-22, forum), (-33, mono)]),
selectedDialogTopics={forum: topic, mono: mono_topic},
)
messenger = sys.modules["org.telegram.messenger"]
saved = messenger.MessageObject
messenger.MessageObject = TopicMessageObject
messenger.DialogObject = types.SimpleNamespace(getPeerDialogId=lambda peer: 4242)
messenger.MessagesController = types.SimpleNamespace(
getInstance=lambda account: types.SimpleNamespace(isMonoForum=lambda d: d == -33)
)
try:
targets = selected_targets(alert, 0)
finally:
messenger.MessageObject = saved
params = [
address(types.SimpleNamespace(replyToMsg=None, replyToTopMsg=None, monoForumPeer=0), t)
for t in targets
]
addressed = [
(
t.peer,
getattr(p.replyToTopMsg, "message", None),
p.replyToMsg is p.replyToTopMsg,
getattr(p.replyToTopMsg, "isTopicMainMessage", None),
p.monoForumPeer,
)
for t, p in zip(targets, params)
]
check(
"selected forum topic is replied to; mono-forum topic sets monoForumPeer; plain chat untouched",
addressed
== [
(11, None, True, None, 0),
(-22, "start-msg", True, True, 0),
(-33, None, True, None, 4242),
],
f"got {addressed!r}",
)
media_at = getattr(ivmedia, "media_at", None) media_at = getattr(ivmedia, "media_at", None)
no_media = getattr(ivmedia, "NoMedia", None) no_media = getattr(ivmedia, "NoMedia", None)
raised = False raised = False