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>
This commit is contained in:
lyuksovannyyandClaude Opus 5.5 committed 2026-10-09 12:50:03 +02:00
1 parent 46503f24a0
commit 1f0a9a1a7a
6 files changed
+150 -23

No files matched your search

+19
View File
@@ -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 prefix. DMs, basic groups and Saved Messages have no link, so media forwarded
from rich messages there is sent without a caption. 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 ## File references
Pass the adapter's parent object (WebPage or MessageObject) as `parentObject` Pass the adapter's parent object (WebPage or MessageObject) as `parentObject`
+1 -1
View File
@@ -3,7 +3,7 @@
"name": "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.", "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.1.1", "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",
+4 -4
View File
@@ -82,18 +82,18 @@ class ExtendedMediaForwardsPlugin(BasePlugin):
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, caption, peers, comment, parent) 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("[extended_media_forwards] picker failed: %s" % exc) log("[extended_media_forwards] picker failed: %s" % exc)
sender.notify_error(str(exc)) sender.notify_error(str(exc))
+39 -9
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
+19 -9
View File
@@ -38,10 +38,10 @@ CAPTION_TEXT = "telegra.ph source"
RICH_CAPTION_TEXT = "message 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. """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 is the object the media belongs to (WebPage or MessageObject); FileLoader
uses it to refresh an expired file reference. 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(): def worker():
try: try:
path = cached_path or _download(account, media, is_video, parent) 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, caption, peer)) _send(account, _media_params(account, media, path, is_video, caption, target))
except Exception as exc: except Exception as exc:
log("[extended_media_forwards] send failed: %s" % exc) log("[extended_media_forwards] send failed: %s" % exc)
notify_error(str(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() 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, caption, 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)
if caption is not None: if caption is not None:
text, url = caption text, url = caption
params.caption = text params.caption = text
+68
View File
@@ -463,6 +463,74 @@ def main(argv=None) -> int:
f"got {links!r}", 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