feat: Telegram send_document and send_video for native file attachments

Implement send_document() and send_video() overrides in TelegramAdapter
so the agent can deliver files (PDFs, CSVs, docs, etc.) and videos as
native Telegram attachments instead of just printing the file path as
text.

The base adapter already routes MEDIA:<path> tags by extension — audio
goes to send_voice(), images to send_image_file(), and everything else
falls through to send_document(). But TelegramAdapter didn't override
send_document() or send_video(), so those fell back to plain text.

Now when the agent includes MEDIA:/path/to/report.pdf in its response,
users get a proper downloadable file attachment in Telegram.

Features:
- send_document: sends files via bot.send_document with display name,
  caption (truncated to 1024), and reply_to support
- send_video: sends videos via bot.send_video with inline playback
- Both fall back to base class text if the Telegram API call fails
- 10 new tests covering success, custom filename, file-not-found,
  not-connected, caption truncation, API error fallback, and reply_to

Requested by @TigerHixTang on Twitter.
This commit is contained in:
teknium1
2026-03-09 12:17:35 -07:00
parent a5a5d82a21
commit 5eaf4a3f32
2 changed files with 259 additions and 0 deletions

View File

@@ -346,6 +346,64 @@ class TelegramAdapter(BasePlatformAdapter):
print(f"[{self.name}] Failed to send local image: {e}")
return await super().send_image_file(chat_id, image_path, caption, reply_to)
async def send_document(
self,
chat_id: str,
file_path: str,
caption: Optional[str] = None,
file_name: Optional[str] = None,
reply_to: Optional[str] = None,
) -> SendResult:
"""Send a document/file natively as a Telegram file attachment."""
if not self._bot:
return SendResult(success=False, error="Not connected")
try:
if not os.path.exists(file_path):
return SendResult(success=False, error=f"File not found: {file_path}")
display_name = file_name or os.path.basename(file_path)
with open(file_path, "rb") as f:
msg = await self._bot.send_document(
chat_id=int(chat_id),
document=f,
filename=display_name,
caption=caption[:1024] if caption else None,
reply_to_message_id=int(reply_to) if reply_to else None,
)
return SendResult(success=True, message_id=str(msg.message_id))
except Exception as e:
print(f"[{self.name}] Failed to send document: {e}")
return await super().send_document(chat_id, file_path, caption, file_name, reply_to)
async def send_video(
self,
chat_id: str,
video_path: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
) -> SendResult:
"""Send a video natively as a Telegram video message."""
if not self._bot:
return SendResult(success=False, error="Not connected")
try:
if not os.path.exists(video_path):
return SendResult(success=False, error=f"Video file not found: {video_path}")
with open(video_path, "rb") as f:
msg = await self._bot.send_video(
chat_id=int(chat_id),
video=f,
caption=caption[:1024] if caption else None,
reply_to_message_id=int(reply_to) if reply_to else None,
)
return SendResult(success=True, message_id=str(msg.message_id))
except Exception as e:
print(f"[{self.name}] Failed to send video: {e}")
return await super().send_video(chat_id, video_path, caption, reply_to)
async def send_image(
self,
chat_id: str,

View File

@@ -20,6 +20,7 @@ from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
MessageEvent,
MessageType,
SendResult,
SUPPORTED_DOCUMENT_TYPES,
)
@@ -336,3 +337,203 @@ class TestDocumentDownloadBlock:
await adapter._handle_media_message(update, MagicMock())
# handle_message should still be called (the handler catches the exception)
adapter.handle_message.assert_called_once()
# ---------------------------------------------------------------------------
# TestSendDocument — outbound file attachment delivery
# ---------------------------------------------------------------------------
class TestSendDocument:
"""Tests for TelegramAdapter.send_document() — sending files to users."""
@pytest.fixture()
def connected_adapter(self, adapter):
"""Adapter with a mock bot attached."""
bot = AsyncMock()
adapter._bot = bot
return adapter
@pytest.mark.asyncio
async def test_send_document_success(self, connected_adapter, tmp_path):
"""A local file is sent via bot.send_document and returns success."""
# Create a real temp file
test_file = tmp_path / "report.pdf"
test_file.write_bytes(b"%PDF-1.4 fake content")
mock_msg = MagicMock()
mock_msg.message_id = 99
connected_adapter._bot.send_document = AsyncMock(return_value=mock_msg)
result = await connected_adapter.send_document(
chat_id="12345",
file_path=str(test_file),
caption="Here's the report",
)
assert result.success is True
assert result.message_id == "99"
connected_adapter._bot.send_document.assert_called_once()
call_kwargs = connected_adapter._bot.send_document.call_args[1]
assert call_kwargs["chat_id"] == 12345
assert call_kwargs["filename"] == "report.pdf"
assert call_kwargs["caption"] == "Here's the report"
@pytest.mark.asyncio
async def test_send_document_custom_filename(self, connected_adapter, tmp_path):
"""The file_name parameter overrides the basename for display."""
test_file = tmp_path / "doc_abc123_ugly.csv"
test_file.write_bytes(b"a,b,c\n1,2,3")
mock_msg = MagicMock()
mock_msg.message_id = 100
connected_adapter._bot.send_document = AsyncMock(return_value=mock_msg)
result = await connected_adapter.send_document(
chat_id="12345",
file_path=str(test_file),
file_name="clean_data.csv",
)
assert result.success is True
call_kwargs = connected_adapter._bot.send_document.call_args[1]
assert call_kwargs["filename"] == "clean_data.csv"
@pytest.mark.asyncio
async def test_send_document_file_not_found(self, connected_adapter):
"""Missing file returns error without calling Telegram API."""
result = await connected_adapter.send_document(
chat_id="12345",
file_path="/nonexistent/file.pdf",
)
assert result.success is False
assert "not found" in result.error.lower()
connected_adapter._bot.send_document.assert_not_called()
@pytest.mark.asyncio
async def test_send_document_not_connected(self, adapter):
"""If bot is None, returns not connected error."""
result = await adapter.send_document(
chat_id="12345",
file_path="/some/file.pdf",
)
assert result.success is False
assert "Not connected" in result.error
@pytest.mark.asyncio
async def test_send_document_caption_truncated(self, connected_adapter, tmp_path):
"""Captions longer than 1024 chars are truncated."""
test_file = tmp_path / "data.json"
test_file.write_bytes(b"{}")
mock_msg = MagicMock()
mock_msg.message_id = 101
connected_adapter._bot.send_document = AsyncMock(return_value=mock_msg)
long_caption = "x" * 2000
await connected_adapter.send_document(
chat_id="12345",
file_path=str(test_file),
caption=long_caption,
)
call_kwargs = connected_adapter._bot.send_document.call_args[1]
assert len(call_kwargs["caption"]) == 1024
@pytest.mark.asyncio
async def test_send_document_api_error_falls_back(self, connected_adapter, tmp_path):
"""If Telegram API raises, falls back to base class text message."""
test_file = tmp_path / "file.pdf"
test_file.write_bytes(b"data")
connected_adapter._bot.send_document = AsyncMock(
side_effect=RuntimeError("Telegram API error")
)
# The base fallback calls self.send() which is also on _bot, so mock it
# to avoid cascading errors.
connected_adapter.send = AsyncMock(
return_value=SendResult(success=True, message_id="fallback")
)
result = await connected_adapter.send_document(
chat_id="12345",
file_path=str(test_file),
)
# Should have fallen back to base class
assert result.success is True
assert result.message_id == "fallback"
@pytest.mark.asyncio
async def test_send_document_reply_to(self, connected_adapter, tmp_path):
"""reply_to parameter is forwarded as reply_to_message_id."""
test_file = tmp_path / "spec.md"
test_file.write_bytes(b"# Spec")
mock_msg = MagicMock()
mock_msg.message_id = 102
connected_adapter._bot.send_document = AsyncMock(return_value=mock_msg)
await connected_adapter.send_document(
chat_id="12345",
file_path=str(test_file),
reply_to="50",
)
call_kwargs = connected_adapter._bot.send_document.call_args[1]
assert call_kwargs["reply_to_message_id"] == 50
# ---------------------------------------------------------------------------
# TestSendVideo — outbound video delivery
# ---------------------------------------------------------------------------
class TestSendVideo:
"""Tests for TelegramAdapter.send_video() — sending videos to users."""
@pytest.fixture()
def connected_adapter(self, adapter):
bot = AsyncMock()
adapter._bot = bot
return adapter
@pytest.mark.asyncio
async def test_send_video_success(self, connected_adapter, tmp_path):
test_file = tmp_path / "clip.mp4"
test_file.write_bytes(b"\x00\x00\x00\x1c" + b"ftyp" + b"\x00" * 100)
mock_msg = MagicMock()
mock_msg.message_id = 200
connected_adapter._bot.send_video = AsyncMock(return_value=mock_msg)
result = await connected_adapter.send_video(
chat_id="12345",
video_path=str(test_file),
caption="Check this out",
)
assert result.success is True
assert result.message_id == "200"
connected_adapter._bot.send_video.assert_called_once()
@pytest.mark.asyncio
async def test_send_video_file_not_found(self, connected_adapter):
result = await connected_adapter.send_video(
chat_id="12345",
video_path="/nonexistent/video.mp4",
)
assert result.success is False
assert "not found" in result.error.lower()
@pytest.mark.asyncio
async def test_send_video_not_connected(self, adapter):
result = await adapter.send_video(
chat_id="12345",
video_path="/some/video.mp4",
)
assert result.success is False
assert "Not connected" in result.error