Skip to content

Commit 2e14309

Browse files
committed
fix: download telegram photo/sticker/document/video to local temp path
Fixes #9448: these components stored Telegram's file URL directly, which generic message components can't resolve as a local file.
1 parent fc81f29 commit 2e14309

2 files changed

Lines changed: 188 additions & 9 deletions

File tree

astrbot/core/platform/sources/telegram/tg_adapter.py

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,26 @@ async def message_handler(
436436
if abm:
437437
await self.handle_msg(abm)
438438

439+
async def _download_to_temp(self, file_path: str) -> str:
440+
"""Download a Telegram file to a local temp path.
441+
442+
Telegram's file_path can be a relative path (self-hosted Bot API server
443+
or reverse proxy setups), and the generic message components only
444+
understand local paths or http(s) URLs. So we always fetch the file
445+
ourselves instead of passing file_path straight into the component.
446+
447+
Args:
448+
file_path: The file_path returned by Telegram's getFile API.
449+
450+
Returns:
451+
Local absolute path of the downloaded file.
452+
"""
453+
file_basename = os.path.basename(file_path)
454+
temp_dir = get_astrbot_temp_path()
455+
temp_path = os.path.join(temp_dir, f"{uuid.uuid4().hex}_{file_basename}")
456+
await download_file(file_path, path=temp_path)
457+
return temp_path
458+
439459
async def convert_message(
440460
self,
441461
update: Update,
@@ -591,13 +611,27 @@ def _apply_caption() -> None:
591611
elif update.message.photo:
592612
photo = update.message.photo[-1] # get the largest photo
593613
file = await photo.get_file()
594-
message.message.append(Comp.Image(file=file.file_path, url=file.file_path))
595-
_apply_caption()
614+
file_path = file.file_path
615+
if file_path is None:
616+
logger.warning(
617+
"Telegram photo file_path is None, cannot save the file."
618+
)
619+
else:
620+
temp_path = await self._download_to_temp(file_path)
621+
message.message.append(Comp.Image(file=temp_path, url=temp_path))
622+
_apply_caption()
596623

597624
elif update.message.sticker:
598625
# 将sticker当作图片处理
599626
file = await update.message.sticker.get_file()
600-
message.message.append(Comp.Image(file=file.file_path, url=file.file_path))
627+
file_path = file.file_path
628+
if file_path is None:
629+
logger.warning(
630+
"Telegram sticker file_path is None, cannot save the file."
631+
)
632+
else:
633+
temp_path = await self._download_to_temp(file_path)
634+
message.message.append(Comp.Image(file=temp_path, url=temp_path))
601635
if update.message.sticker.emoji:
602636
sticker_text = f"Sticker: {update.message.sticker.emoji}"
603637
message.message_str = sticker_text
@@ -612,8 +646,9 @@ def _apply_caption() -> None:
612646
f"Telegram document file_path is None, cannot save the file {file_name}.",
613647
)
614648
else:
649+
temp_path = await self._download_to_temp(file_path)
615650
message.message.append(
616-
Comp.File(file=file_path, name=file_name, url=file_path)
651+
Comp.File(file=temp_path, name=file_name, url=temp_path)
617652
)
618653
_apply_caption()
619654

@@ -626,7 +661,8 @@ def _apply_caption() -> None:
626661
f"Telegram video file_path is None, cannot save the file {file_name}.",
627662
)
628663
else:
629-
message.message.append(Comp.Video(file=file_path, path=file.file_path))
664+
temp_path = await self._download_to_temp(file_path)
665+
message.message.append(Comp.Video(file=temp_path, path=temp_path))
630666
_apply_caption()
631667

632668
return message

tests/test_telegram_adapter.py

Lines changed: 147 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ async def test_telegram_reply_without_quote_text_uses_full_message(quote_text):
156156

157157

