Skip to content

Commit a7bdd4d

Browse files
authored
Merge pull request #187 from antonkulaga/feature/send-media-topic-id
Add topic_id support to media send tools
2 parents bba25f7 + a20a8b1 commit a7bdd4d

3 files changed

Lines changed: 115 additions & 14 deletions

File tree

telegram_mcp/tools/chats.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,8 +235,9 @@ async def list_topics(
235235
"""
236236
Retrieve forum topics from a supergroup with the forum feature enabled.
237237
238-
Note for LLM: You can send a message to a selected topic via reply_to_message tool
239-
by using Topic ID as the message_id parameter.
238+
Note for LLM: Send into a topic by passing Topic ID as topic_id to send_file /
239+
send_album / send_voice / send_sticker / send_gif, or as message_id to
240+
reply_to_message for text.
240241
241242
Args:
242243
chat_id: The ID of the forum-enabled chat (supergroup).

telegram_mcp/tools/media.py

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ async def send_file(
1010
chat_id: Union[int, str],
1111
file_path: Union[str, List[str]],
1212
caption: str = None,
13+
topic_id: Optional[int] = None,
1314
ctx: Optional[Context] = None,
1415
account: str = None,
1516
) -> str:
@@ -20,13 +21,16 @@ async def send_file(
2021
file_path: Absolute or relative path to the file under allowed roots.
2122
Pass a list of 2-10 paths to send them as one Telegram media group.
2223
caption: Optional caption for the file or media group.
24+
topic_id: Optional forum topic ID (from list_topics). Sends into that topic
25+
in a forum-enabled community/supergroup. Also works as reply_to for a message.
2326
"""
2427
try:
2528
if isinstance(file_path, list):
2629
return await _send_album(
2730
chat_id=chat_id,
2831
file_paths=file_path,
2932
caption=caption,
33+
topic_id=topic_id,
3034
ctx=ctx,
3135
account=account,
3236
)
@@ -40,18 +44,24 @@ async def send_file(
4044
if path_error:
4145
return path_error
4246
entity = await resolve_entity(chat_id, cl)
43-
await cl.send_file(entity, str(safe_path), caption=caption)
47+
await cl.send_file(entity, str(safe_path), caption=caption, reply_to=topic_id)
4448
return f"File sent to chat {chat_id} from {safe_path}."
4549
except Exception as e:
4650
return log_and_format_error(
47-
"send_file", e, chat_id=chat_id, file_path=file_path, caption=caption
51+
"send_file",
52+
e,
53+
chat_id=chat_id,
54+
file_path=file_path,
55+
caption=caption,
56+
topic_id=topic_id,
4857
)
4958

5059

5160
async def _send_album(
5261
chat_id: Union[int, str],
5362
file_paths: List[str],
5463
caption: str = None,
64+
topic_id: Optional[int] = None,
5565
ctx: Optional[Context] = None,
5666
account: str = None,
5767
) -> str:
@@ -71,7 +81,7 @@ async def _send_album(
7181
safe_paths.append(str(safe_path))
7282

7383
entity = await resolve_entity(chat_id, cl)
74-
await cl.send_file(entity, safe_paths, caption=caption)
84+
await cl.send_file(entity, safe_paths, caption=caption, reply_to=topic_id)
7585
return f"Album sent to chat {chat_id} with {len(safe_paths)} files."
7686

7787

@@ -84,6 +94,7 @@ async def send_album(
8494
chat_id: Union[int, str],
8595
file_paths: List[str],
8696
caption: str = None,
97+
topic_id: Optional[int] = None,
8798
ctx: Optional[Context] = None,
8899
account: str = None,
89100
) -> str:
@@ -94,6 +105,8 @@ async def send_album(
94105
chat_id: The chat ID or username.
95106
file_paths: 2-10 absolute or relative file paths under allowed roots.
96107
caption: Optional caption for the album. Telegram displays it on the first item.
108+
topic_id: Optional forum topic ID (from list_topics). Sends into that topic
109+
in a forum-enabled community/supergroup. Also works as reply_to for a message.
97110
"""
98111
try:
99112
if not isinstance(file_paths, list):
@@ -102,12 +115,18 @@ async def send_album(
102115
chat_id=chat_id,
103116
file_paths=file_paths,
104117
caption=caption,
118+
topic_id=topic_id,
105119
ctx=ctx,
106120
account=account,
107121
)
108122
except Exception as e:
109123
return log_and_format_error(
110-
"send_album", e, chat_id=chat_id, file_paths=file_paths, caption=caption
124+
"send_album",
125+
e,
126+
chat_id=chat_id,
127+
file_paths=file_paths,
128+
caption=caption,
129+
topic_id=topic_id,
111130
)
112131

113132

@@ -183,6 +202,7 @@ async def download_media(
183202
async def send_voice(
184203
chat_id: Union[int, str],
185204
file_path: str,
205+
topic_id: Optional[int] = None,
186206
ctx: Optional[Context] = None,
187207
account: str = None,
188208
) -> str:
@@ -192,6 +212,8 @@ async def send_voice(
192212
Args:
193213
chat_id: The chat ID or username.
194214
file_path: Absolute or relative path under allowed roots to the OGG/OPUS file.
215+
topic_id: Optional forum topic ID (from list_topics). Sends into that topic
216+
in a forum-enabled community/supergroup. Also works as reply_to for a message.
195217
"""
196218
try:
197219
cl = get_client(account)
@@ -215,10 +237,12 @@ async def send_voice(
215237
return "Voice file must be .ogg or .opus format."
216238

217239
entity = await resolve_entity(chat_id, cl)
218-
await cl.send_file(entity, str(safe_path), voice_note=True)
240+
await cl.send_file(entity, str(safe_path), voice_note=True, reply_to=topic_id)
219241
return f"Voice message sent to chat {chat_id} from {safe_path}."
220242
except Exception as e:
221-
return log_and_format_error("send_voice", e, chat_id=chat_id, file_path=file_path)
243+
return log_and_format_error(
244+
"send_voice", e, chat_id=chat_id, file_path=file_path, topic_id=topic_id
245+
)
222246

223247

224248
@mcp.tool(
@@ -308,6 +332,7 @@ async def get_sticker_sets(account: str = None) -> str:
308332
async def send_sticker(
309333
chat_id: Union[int, str],
310334
file_path: str,
335+
topic_id: Optional[int] = None,
311336
ctx: Optional[Context] = None,
312337
account: str = None,
313338
) -> str:
@@ -317,6 +342,8 @@ async def send_sticker(
317342
Args:
318343
chat_id: The chat ID or username.
319344
file_path: Absolute or relative path under allowed roots to the .webp sticker file.
345+
topic_id: Optional forum topic ID (from list_topics). Sends into that topic
346+
in a forum-enabled community/supergroup. Also works as reply_to for a message.
320347
"""
321348
try:
322349
cl = get_client(account)
@@ -329,10 +356,12 @@ async def send_sticker(
329356
return path_error
330357

331358
entity = await resolve_entity(chat_id, cl)
332-
await cl.send_file(entity, str(safe_path), force_document=False)
359+
await cl.send_file(entity, str(safe_path), force_document=False, reply_to=topic_id)
333360
return f"Sticker sent to chat {chat_id} from {safe_path}."
334361
except Exception as e:
335-
return log_and_format_error("send_sticker", e, chat_id=chat_id, file_path=file_path)
362+
return log_and_format_error(
363+
"send_sticker", e, chat_id=chat_id, file_path=file_path, topic_id=topic_id
364+
)
336365

337366

338367
@mcp.tool(
@@ -399,23 +428,32 @@ async def get_gif_search(query: str, limit: int = 10, account: str = None) -> st
399428
@mcp.tool(annotations=ToolAnnotations(title="Send Gif", openWorldHint=True, destructiveHint=True))
400429
@with_account(readonly=False)
401430
@validate_id("chat_id")
402-
async def send_gif(chat_id: Union[int, str], gif_id: int, account: str = None) -> str:
431+
async def send_gif(
432+
chat_id: Union[int, str],
433+
gif_id: int,
434+
topic_id: Optional[int] = None,
435+
account: str = None,
436+
) -> str:
403437
"""
404438
Send a GIF to a chat by Telegram GIF document ID (not a file path).
405439
406440
Args:
407441
chat_id: The chat ID or username.
408442
gif_id: Telegram document ID for the GIF (from get_gif_search).
443+
topic_id: Optional forum topic ID (from list_topics). Sends into that topic
444+
in a forum-enabled community/supergroup. Also works as reply_to for a message.
409445
"""
410446
try:
411447
cl = get_client(account)
412448
if not isinstance(gif_id, int):
413449
return "gif_id must be a Telegram document ID (integer), not a file path. Use get_gif_search to find IDs."
414450
entity = await resolve_entity(chat_id, cl)
415-
await cl.send_file(entity, gif_id)
451+
await cl.send_file(entity, gif_id, reply_to=topic_id)
416452
return f"GIF sent to chat {chat_id}."
417453
except Exception as e:
418-
return log_and_format_error("send_gif", e, chat_id=chat_id, gif_id=gif_id)
454+
return log_and_format_error(
455+
"send_gif", e, chat_id=chat_id, gif_id=gif_id, topic_id=topic_id
456+
)
419457

420458

421459
__all__ = [

tests/test_media_album.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ class _DummyClient:
88
def __init__(self):
99
self.sent = None
1010

11-
async def send_file(self, entity, file_paths, caption=None):
11+
async def send_file(self, entity, file_paths, caption=None, reply_to=None):
1212
self.sent = {
1313
"entity": entity,
1414
"file_paths": file_paths,
1515
"caption": caption,
16+
"reply_to": reply_to,
1617
}
1718

1819

@@ -52,6 +53,67 @@ async def _resolve_entity(chat_id, cl):
5253
"entity": "entity:AgenticAIChat",
5354
"file_paths": [str(first), str(second)],
5455
"caption": "pick one",
56+
"reply_to": None,
57+
}
58+
59+
60+
@pytest.mark.asyncio
61+
@pytest.mark.parametrize("tool_name", ["send_album", "send_file"])
62+
async def test_album_mode_passes_topic_id_as_reply_to(tmp_path, monkeypatch, tool_name):
63+
root = (tmp_path / "root").resolve()
64+
root.mkdir()
65+
first = root / "one.png"
66+
second = root / "two.png"
67+
first.write_bytes(b"png-one")
68+
second.write_bytes(b"png-two")
69+
70+
client = _DummyClient()
71+
monkeypatch.setattr(runtime, "SERVER_ALLOWED_ROOTS", [root])
72+
monkeypatch.setattr(media, "clients", {"default": client})
73+
monkeypatch.setattr(media, "get_client", lambda account=None: client)
74+
75+
async def _resolve_entity(chat_id, cl):
76+
return "entity:forum"
77+
78+
monkeypatch.setattr(media, "resolve_entity", _resolve_entity)
79+
80+
tool = getattr(media, tool_name)
81+
result = await tool(
82+
"ForumChat",
83+
["one.png", str(second)],
84+
caption="topic post",
85+
topic_id=777,
86+
)
87+
88+
assert result == "Album sent to chat ForumChat with 2 files."
89+
assert client.sent["reply_to"] == 777
90+
91+
92+
@pytest.mark.asyncio
93+
async def test_send_file_passes_topic_id_as_reply_to(tmp_path, monkeypatch):
94+
root = (tmp_path / "root").resolve()
95+
root.mkdir()
96+
path = root / "doc.pdf"
97+
path.write_bytes(b"%PDF")
98+
99+
client = _DummyClient()
100+
monkeypatch.setattr(runtime, "SERVER_ALLOWED_ROOTS", [root])
101+
monkeypatch.setattr(media, "clients", {"default": client})
102+
monkeypatch.setattr(media, "get_client", lambda account=None: client)
103+
104+
async def _resolve_entity(chat_id, cl):
105+
return "entity:forum"
106+
107+
monkeypatch.setattr(media, "resolve_entity", _resolve_entity)
108+
109+
result = await media.send_file("ForumChat", "doc.pdf", caption="hello", topic_id=42)
110+
111+
assert result == f"File sent to chat ForumChat from {path}."
112+
assert client.sent == {
113+
"entity": "entity:forum",
114+
"file_paths": str(path),
115+
"caption": "hello",
116+
"reply_to": 42,
55117
}
56118

57119

0 commit comments

Comments
 (0)