Skip to content

Commit fbc4adc

Browse files
authored
Merge pull request chigwell#206 from vsem-azamat/fix/bounded-media-downloads
fix: bound and stage media downloads
2 parents ff09e6c + 6936343 commit fbc4adc

3 files changed

Lines changed: 291 additions & 18 deletions

File tree

‎telegram_mcp/runtime.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -703,6 +703,7 @@ async def ensure_connected(cl: TelegramClient = None):
703703
"edit_chat_photo": {".jpg", ".jpeg", ".png", ".webp"},
704704
}
705705
MAX_FILE_BYTES: dict[str, int] = {
706+
"download_media": 200 * 1024 * 1024, # 200 MB
706707
"send_file": 200 * 1024 * 1024, # 200 MB
707708
"upload_file": 200 * 1024 * 1024,
708709
"send_voice": 100 * 1024 * 1024,

‎telegram_mcp/tools/media.py‎

Lines changed: 68 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
"""Media MCP tools."""
22

3+
import os
4+
import shutil
5+
import tempfile
6+
from uuid import uuid4
7+
38
from telegram_mcp.runtime import *
49

510
from telegram_mcp.contact_sheet import ContactSheetUnavailable, build_contact_sheet
@@ -16,6 +21,19 @@
1621
PHOTO_SHEET_MAXIMUM_TILES = 12
1722

1823

24+
def _known_media_size(message) -> Optional[int]:
25+
"""Return Telegram's declared media size when available."""
26+
size = getattr(getattr(message, "file", None), "size", None)
27+
if isinstance(size, int) and size >= 0:
28+
return size
29+
size = getattr(getattr(getattr(message, "media", None), "document", None), "size", None)
30+
return size if isinstance(size, int) and size >= 0 else None
31+
32+
33+
class _DownloadLimitExceeded(Exception):
34+
"""Stop an in-progress media download once it exceeds the configured limit."""
35+
36+
1937
@mcp.tool(annotations=ToolAnnotations(title="Send File", openWorldHint=True, destructiveHint=True))
2038
@with_account(readonly=False)
2139
@validate_id("chat_id")
@@ -202,7 +220,12 @@ async def download_media(
202220
if not msg or not msg.media:
203221
return "No media found in the specified message."
204222

205-
default_name = f"telegram_{chat_id}_{message_id}_{int(time.time())}"
223+
limit = MAX_FILE_BYTES["download_media"]
224+
declared_size = _known_media_size(msg)
225+
if declared_size is not None and declared_size > limit:
226+
return f"Media is too large for download_media (limit: {limit} bytes)."
227+
228+
default_name = f"telegram_{chat_id}_{message_id}_{int(time.time())}_{uuid4().hex}"
206229
out_path, path_error = await _resolve_writable_file_path(
207230
raw_path=file_path,
208231
default_filename=default_name,
@@ -212,23 +235,50 @@ async def download_media(
212235
if path_error:
213236
return path_error
214237

215-
# Strip user-supplied extension so Telethon auto-detects the real media type.
216-
# If a path with extension is passed (e.g. ticket.jpg), Telethon writes to that
217-
# exact path even if the file is actually a PDF. Stripping the suffix lets
218-
# Telethon append the correct extension based on the actual file content.
219-
out_path_for_dl = out_path.with_suffix("")
220-
downloaded = await cl.download_media(msg, file=str(out_path_for_dl))
221-
if not downloaded:
222-
return f"Download failed for message {message_id}."
223-
224-
final_path = Path(downloaded).resolve(strict=True)
225-
roots, roots_error = await _ensure_allowed_roots(ctx, "download_media")
226-
if roots_error:
227-
return roots_error
228-
if not _path_is_within_any_root(final_path, roots):
229-
return "Download failed: resulting path is outside allowed roots."
230-
231-
return f"Media downloaded to {final_path}."
238+
temp_dir = Path(
239+
tempfile.mkdtemp(prefix=".telegram-mcp-download-", dir=str(out_path.parent))
240+
)
241+
try:
242+
staged_name = out_path.with_suffix("").name if out_path.suffix else out_path.name
243+
temp_requested_path = temp_dir / staged_name
244+
245+
def enforce_download_limit(received: int, total: int) -> None:
246+
if received > limit or (total and total > limit):
247+
raise _DownloadLimitExceeded
248+
249+
try:
250+
downloaded = await cl.download_media(
251+
msg,
252+
file=str(temp_requested_path),
253+
progress_callback=enforce_download_limit,
254+
)
255+
except _DownloadLimitExceeded:
256+
return f"Media is too large for download_media (limit: {limit} bytes)."
257+
258+
if not downloaded:
259+
return f"Download failed for message {message_id}."
260+
261+
temp_final_path = Path(downloaded).resolve(strict=True)
262+
if temp_final_path.parent != temp_dir.resolve():
263+
return "Download failed: resulting temporary path is invalid."
264+
if temp_final_path.stat().st_size > limit:
265+
return f"Media is too large for download_media (limit: {limit} bytes)."
266+
267+
final_path = out_path
268+
if temp_final_path.suffix:
269+
final_path, path_error = await _resolve_writable_file_path(
270+
raw_path=str(out_path.with_suffix(temp_final_path.suffix)),
271+
default_filename=default_name,
272+
ctx=ctx,
273+
tool_name="download_media",
274+
)
275+
if path_error:
276+
return path_error
277+
278+
os.replace(temp_final_path, final_path)
279+
return f"Media downloaded to {final_path}."
280+
finally:
281+
shutil.rmtree(temp_dir, ignore_errors=True)
232282
except Exception as e:
233283
return log_and_format_error(
234284
"download_media",

‎tests/test_media_download.py‎

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import inspect
2+
from pathlib import Path
3+
from types import SimpleNamespace
4+
5+
import pytest
6+
7+
from telegram_mcp.tools import media
8+
9+
_download_media = inspect.unwrap(media.download_media)
10+
11+
12+
async def _call_download(monkeypatch, target, client, message):
13+
monkeypatch.setattr(media, "get_client", lambda account=None: client)
14+
15+
async def _resolve_entity(chat_id, cl):
16+
assert cl is client
17+
return "entity"
18+
19+
async def _resolve_path(**kwargs):
20+
raw_path = kwargs["raw_path"]
21+
return (Path(raw_path).resolve() if raw_path else target), None
22+
23+
async def _allowed_roots(ctx, tool_name):
24+
return [target.parent], None
25+
26+
monkeypatch.setattr(media, "resolve_entity", _resolve_entity)
27+
monkeypatch.setattr(media, "_resolve_writable_file_path", _resolve_path)
28+
monkeypatch.setattr(media, "_ensure_allowed_roots", _allowed_roots)
29+
return await _download_media(123, message.id, file_path=str(target))
30+
31+
32+
async def _call_default_download(monkeypatch, root, client, message):
33+
monkeypatch.setattr(media, "get_client", lambda account=None: client)
34+
35+
async def _resolve_entity(chat_id, cl):
36+
assert cl is client
37+
return "entity"
38+
39+
async def _resolve_path(**kwargs):
40+
raw_path = kwargs["raw_path"]
41+
if raw_path:
42+
return Path(raw_path).resolve(), None
43+
downloads = root / "downloads"
44+
downloads.mkdir(exist_ok=True)
45+
return (downloads / kwargs["default_filename"]).resolve(), None
46+
47+
monkeypatch.setattr(media, "resolve_entity", _resolve_entity)
48+
monkeypatch.setattr(media, "_resolve_writable_file_path", _resolve_path)
49+
return await _download_media(123, message.id)
50+
51+
52+
def _message(size):
53+
return SimpleNamespace(id=7, media=object(), file=SimpleNamespace(size=size))
54+
55+
56+
class _Client:
57+
def __init__(self, message, download):
58+
self.message = message
59+
self._download = download
60+
self.download_calls = 0
61+
62+
async def get_messages(self, entity, ids):
63+
assert entity == "entity"
64+
assert ids == self.message.id
65+
return self.message
66+
67+
async def download_media(self, message, file, **kwargs):
68+
self.download_calls += 1
69+
return await self._download(Path(file), kwargs)
70+
71+
72+
@pytest.mark.asyncio
73+
async def test_default_download_paths_do_not_collide_when_time_is_frozen(tmp_path, monkeypatch):
74+
message = _message(size=3)
75+
payloads = iter((b"one", b"two"))
76+
77+
async def successful_download(file, kwargs):
78+
downloaded = Path(f"{file}.webp")
79+
downloaded.write_bytes(next(payloads))
80+
return str(downloaded)
81+
82+
client = _Client(message, successful_download)
83+
monkeypatch.setattr(media.time, "time", lambda: 1_700_000_000)
84+
85+
first = await _call_default_download(monkeypatch, tmp_path, client, message)
86+
second = await _call_default_download(monkeypatch, tmp_path, client, message)
87+
88+
prefix = "Media downloaded to "
89+
first_path = Path(first.removeprefix(prefix).removesuffix("."))
90+
second_path = Path(second.removeprefix(prefix).removesuffix("."))
91+
assert first_path != second_path
92+
assert first_path.read_bytes() == b"one"
93+
assert second_path.read_bytes() == b"two"
94+
95+
96+
@pytest.mark.asyncio
97+
async def test_download_media_rejects_declared_oversize_before_transfer(tmp_path, monkeypatch):
98+
target = tmp_path / "large.bin"
99+
message = _message(size=5)
100+
101+
async def fail_download(file, kwargs):
102+
raise AssertionError("download must not start")
103+
104+
client = _Client(message, fail_download)
105+
monkeypatch.setitem(media.MAX_FILE_BYTES, "download_media", 4)
106+
107+
result = await _call_download(monkeypatch, target, client, message)
108+
109+
assert "too large" in result.lower()
110+
assert client.download_calls == 0
111+
assert list(tmp_path.iterdir()) == []
112+
113+
114+
@pytest.mark.asyncio
115+
async def test_download_media_aborts_in_progress_oversize_and_cleans_staging(
116+
tmp_path, monkeypatch
117+
):
118+
target = tmp_path / "bounded.bin"
119+
message = _message(size=None)
120+
121+
async def oversized_download(file, kwargs):
122+
partial = Path(f"{file}.bin")
123+
partial.write_bytes(b"12345")
124+
kwargs["progress_callback"](5, 10)
125+
return str(partial)
126+
127+
client = _Client(message, oversized_download)
128+
monkeypatch.setitem(media.MAX_FILE_BYTES, "download_media", 4)
129+
130+
result = await _call_download(monkeypatch, target, client, message)
131+
132+
assert "too large" in result.lower()
133+
assert client.download_calls == 1
134+
assert list(tmp_path.iterdir()) == []
135+
136+
137+
@pytest.mark.asyncio
138+
async def test_download_media_verifies_final_size_and_cleans_staging(tmp_path, monkeypatch):
139+
target = tmp_path / "stale-size.bin"
140+
message = _message(size=4)
141+
142+
async def oversized_download(file, kwargs):
143+
downloaded = Path(f"{file}.bin")
144+
downloaded.write_bytes(b"12345")
145+
return str(downloaded)
146+
147+
client = _Client(message, oversized_download)
148+
monkeypatch.setitem(media.MAX_FILE_BYTES, "download_media", 4)
149+
150+
result = await _call_download(monkeypatch, target, client, message)
151+
152+
assert "too large" in result.lower()
153+
assert list(tmp_path.iterdir()) == []
154+
155+
156+
@pytest.mark.asyncio
157+
async def test_download_media_cleans_extension_appended_file_after_false_return(
158+
tmp_path, monkeypatch
159+
):
160+
target = tmp_path / "missing.bin"
161+
message = _message(size=3)
162+
163+
async def failed_download(file, kwargs):
164+
Path(f"{file}.part").write_bytes(b"123")
165+
return None
166+
167+
client = _Client(message, failed_download)
168+
monkeypatch.setitem(media.MAX_FILE_BYTES, "download_media", 4)
169+
170+
result = await _call_download(monkeypatch, target, client, message)
171+
172+
assert "download failed" in result.lower()
173+
assert list(tmp_path.iterdir()) == []
174+
175+
176+
@pytest.mark.asyncio
177+
async def test_download_media_cleans_extension_appended_file_after_exception(
178+
tmp_path, monkeypatch
179+
):
180+
target = tmp_path / "broken.bin"
181+
message = _message(size=3)
182+
183+
async def failed_download(file, kwargs):
184+
Path(f"{file}.part").write_bytes(b"123")
185+
raise RuntimeError("network failed")
186+
187+
client = _Client(message, failed_download)
188+
monkeypatch.setitem(media.MAX_FILE_BYTES, "download_media", 4)
189+
190+
result = await _call_download(monkeypatch, target, client, message)
191+
192+
assert "error occurred" in result.lower()
193+
assert list(tmp_path.iterdir()) == []
194+
195+
196+
@pytest.mark.asyncio
197+
async def test_download_media_stages_atomically_and_preserves_detected_extension(
198+
tmp_path, monkeypatch
199+
):
200+
requested = tmp_path / "photo.jpg"
201+
final_path = tmp_path / "photo.webp"
202+
final_path.write_bytes(b"old")
203+
message = _message(size=3)
204+
205+
async def successful_download(file, kwargs):
206+
assert file != requested.with_suffix("")
207+
assert file.is_relative_to(tmp_path)
208+
assert final_path.read_bytes() == b"old"
209+
kwargs["progress_callback"](3, 3)
210+
downloaded = Path(f"{file}.webp")
211+
downloaded.write_bytes(b"new")
212+
assert final_path.read_bytes() == b"old"
213+
return str(downloaded)
214+
215+
client = _Client(message, successful_download)
216+
monkeypatch.setitem(media.MAX_FILE_BYTES, "download_media", 4)
217+
218+
result = await _call_download(monkeypatch, requested, client, message)
219+
220+
assert result == f"Media downloaded to {final_path.resolve()}."
221+
assert final_path.read_bytes() == b"new"
222+
assert list(tmp_path.iterdir()) == [final_path]

0 commit comments

Comments
 (0)