158158
@pytest.mark.asyncio
159-
async def test_telegram_document_caption_populates_message_text_and_plain():
159+
async def test_telegram_document_caption_populates_message_text_and_plain(tmp_path):
160160
TelegramPlatformAdapter = _load_telegram_adapter()
161161
adapter = TelegramPlatformAdapter(
162162
make_platform_config("telegram"),
@@ -172,8 +172,16 @@ async def test_telegram_document_caption_populates_message_text_and_plain():
172172
caption="@alice 请总结这份文档",
173173
caption_entities=[mention],
174174
)
175+
convert_message_globals = adapter.convert_message.__func__.__globals__
175176

176-
result = await adapter.convert_message(update, _build_context())
177+
with patch.dict(
178+
convert_message_globals,
179+
{
180+
"get_astrbot_temp_path": MagicMock(return_value=str(tmp_path)),
181+
"download_file": AsyncMock(),
182+
},
183+
):
184+
result = await adapter.convert_message(update, _build_context())
177185

178186
assert result is not None
179187
assert result.message_str == "@alice 请总结这份文档"
@@ -189,7 +197,7 @@ async def test_telegram_document_caption_populates_message_text_and_plain():
189197

190198

191199
@pytest.mark.asyncio
192-
async def test_telegram_video_caption_populates_message_text_and_plain():
200+
async def test_telegram_video_caption_populates_message_text_and_plain(tmp_path):
193201
TelegramPlatformAdapter = _load_telegram_adapter()
194202
adapter = TelegramPlatformAdapter(
195203
make_platform_config("telegram"),
@@ -203,8 +211,16 @@ async def test_telegram_video_caption_populates_message_text_and_plain():
203211
video=video,
204212
caption="这段视频讲了什么",
205213
)
214+
convert_message_globals = adapter.convert_message.__func__.__globals__
206215

207-
result = await adapter.convert_message(update, _build_context())
216+
with patch.dict(
217+
convert_message_globals,
218+
{
219+
"get_astrbot_temp_path": MagicMock(return_value=str(tmp_path)),
220+
"download_file": AsyncMock(),
221+
},
222+
):
223+
result = await adapter.convert_message(update, _build_context())
208224

209225
assert result is not None
210226
assert result.message_str == "这段视频讲了什么"
@@ -215,6 +231,133 @@ async def test_telegram_video_caption_populates_message_text_and_plain():
215231
)
216232

217233

