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
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`
+1 -1
View File
@@ -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",
+4 -4
View File
@@ -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))
+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
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
+19 -9
View File
@@ -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
+68
View File
@@ -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