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
+ ]