234+
@pytest.mark.asyncio
235+
async def test_telegram_document_downloads_to_local_temp_path(tmp_path):
236+
"""#9448: document 组件必须拿到本地路径,而不是原始 Telegram file_path/URL。"""
237+
TelegramPlatformAdapter = _load_telegram_adapter()
238+
adapter = TelegramPlatformAdapter(
239+
make_platform_config("telegram"),
240+
{},
241+
asyncio.Queue(),
242+
)
243+
document = create_mock_file("https://api.telegram.org/file/test/report.md")
244+
document.file_name = "report.md"
245+
update = create_mock_update(message_text=None, document=document)
246+
convert_message_globals = adapter.convert_message.__func__.__globals__
247+
mock_download = AsyncMock()
248+
249+
with patch.dict(
250+
convert_message_globals,
251+
{
252+
"get_astrbot_temp_path": MagicMock(return_value=str(tmp_path)),
253+
"download_file": mock_download,
254+
},
255+
):
256+
result = await adapter.convert_message(update, _build_context())
257+
258+
assert result is not None
259+
file_comp = next(c for c in result.message if isinstance(c, Comp.File))
260+
assert file_comp.name == "report.md"
261+
assert file_comp.file_.startswith(str(tmp_path))
262+
assert file_comp.file_ != "https://api.telegram.org/file/test/report.md"
263+
mock_download.assert_awaited_once()
264+
assert (
265+
mock_download.await_args.args[0]
266+
== "https://api.telegram.org/file/test/report.md"
267+
)
268+
269+
270+
@pytest.mark.asyncio
271+
async def test_telegram_video_downloads_to_local_temp_path(tmp_path):
272+
"""#9448: video 组件必须拿到本地路径,而不是原始 Telegram file_path/URL。"""
273+
TelegramPlatformAdapter = _load_telegram_adapter()
274+
adapter = TelegramPlatformAdapter(
275+
make_platform_config("telegram"),
276+
{},
277+
asyncio.Queue(),
278+
)
279+
video = create_mock_file("https://api.telegram.org/file/test/lesson.mp4")
280+
video.file_name = "lesson.mp4"
281+
update = create_mock_update(message_text=None, video=video)
282+
convert_message_globals = adapter.convert_message.__func__.__globals__
283+
mock_download = AsyncMock()
284+
285+
with patch.dict(
286+
convert_message_globals,
287+
{
288+
"get_astrbot_temp_path": MagicMock(return_value=str(tmp_path)),
289+
"download_file": mock_download,
290+
},
291+
):
292+
result = await adapter.convert_message(update, _build_context())
293+
294+
assert result is not None
295+
video_comp = next(c for c in result.message if isinstance(c, Comp.Video))
296+
assert video_comp.file.startswith(str(tmp_path))
297+
assert video_comp.path.startswith(str(tmp_path))
298+
assert video_comp.file != "https://api.telegram.org/file/test/lesson.mp4"
299+
300+
301+
@pytest.mark.asyncio
302+
async def test_telegram_photo_downloads_to_local_temp_path(tmp_path):
303+
"""#9448: photo 组件必须拿到本地路径,而不是原始 Telegram file_path/URL。"""
304+
TelegramPlatformAdapter = _load_telegram_adapter()
305+
adapter = TelegramPlatformAdapter(
306+
make_platform_config("telegram"),
307+
{},
308+
asyncio.Queue(),
309+
)
310+
photo = create_mock_file("https://api.telegram.org/file/test/photo.jpg")
311+
update = create_mock_update(message_text=None, photo=[photo])
312+
convert_message_globals = adapter.convert_message.__func__.__globals__
313+
mock_download = AsyncMock()
314+
315+
with patch.dict(
316+
convert_message_globals,
317+
{
318+
"get_astrbot_temp_path": MagicMock(return_value=str(tmp_path)),
319+
"download_file": mock_download,
320+
},
321+
):
322+
result = await adapter.convert_message(update, _build_context())
323+
324+
assert result is not None
325+
image_comp = next(c for c in result.message if isinstance(c, Comp.Image))
326+
assert image_comp.file.startswith(str(tmp_path))
327+
assert image_comp.file != "https://api.telegram.org/file/test/photo.jpg"
328+
329+
330+
@pytest.mark.asyncio
331+
async def test_telegram_sticker_downloads_to_local_temp_path(tmp_path):
332+
"""#9448: sticker 组件必须拿到本地路径,而不是原始 Telegram file_path/URL。"""
333+
TelegramPlatformAdapter = _load_telegram_adapter()
334+
adapter = TelegramPlatformAdapter(
335+
make_platform_config("telegram"),
336+
{},
337+
asyncio.Queue(),
338+
)
339+
sticker = create_mock_file("https://api.telegram.org/file/test/sticker.webp")
340+
sticker.emoji = "😀"
341+
update = create_mock_update(message_text=None, sticker=sticker)
342+
convert_message_globals = adapter.convert_message.__func__.__globals__
343+
mock_download = AsyncMock()
344+
345+
with patch.dict(
346+
convert_message_globals,
347+
{
348+
"get_astrbot_temp_path": MagicMock(return_value=str(tmp_path)),
349+
"download_file": mock_download,
350+
},
351+
):
352+
result = await adapter.convert_message(update, _build_context())
353+
354+
assert result is not None
355+
image_comp = next(c for c in result.message if isinstance(c, Comp.Image))
356+
assert image_comp.file.startswith(str(tmp_path))
357+
assert image_comp.file != "https://api.telegram.org/file/test/sticker.webp"
358+
assert result.message_str == "Sticker: 😀"
359+
360+
218361
@pytest.mark.asyncio
219362
async def test_telegram_voice_message_creates_record_component(tmp_path):
220363
TelegramPlatformAdapter = _load_telegram_adapter()

0 commit comments

Comments
 (0)