Add Telegram speech mode
Anmol Malik committed
May 28, 2026 at 23:35 UTC
709b8653996508eeda42ebfbdc54a282d5d3c92e
7 files changed
+248
-4
helpers/integration_commands.py
+8
@@ -70,6 +70,12 @@ COMMAND_REGISTRY: tuple[IntegrationCommandDef, ...] = (
70
"Configuration",
71
args_hint="[on|off]",
72
),
73
+ IntegrationCommandDef(
74
+ "speech",
75
+ "Enable or disable Telegram spoken replies.",
76
+ "Configuration",
77
+ args_hint="[on|off]",
78
+ ),
79
IntegrationCommandDef(
80
"project",
81
"Show or switch the active project.",
@@ -194,6 +200,8 @@ def try_handle_command(context: "AgentContext", text: str) -> str | None:
200
return _handle_toggle(context, args, "telegram_stream_enabled", "Response streaming")
201
if command == "/tools":
202
return _handle_toggle(context, args, "telegram_tools_enabled", "Tool progress")
203
+ if command == "/speech":
204
+ return _handle_toggle(context, args, "telegram_speech_enabled", "Speech replies")
205
if command == "/project":
206
return _handle_project(context, args)
207
if command == "/model":
plugins/_telegram_integration/README.md
+3
@@ -24,6 +24,7 @@ This plugin connects one or more Telegram bots to Agent Zero. Each bot runs inde
24
- `/agent <profile>` switches the active agent profile when the current run is idle.
25
- `/model`, `/project`, and `/agent` show Telegram inline keyboard pickers when used without arguments.
26
- `/stream` toggles live response streaming; `/tools` toggles tool progress messages.
27
+ - `/speech` toggles spoken replies. When enabled, final text replies also include Kokoro-generated Telegram audio when Kokoro TTS is available.
28
- `/send` or `/queue send` flushes queued messages for the current chat.
29
- `/steer <message>` sends an intervention to the active run.
30
- **Group support**
@@ -35,6 +36,7 @@ This plugin connects one or more Telegram bots to Agent Zero. Each bot runs inde
36
- **Reply delivery**
37
- Streams tool progress and response text through real Telegram messages updated with `editMessageText`.
38
- Tool progress and the AI response are separate messages; only the AI response replies to the user message.
39
+ - Sends native Telegram media: photos, voice/audio files, videos, and document fallback.
40
- `tool_execute_after` intercepts the `response` tool — sends `break_loop=false` updates as separate intermediate Telegram messages.
41
- `process_chain_end` auto-sends the final response, with retry logic on failure.
42
- **Formatting**
@@ -63,6 +65,7 @@ This plugin connects one or more Telegram bots to Agent Zero. Each bot runs inde
65
- `helpers/telegram_client.py` — Low-level Telegram API wrapper: send text/file/photo, Markdown→HTML converter, keyboard builder, message splitting.
66
- `helpers/command_ui.py` — Telegram inline keyboard command pickers and callback handling.
67
- `helpers/draft_stream.py` — Editable Telegram message streaming for tool progress and response previews.
68
+ - `helpers/speech.py` — Telegram speech-mode TTS synthesis and audio attachment generation.
69
- **Extensions**
70
- `extensions/python/job_loop/_10_telegram_bot.py` — Bot lifecycle manager, starts/stops bots on each tick.
71
- `extensions/python/message_loop_start/_45_telegram_draft_start.py` — Initializes Telegram response streaming.
plugins/_telegram_integration/extensions/python/process_chain_end/_55_telegram_reply.py
+10
@@ -32,7 +32,17 @@ class TelegramAutoReply(Extension):
32
33
try:
34
if response_text:
35
+ from plugins._telegram_integration.helpers import speech
36
+
37
+ speech_attachment, speech_warning = await speech.synthesize_attachment(
38
+ context, response_text
39
+ )
40
+ if speech_attachment:
41
+ attachments = [*attachments, speech_attachment]
42
await self._send_reply(context, response_text, attachments, keyboard)
43
+ warning = speech.consume_warning(context, speech_warning)
44
+ if warning:
45
+ await self._send_reply(context, warning, [], None)
46
except Exception as e:
47
PrintStyle.error(f"Telegram auto-reply error: {format_error(e)}")
48
finally:
plugins/_telegram_integration/helpers/command_ui.py
+33
-4
@@ -13,6 +13,7 @@ from plugins._telegram_integration.helpers import telegram_client as tc
13
from plugins._telegram_integration.helpers.constants import (
14
CTX_TG_STREAM_ENABLED,
15
CTX_TG_TOOLS_ENABLED,
16
+ CTX_TG_SPEECH_ENABLED,
17
CTX_TG_BOT,
18
CTX_TG_CHAT_ID,
19
CTX_TG_CHAT_TYPE,
@@ -85,6 +86,17 @@ async def handle_command(
86
args,
87
)
88
return True
89
+ if command == "/speech":
90
+ await send_toggle_picker(
91
+ context,
92
+ token,
93
+ chat_id,
94
+ reply_to_message_id,
95
+ CTX_TG_SPEECH_ENABLED,
96
+ "Speech replies",
97
+ args,
98
+ )
99
+ return True
100
return False
101
102
@@ -129,9 +141,8 @@ async def handle_callback(
141
selected_context = await _select_session(context, _safe_int(value))
142
await edit_session_picker(selected_context or context, token, chat_id, message_id, 0, selected=bool(selected_context))
143
return True
132
- if kind in {"stream", "tools"} and action in {"on", "off"}:
133
- key = CTX_TG_STREAM_ENABLED if kind == "stream" else CTX_TG_TOOLS_ENABLED
134
- label = "Response streaming" if kind == "stream" else "Tool progress"
144
+ if kind in {"stream", "tools", "speech"} and action in {"on", "off"}:
145
+ key, label = _toggle_key_label(kind)
146
context.set_data(key, action == "on")
147
save_tmp_chat(context)
148
mark_dirty_for_context(context.id, reason=f"telegram.{kind}_toggle")
@@ -334,7 +345,7 @@ def _agent_view(
345
346
def _toggle_view(context: AgentContext, key: str, label: str) -> tuple[str, dict]:
347
enabled = _toggle_enabled(context, key)
337
- kind = "stream" if key == CTX_TG_STREAM_ENABLED else "tools"
348
+ kind = _toggle_kind(key)
349
state = "enabled" if enabled else "disabled"
350
text = f"{_html(label)}: <b>{state}</b>"
351
rows = [[
@@ -542,9 +553,27 @@ def _paged_buttons(
553
554
def _toggle_enabled(context: AgentContext, key: str) -> bool:
555
value = context.get_data(key)
556
+ if key == CTX_TG_SPEECH_ENABLED:
557
+ return bool(value)
558
return True if value is None else bool(value)
559
560
561
+def _toggle_kind(key: str) -> str:
562
+ if key == CTX_TG_STREAM_ENABLED:
563
+ return "stream"
564
+ if key == CTX_TG_TOOLS_ENABLED:
565
+ return "tools"
566
+ return "speech"
567
+
568
+
569
+def _toggle_key_label(kind: str) -> tuple[str, str]:
570
+ if kind == "stream":
571
+ return CTX_TG_STREAM_ENABLED, "Response streaming"
572
+ if kind == "tools":
573
+ return CTX_TG_TOOLS_ENABLED, "Tool progress"
574
+ return CTX_TG_SPEECH_ENABLED, "Speech replies"
575
+
576
+
577
def _parse_toggle(args: str) -> bool | None:
578
value = (args or "").strip().lower()
579
if value in {"on", "enable", "enabled", "yes", "true", "1"}:
plugins/_telegram_integration/helpers/constants.py
+2
@@ -13,6 +13,7 @@ CTX_TG_TYPING_STOP = "_telegram_typing_stop"
13
CTX_TG_REPLY_TO = "_telegram_reply_to_message_id"
14
CTX_TG_STREAM_ENABLED = "telegram_stream_enabled"
15
CTX_TG_TOOLS_ENABLED = "telegram_tools_enabled"
16
+CTX_TG_SPEECH_ENABLED = "telegram_speech_enabled"
17
18
# Transient
19
CTX_TG_ATTACHMENTS = "_telegram_response_attachments"
@@ -25,3 +26,4 @@ CTX_TG_RESPONSE_LAST_UPDATE = "_telegram_response_last_update"
26
CTX_TG_ERROR_SENT = "_telegram_error_sent"
27
CTX_TG_HEARTBEAT_TASK = "_telegram_heartbeat_task"
28
CTX_TG_HEARTBEAT_STOP = "_telegram_heartbeat_stop"
29
+CTX_TG_SPEECH_WARNING_SENT = "_telegram_speech_warning_sent"
plugins/_telegram_integration/helpers/speech.py
new
+80
@@ -0,0 +1,80 @@
1
+from __future__ import annotations
2
+
3
+import re
4
+import uuid
5
+
6
+from agent import AgentContext
7
+from helpers import files
8
+from plugins._telegram_integration.helpers.constants import (
9
+ CTX_TG_SPEECH_ENABLED,
10
+ CTX_TG_SPEECH_WARNING_SENT,
11
+ DOWNLOAD_FOLDER,
12
+)
13
+
14
+
15
+MAX_SPEECH_CHARS = 1800
16
+
17
+
18
+def is_enabled(context: AgentContext) -> bool:
19
+ return bool(context.get_data(CTX_TG_SPEECH_ENABLED))
20
+
21
+
22
+async def synthesize_attachment(context: AgentContext, text: str) -> tuple[str | None, str | None]:
23
+ if not is_enabled(context):
24
+ return None, None
25
+
26
+ speech_text = _speech_text(text)
27
+ if not speech_text:
28
+ return None, "Speech mode is enabled, but there was no readable text to speak."
29
+
30
+ try:
31
+ from plugins._kokoro_tts.helpers import runtime
32
+ except Exception:
33
+ return None, _setup_message()
34
+
35
+ if not runtime.is_globally_enabled():
36
+ return None, _setup_message()
37
+
38
+ try:
39
+ audio = await runtime.synthesize_sentences([speech_text])
40
+ except Exception as exc:
41
+ return None, f"Speech mode is enabled, but text-to-speech failed: {exc}"
42
+
43
+ if not audio:
44
+ return None, "Speech mode is enabled, but text-to-speech returned no audio."
45
+
46
+ name = f"telegram_speech_{context.id}_{uuid.uuid4().hex[:10]}.wav"
47
+ relative_path = f"{DOWNLOAD_FOLDER}/{name}"
48
+ files.write_file_base64(relative_path, audio)
49
+ return files.get_abs_path_dockerized(relative_path), None
50
+
51
+
52
+def consume_warning(context: AgentContext, warning: str | None) -> str | None:
53
+ if not warning:
54
+ context.data.pop(CTX_TG_SPEECH_WARNING_SENT, None)
55
+ return None
56
+ if context.data.get(CTX_TG_SPEECH_WARNING_SENT):
57
+ return None
58
+ context.data[CTX_TG_SPEECH_WARNING_SENT] = True
59
+ return warning
60
+
61
+
62
+def _setup_message() -> str:
63
+ return (
64
+ "Speech mode is enabled, but Kokoro TTS is not available. "
65
+ "Enable the Kokoro TTS plugin from Agent Zero settings, then try again."
66
+ )
67
+
68
+
69
+def _speech_text(text: str) -> str:
70
+ value = str(text or "")
71
+ value = re.sub(r"```.*?```", " ", value, flags=re.DOTALL)
72
+ value = re.sub(r"`([^`]+)`", r"\1", value)
73
+ value = re.sub(r"!\[([^\]]*)\]\([^)]+\)", r"\1", value)
74
+ value = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", value)
75
+ value = re.sub(r"<[^>]+>", " ", value)
76
+ value = re.sub(r"[*_#>\-]+", " ", value)
77
+ value = re.sub(r"\s+", " ", value).strip()
78
+ if len(value) > MAX_SPEECH_CHARS:
79
+ value = value[:MAX_SPEECH_CHARS].rstrip()
80
+ return value
tests/test_telegram_speech_mode.py
new
+112
@@ -0,0 +1,112 @@
1
+import asyncio
2
+
3
+from plugins._telegram_integration.extensions.python.process_chain_end import (
4
+ _55_telegram_reply,
5
+)
6
+from plugins._telegram_integration.helpers import command_ui, speech
7
+from plugins._telegram_integration.helpers.constants import (
8
+ CTX_TG_BOT,
9
+ CTX_TG_REPLY_TO,
10
+ CTX_TG_SPEECH_ENABLED,
11
+)
12
+
13
+
14
+class FakeContext:
15
+ id = "ctx-speech"
16
+
17
+ def __init__(self):
18
+ self.data = {}
19
+
20
+ def get_data(self, key):
21
+ return self.data.get(key)
22
+
23
+ def set_data(self, key, value):
24
+ self.data[key] = value
25
+
26
+
27
+class FakeAgent:
28
+ number = 0
29
+
30
+ def __init__(self):
31
+ self.context = FakeContext()
32
+ self.context.data[CTX_TG_BOT] = "main"
33
+ self.context.data[CTX_TG_REPLY_TO] = 456
34
+
35
+
36
+def test_speech_toggle_defaults_off():
37
+ context = FakeContext()
38
+
39
+ text, markup = command_ui._toggle_view(context, CTX_TG_SPEECH_ENABLED, "Speech replies")
40
+
41
+ assert "Speech replies: <b>disabled</b>" == text
42
+ assert markup["inline_keyboard"][0][0]["callback_data"] == "tg:speech:on"
43
+ assert markup["inline_keyboard"][0][1]["callback_data"] == "tg:speech:off"
44
+
45
+
46
+def test_speech_text_is_cleaned_for_tts():
47
+ cleaned = speech._speech_text(
48
+ "**Hello** [`Agent Zero`](https://example.com)\n\n```python\nprint('skip')\n```"
49
+ )
50
+
51
+ assert cleaned == "Hello Agent Zero"
52
+
53
+
54
+def test_speech_synthesis_writes_audio_attachment(monkeypatch):
55
+ context = FakeContext()
56
+ context.data[CTX_TG_SPEECH_ENABLED] = True
57
+ writes = []
58
+
59
+ class FakeRuntime:
60
+ @staticmethod
61
+ def is_globally_enabled():
62
+ return True
63
+
64
+ @staticmethod
65
+ async def synthesize_sentences(sentences):
66
+ assert sentences == ["Hello from Agent Zero."]
67
+ return "UklGRg=="
68
+
69
+ monkeypatch.setattr(speech.files, "write_file_base64", lambda path, audio: writes.append((path, audio)))
70
+ monkeypatch.setattr(speech.files, "get_abs_path_dockerized", lambda path: f"/a0/{path}")
71
+ monkeypatch.setitem(__import__("sys").modules, "plugins._kokoro_tts.helpers.runtime", FakeRuntime)
72
+
73
+ path, warning = asyncio.run(speech.synthesize_attachment(context, "Hello from Agent Zero."))
74
+
75
+ assert warning is None
76
+ assert path.startswith("/a0/usr/uploads/telegram_speech_ctx-speech_")
77
+ assert path.endswith(".wav")
78
+ assert writes[0][1] == "UklGRg=="
79
+
80
+
81
+def test_final_reply_includes_speech_attachment(monkeypatch):
82
+ agent = FakeAgent()
83
+ sent = []
84
+
85
+ async def fake_synthesize(context, text):
86
+ return "/a0/usr/uploads/reply.wav", None
87
+
88
+ async def fake_send_reply(context, response_text, attachments, keyboard):
89
+ sent.append(
90
+ {
91
+ "text": response_text,
92
+ "attachments": attachments,
93
+ "keyboard": keyboard,
94
+ }
95
+ )
96
+
97
+ monkeypatch.setattr(_55_telegram_reply, "_extract_last_response", lambda context: "Final reply.")
98
+ monkeypatch.setattr(speech, "synthesize_attachment", fake_synthesize)
99
+
100
+ extension = _55_telegram_reply.TelegramAutoReply(agent=agent)
101
+ extension._send_reply = fake_send_reply
102
+
103
+ asyncio.run(extension.execute())
104
+
105
+ assert sent == [
106
+ {
107
+ "text": "Final reply.",
108
+ "attachments": ["/a0/usr/uploads/reply.wav"],
109
+ "keyboard": None,
110
+ }
111
+ ]
112
+ assert CTX_TG_REPLY_TO not in agent.context.data