From 1f0a9a1a7a6b0b951fc79399ad5b1de62cdebded Mon Sep 17 00:00:00 2001 From: lyuksovannyy Date: Fri, 9 Oct 2026 12:50:03 +0200 Subject: [PATCH] Send forwarded media to the forum topic picked in the share sheet 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 --- docs/media-viewer-notes.md | 19 +++++++++++ plugin.json | 2 +- src/main.py | 8 ++--- src/picker.py | 48 ++++++++++++++++++++++----- src/sender.py | 28 +++++++++++----- tools/verify_bundle.py | 68 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 150 insertions(+), 23 deletions(-) diff --git a/docs/media-viewer-notes.md b/docs/media-viewer-notes.md index ba15006..8a5377c 100644 --- a/docs/media-viewer-notes.md +++ b/docs/media-viewer-notes.md @@ -84,6 +84,25 @@ 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` diff --git a/plugin.json b/plugin.json index d4fddae..cd7c45a 100644 --- a/plugin.json +++ b/plugin.json @@ -3,7 +3,7 @@ "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.1.1", + "version": "1.1.2", "icon": "exteraPlugins/1", "app_version": ">=12.5.1", "sdk_version": ">=1.4.4.3", diff --git a/src/main.py b/src/main.py index a1aa4e4..0f582f8 100644 --- a/src/main.py +++ b/src/main.py @@ -82,18 +82,18 @@ class ExtendedMediaForwardsPlugin(BasePlugin): if context is None: return - def picked(peers, comment): - if not peers: + def picked(targets, comment): + if not targets: 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, caption, peers, comment, parent) + sender.send_media(account, media, path, is_video, caption, targets, 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)) + picker.pick_chats(context, account, picked, ivmedia.resources_provider(viewer)) except Exception as exc: log("[extended_media_forwards] picker failed: %s" % exc) sender.notify_error(str(exc)) diff --git a/src/picker.py b/src/picker.py index e58f272..3853788 100644 --- a/src/picker.py +++ b/src/picker.py @@ -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 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`. +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 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 # 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 class Delegate(dynamic_proxy(ShareAlert.ShareAlertDelegate)): - def __init__(self, alert, on_picked): + def __init__(self, alert, account, on_picked): super().__init__() self.alert = alert + self.account = account self.on_picked = on_picked def didCopy(self): return False 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 -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") if dialogs is None: 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): @@ -57,9 +87,9 @@ def _comment(alert): 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.""" +def pick_chats(context, account, on_picked, resources_provider=None): + """Shows the stock share sheet. `on_picked(targets, comment)` runs on the + UI thread once the user confirms, with one `Target` per chosen chat.""" global _delegate_class 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: _delegate_class = _make_delegate_class() # 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) pick_chats._alive = delegate diff --git a/src/sender.py b/src/sender.py index 3df7e06..9d09beb 100644 --- a/src/sender.py +++ b/src/sender.py @@ -38,10 +38,10 @@ CAPTION_TEXT = "telegra.ph source" RICH_CAPTION_TEXT = "message source" -def send_media(account, media, cached_path, is_video, caption, peers, comment, parent=None): +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. - `caption` is `(text, url)` for a linked caption or None for none. `parent` + `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. """ @@ -49,10 +49,10 @@ def send_media(account, media, cached_path, is_video, caption, peers, comment, p def worker(): try: path = cached_path or _download(account, media, is_video, parent) - for peer in peers: + for target in targets: if comment: - _send(account, _text_params(comment, peer)) - _send(account, _media_params(account, media, path, is_video, caption, peer)) + _send(account, _text_params(comment, target)) + _send(account, _media_params(account, media, path, is_video, caption, target)) except Exception as exc: log("[extended_media_forwards] send failed: %s" % exc) notify_error(str(exc)) @@ -62,16 +62,26 @@ def send_media(account, media, cached_path, is_video, caption, peers, comment, p 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 - return SendMessagesHelper.SendMessageParams.of(text, peer) + return _address(SendMessagesHelper.SendMessageParams.of(text, target.peer), target) -def _media_params(account, media, path, is_video, caption, peer): +def _media_params(account, media, path, is_video, caption, target): from org.telegram.messenger import SendMessagesHelper - params = SendMessagesHelper.SendMessageParams.of(None, peer) + params = _address(SendMessagesHelper.SendMessageParams.of(None, target.peer), target) if caption is not None: text, url = caption params.caption = text diff --git a/tools/verify_bundle.py b/tools/verify_bundle.py index caed0dc..4538336 100755 --- a/tools/verify_bundle.py +++ b/tools/verify_bundle.py @@ -463,6 +463,74 @@ def main(argv=None) -> int: 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) no_media = getattr(ivmedia, "NoMedia", None) raised = False