Send Telegram media attachments natively

Anmol Malik committed May 28, 2026 at 23:07 UTC 97ef4797e0ecd0d3e53843f094787bff9a5680d0
3 files changed +207
plugins/_telegram_integration/helpers/handler.py
+6
@@ -526,6 +526,12 @@ async def send_telegram_reply(
526 local_path = files.fix_dev_path(path)
527 if tc.is_image_file(local_path):
528 await tc.send_photo(reply_bot, chat_id, local_path, reply_to_message_id=reply_to)
529 + elif tc.is_voice_file(local_path):
530 + await tc.send_voice(reply_bot, chat_id, local_path, reply_to_message_id=reply_to)
531 + elif tc.is_audio_file(local_path):
532 + await tc.send_audio(reply_bot, chat_id, local_path, reply_to_message_id=reply_to)
533 + elif tc.is_video_file(local_path):
534 + await tc.send_video(reply_bot, chat_id, local_path, reply_to_message_id=reply_to)
535 else:
536 await tc.send_file(reply_bot, chat_id, local_path, reply_to_message_id=reply_to)
537
plugins/_telegram_integration/helpers/telegram_client.py
+93
@@ -115,6 +115,81 @@ async def send_photo(
115 return None
116
117
118 +async def send_voice(
119 + bot: Bot,
120 + chat_id: int,
121 + voice_path: str,
122 + caption: str = "",
123 + reply_to_message_id: int | None = None,
124 +) -> int | None:
125 + """Send a voice message from local path. Returns message_id or None on error."""
126 + try:
127 + if not os.path.isfile(voice_path):
128 + PrintStyle.error(f"Telegram: voice file not found: {voice_path}")
129 + return None
130 + input_file = FSInputFile(voice_path)
131 + msg = await bot.send_voice(
132 + chat_id=chat_id,
133 + voice=input_file,
134 + caption=caption[:1024] if caption else None,
135 + reply_to_message_id=reply_to_message_id,
136 + )
137 + return msg.message_id
138 + except Exception as e:
139 + PrintStyle.error(f"Telegram send_voice failed: {format_error(e)}")
140 + return None
141 +
142 +
143 +async def send_audio(
144 + bot: Bot,
145 + chat_id: int,
146 + audio_path: str,
147 + caption: str = "",
148 + reply_to_message_id: int | None = None,
149 +) -> int | None:
150 + """Send an audio message from local path. Returns message_id or None on error."""
151 + try:
152 + if not os.path.isfile(audio_path):
153 + PrintStyle.error(f"Telegram: audio file not found: {audio_path}")
154 + return None
155 + input_file = FSInputFile(audio_path)
156 + msg = await bot.send_audio(
157 + chat_id=chat_id,
158 + audio=input_file,
159 + caption=caption[:1024] if caption else None,
160 + reply_to_message_id=reply_to_message_id,
161 + )
162 + return msg.message_id
163 + except Exception as e:
164 + PrintStyle.error(f"Telegram send_audio failed: {format_error(e)}")
165 + return None
166 +
167 +
168 +async def send_video(
169 + bot: Bot,
170 + chat_id: int,
171 + video_path: str,
172 + caption: str = "",
173 + reply_to_message_id: int | None = None,
174 +) -> int | None:
175 + """Send a video message from local path. Returns message_id or None on error."""
176 + try:
177 + if not os.path.isfile(video_path):
178 + PrintStyle.error(f"Telegram: video file not found: {video_path}")
179 + return None
180 + input_file = FSInputFile(video_path)
181 + msg = await bot.send_video(
182 + chat_id=chat_id,
183 + video=input_file,
184 + caption=caption[:1024] if caption else None,
185 + reply_to_message_id=reply_to_message_id,
186 + )
187 + return msg.message_id
188 + except Exception as e:
189 + PrintStyle.error(f"Telegram send_video failed: {format_error(e)}")
190 + return None
191 +
192 +
193 # Inline keyboards
194
195 def build_inline_keyboard(
@@ -294,6 +369,9 @@ def _split_text(text: str, max_len: int) -> list[str]:
369
370
371 _IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
372 +_VOICE_EXTENSIONS = {".ogg", ".oga", ".opus"}
373 +_AUDIO_EXTENSIONS = {".mp3", ".m4a", ".aac", ".wav", ".flac"}
374 +_VIDEO_EXTENSIONS = {".mp4", ".m4v", ".mov", ".webm"}
375
376
377 def is_image_file(path: str) -> bool:
@@ -301,6 +379,21 @@ def is_image_file(path: str) -> bool:
379 return ext in _IMAGE_EXTENSIONS
380
381
382 +def is_voice_file(path: str) -> bool:
383 + _, ext = os.path.splitext(path.lower())
384 + return ext in _VOICE_EXTENSIONS
385 +
386 +
387 +def is_audio_file(path: str) -> bool:
388 + _, ext = os.path.splitext(path.lower())
389 + return ext in _AUDIO_EXTENSIONS
390 +
391 +
392 +def is_video_file(path: str) -> bool:
393 + _, ext = os.path.splitext(path.lower())
394 + return ext in _VIDEO_EXTENSIONS
395 +
396 +
397 def md_to_telegram_html(text: str) -> str:
398 """Convert Markdown to Telegram-compatible HTML."""
399 stash: list[str] = []
tests/test_telegram_media_delivery.py new
+108
@@ -0,0 +1,108 @@
1 +import asyncio
2 +
3 +from plugins._telegram_integration.helpers import handler
4 +from plugins._telegram_integration.helpers import telegram_client as tc
5 +from plugins._telegram_integration.helpers.constants import (
6 + CTX_TG_BOT,
7 + CTX_TG_CHAT_ID,
8 + CTX_TG_REPLY_TO,
9 +)
10 +
11 +
12 +class FakeBotInstance:
13 + class Bot:
14 + token = "token"
15 +
16 + bot = Bot()
17 +
18 +
19 +class FakeContext:
20 + def __init__(self):
21 + self.data = {
22 + CTX_TG_BOT: "main",
23 + CTX_TG_CHAT_ID: 123,
24 + CTX_TG_REPLY_TO: 456,
25 + }
26 +
27 +
28 +class FakeTempBot:
29 + async def __aenter__(self):
30 + return object()
31 +
32 + async def __aexit__(self, exc_type, exc, tb):
33 + return False
34 +
35 +
36 +class FakeMediaBot:
37 + def __init__(self):
38 + self.calls = []
39 +
40 + async def send_voice(self, **kwargs):
41 + self.calls.append(("voice", kwargs))
42 + return type("Message", (), {"message_id": 11})()
43 +
44 + async def send_audio(self, **kwargs):
45 + self.calls.append(("audio", kwargs))
46 + return type("Message", (), {"message_id": 12})()
47 +
48 + async def send_video(self, **kwargs):
49 + self.calls.append(("video", kwargs))
50 + return type("Message", (), {"message_id": 13})()
51 +
52 +
53 +def test_telegram_client_native_media_helpers(monkeypatch):
54 + bot = FakeMediaBot()
55 + monkeypatch.setattr(tc.os.path, "isfile", lambda path: True)
56 +
57 + voice_id = asyncio.run(tc.send_voice(bot, 123, "voice.ogg", reply_to_message_id=456))
58 + audio_id = asyncio.run(tc.send_audio(bot, 123, "song.mp3", reply_to_message_id=456))
59 + video_id = asyncio.run(tc.send_video(bot, 123, "clip.mp4", reply_to_message_id=456))
60 +
61 + assert (voice_id, audio_id, video_id) == (11, 12, 13)
62 + assert [kind for kind, _ in bot.calls] == ["voice", "audio", "video"]
63 + assert bot.calls[0][1]["chat_id"] == 123
64 + assert bot.calls[0][1]["reply_to_message_id"] == 456
65 +
66 +
67 +def test_telegram_reply_routes_native_media_attachments(monkeypatch):
68 + calls = []
69 +
70 + def fake_temp_bot(*args, **kwargs):
71 + return FakeTempBot()
72 +
73 + async def record(kind, bot, chat_id, path, reply_to_message_id=None, **kwargs):
74 + calls.append(
75 + {
76 + "kind": kind,
77 + "chat_id": chat_id,
78 + "path": path,
79 + "reply_to_message_id": reply_to_message_id,
80 + }
81 + )
82 + return len(calls)
83 +
84 + monkeypatch.setattr(handler, "get_bot", lambda name: FakeBotInstance())
85 + monkeypatch.setattr(handler, "_temp_bot", fake_temp_bot)
86 + monkeypatch.setattr(handler.files, "fix_dev_path", lambda path: path)
87 + monkeypatch.setattr(handler.tc, "send_photo", lambda *a, **kw: record("photo", *a, **kw))
88 + monkeypatch.setattr(handler.tc, "send_voice", lambda *a, **kw: record("voice", *a, **kw))
89 + monkeypatch.setattr(handler.tc, "send_audio", lambda *a, **kw: record("audio", *a, **kw))
90 + monkeypatch.setattr(handler.tc, "send_video", lambda *a, **kw: record("video", *a, **kw))
91 + monkeypatch.setattr(handler.tc, "send_file", lambda *a, **kw: record("document", *a, **kw))
92 +
93 + error = asyncio.run(
94 + handler.send_telegram_reply(
95 + FakeContext(),
96 + "",
97 + ["image.png", "voice.ogg", "song.mp3", "clip.mp4", "archive.zip"],
98 + )
99 + )
100 +
101 + assert error is None
102 + assert calls == [
103 + {"kind": "photo", "chat_id": 123, "path": "image.png", "reply_to_message_id": 456},
104 + {"kind": "voice", "chat_id": 123, "path": "voice.ogg", "reply_to_message_id": 456},
105 + {"kind": "audio", "chat_id": 123, "path": "song.mp3", "reply_to_message_id": 456},
106 + {"kind": "video", "chat_id": 123, "path": "clip.mp4", "reply_to_message_id": 456},
107 + {"kind": "document", "chat_id": 123, "path": "archive.zip", "reply_to_message_id": 456},
108 + ]