Skip to content

Commit 828e40b

Browse files
author
JosXa
committed
feat: add Telegram album sending tool
1 parent 0f35835 commit 828e40b

2 files changed

Lines changed: 132 additions & 0 deletions

File tree

telegram_mcp/tools/media.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,51 @@ async def send_file(
3838
)
3939

4040

41+
@mcp.tool(annotations=ToolAnnotations(title="Send Album", openWorldHint=True, destructiveHint=True))
42+
@with_account(readonly=False)
43+
@validate_id("chat_id")
44+
async def send_album(
45+
chat_id: Union[int, str],
46+
file_paths: List[str],
47+
caption: str = None,
48+
ctx: Optional[Context] = None,
49+
account: str = None,
50+
) -> str:
51+
"""
52+
Send multiple photos/videos as one Telegram media group (album).
53+
54+
Args:
55+
chat_id: The chat ID or username.
56+
file_paths: 2-10 absolute or relative file paths under allowed roots.
57+
caption: Optional caption for the album. Telegram displays it on the first item.
58+
"""
59+
try:
60+
if not isinstance(file_paths, list):
61+
return "file_paths must be a list of file paths."
62+
if not 2 <= len(file_paths) <= 10:
63+
return "Albums must contain between 2 and 10 files."
64+
65+
cl = get_client(account)
66+
safe_paths = []
67+
for file_path in file_paths:
68+
safe_path, path_error = await _resolve_readable_file_path(
69+
raw_path=file_path,
70+
ctx=ctx,
71+
tool_name="send_file",
72+
)
73+
if path_error:
74+
return path_error
75+
safe_paths.append(str(safe_path))
76+
77+
entity = await resolve_entity(chat_id, cl)
78+
await cl.send_file(entity, safe_paths, caption=caption)
79+
return f"Album sent to chat {chat_id} with {len(safe_paths)} files."
80+
except Exception as e:
81+
return log_and_format_error(
82+
"send_album", e, chat_id=chat_id, file_paths=file_paths, caption=caption
83+
)
84+
85+
4186
@mcp.tool(
4287
annotations=ToolAnnotations(title="Download Media", openWorldHint=True, destructiveHint=True)
4388
)
@@ -347,6 +392,7 @@ async def send_gif(chat_id: Union[int, str], gif_id: int, account: str = None) -
347392

348393
__all__ = [
349394
"send_file",
395+
"send_album",
350396
"download_media",
351397
"send_voice",
352398
"upload_file",

tests/test_media_album.py

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

0 commit comments

Comments
 (0)