Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions gateway/platforms/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,64 @@ async def send_image_file(
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,
Expand Down
201 changes: 201 additions & 0 deletions tests/gateway/test_telegram_documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from gateway.platforms.base import (
MessageEvent,
MessageType,
SendResult,
SUPPORTED_DOCUMENT_TYPES,
)

Expand Down Expand Up @@ -336,3 +337,203 @@ async def test_download_exception_handled(self, adapter):
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