Skip to content

Commit 81252dc

Browse files
authored
Merge pull request chigwell#127 from JosXa/send-album
Add Telegram album sending tool
2 parents 3a3aa63 + e00ca44 commit 81252dc

2 files changed

Lines changed: 166 additions & 2 deletions

File tree

telegram_mcp/tools/media.py

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
@validate_id("chat_id")
99
async def send_file(
1010
chat_id: Union[int, str],
11-
file_path: str,
11+
file_path: Union[str, List[str]],
1212
caption: str = None,
1313
ctx: Optional[Context] = None,
1414
account: str = None,
@@ -18,9 +18,19 @@ async def send_file(
1818
Args:
1919
chat_id: The chat ID or username.
2020
file_path: Absolute or relative path to the file under allowed roots.
21-
caption: Optional caption for the file.
21+
Pass a list of 2-10 paths to send them as one Telegram media group.
22+
caption: Optional caption for the file or media group.
2223
"""
2324
try:
25+
if isinstance(file_path, list):
26+
return await _send_album(
27+
chat_id=chat_id,
28+
file_paths=file_path,
29+
caption=caption,
30+
ctx=ctx,
31+
account=account,
32+
)
33+
2434
cl = get_client(account)
2535
safe_path, path_error = await _resolve_readable_file_path(
2636
raw_path=file_path,
@@ -38,6 +48,69 @@ async def send_file(
3848
)
3949

4050

51+
async def _send_album(
52+
chat_id: Union[int, str],
53+
file_paths: List[str],
54+
caption: str = None,
55+
ctx: Optional[Context] = None,
56+
account: str = None,
57+
) -> str:
58+
if not 2 <= len(file_paths) <= 10:
59+
return "Albums must contain between 2 and 10 files."
60+
61+
cl = get_client(account)
62+
safe_paths = []
63+
for file_path in file_paths:
64+
safe_path, path_error = await _resolve_readable_file_path(
65+
raw_path=file_path,
66+
ctx=ctx,
67+
tool_name="send_file",
68+
)
69+
if path_error:
70+
return path_error
71+
safe_paths.append(str(safe_path))
72+
73+
entity = await resolve_entity(chat_id, cl)
74+
await cl.send_file(entity, safe_paths, caption=caption)
75+
return f"Album sent to chat {chat_id} with {len(safe_paths)} files."
76+
77+
78+
@mcp.tool(
79+
annotations=ToolAnnotations(title="Send Album", openWorldHint=True, destructiveHint=True)
80+
)
81+
@with_account(readonly=False)
82+
@validate_id("chat_id")
83+
async def send_album(
84+
chat_id: Union[int, str],
85+
file_paths: List[str],
86+
caption: str = None,
87+
ctx: Optional[Context] = None,
88+
account: str = None,
89+
) -> str:
90+
"""
91+
Send multiple photos/videos as one Telegram media group (album).
92+
93+
Args:
94+
chat_id: The chat ID or username.
95+
file_paths: 2-10 absolute or relative file paths under allowed roots.
96+
caption: Optional caption for the album. Telegram displays it on the first item.
97+
"""
98+
try:
99+
if not isinstance(file_paths, list):
100+
return "file_paths must be a list of file paths."
101+
return await _send_album(
102+
chat_id=chat_id,
103+
file_paths=file_paths,
104+
caption=caption,
105+
ctx=ctx,
106+
account=account,
107+
)
108+
except Exception as e:
109+
return log_and_format_error(
110+
"send_album", e, chat_id=chat_id, file_paths=file_paths, caption=caption
111+
)
112+
113+
41114
@mcp.tool(
42115
annotations=ToolAnnotations(title="Download Media", openWorldHint=True, destructiveHint=True)
43116
)
@@ -347,6 +420,7 @@ async def send_gif(chat_id: Union[int, str], gif_id: int, account: str = None) -
347420

348421
__all__ = [
349422
"send_file",
423+
"send_album",
350424
"download_media",
351425
"send_voice",
352426
"upload_file",

tests/test_media_album.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import pytest
2+
3+
from telegram_mcp import runtime
4+
from telegram_mcp.tools import media
5+
6+
7+
class _DummyClient:
8+
def __init__(self):
9+
self.sent = None
10+
11+
async def send_file(self, entity, file_paths, caption=None):
12+
self.sent = {
13+
"entity": entity,
14+
"file_paths": file_paths,
15+
"caption": caption,
16+
}
17+
18+
19+
@pytest.mark.asyncio
20+
@pytest.mark.parametrize("tool_name", ["send_album", "send_file"])
21+
async def test_album_mode_sends_multiple_files_as_one_media_group(
22+
tmp_path, monkeypatch, tool_name
23+
):
24+
root = (tmp_path / "root").resolve()
25+
root.mkdir()
26+
first = root / "one.png"
27+
second = root / "two.png"
28+
first.write_bytes(b"png-one")
29+
second.write_bytes(b"png-two")
30+
31+
client = _DummyClient()
32+
monkeypatch.setattr(runtime, "SERVER_ALLOWED_ROOTS", [root])
33+
monkeypatch.setattr(media, "clients", {"default": client})
34+
monkeypatch.setattr(media, "get_client", lambda account=None: client)
35+
36+
async def _resolve_entity(chat_id, cl):
37+
assert chat_id == "AgenticAIChat"
38+
assert cl is client
39+
return "entity:AgenticAIChat"
40+
41+
monkeypatch.setattr(media, "resolve_entity", _resolve_entity)
42+
43+
tool = getattr(media, tool_name)
44+
result = await tool(
45+
"AgenticAIChat",
46+
["one.png", str(second)],
47+
caption="pick one",
48+
)
49+
50+
assert result == "Album sent to chat AgenticAIChat with 2 files."
51+
assert client.sent == {
52+
"entity": "entity:AgenticAIChat",
53+
"file_paths": [str(first), str(second)],
54+
"caption": "pick one",
55+
}
56+
57+
58+
@pytest.mark.asyncio
59+
@pytest.mark.parametrize(
60+
("file_paths", "expected"),
61+
[
62+
("not-a-list", "file_paths must be a list of file paths."),
63+
(["one.png"], "Albums must contain between 2 and 10 files."),
64+
([f"{index}.png" for index in range(11)], "Albums must contain between 2 and 10 files."),
65+
],
66+
)
67+
async def test_send_album_validates_album_file_count(file_paths, expected, monkeypatch):
68+
monkeypatch.setattr(media, "clients", {"default": _DummyClient()})
69+
70+
result = await media.send_album("AgenticAIChat", file_paths)
71+
72+
assert result == expected
73+
74+
75+
@pytest.mark.asyncio
76+
async def test_send_album_reuses_readable_path_security(tmp_path, monkeypatch):
77+
root = (tmp_path / "root").resolve()
78+
outside = (tmp_path / "outside").resolve()
79+
root.mkdir()
80+
outside.mkdir()
81+
(root / "one.png").write_bytes(b"png-one")
82+
outside_file = outside / "two.png"
83+
outside_file.write_bytes(b"png-two")
84+
85+
monkeypatch.setattr(runtime, "SERVER_ALLOWED_ROOTS", [root])
86+
monkeypatch.setattr(media, "clients", {"default": _DummyClient()})
87+
88+
result = await media.send_album("AgenticAIChat", ["one.png", str(outside_file)])
89+
90+
assert result == "Path is outside allowed roots."

0 commit comments

Comments
 (0)