Improve Telegram integration command and streaming UX
Anmol Malik committed
May 26, 2026 at 20:18 UTC
1bbbfa44acd87b9702176dfb88be410bcd861554
16 files changed
+1405
-90
helpers/integration_commands.py
+302
-12
@@ -1,6 +1,7 @@
1
from __future__ import annotations
2
3
import re
4
+from dataclasses import dataclass
5
from typing import TYPE_CHECKING
6
7
from helpers import message_queue as mq
@@ -14,7 +15,83 @@ if TYPE_CHECKING:
15
16
17
_CLEAR_VALUES = {"", "default", "none", "clear", "off"}
17
-_SUPPORTED_COMMANDS = {"/send", "/queue", "/project", "/config", "/preset"}
18
+
19
+
20
+@dataclass(frozen=True)
21
+class IntegrationCommandDef:
22
+ name: str
23
+ description: str
24
+ category: str
25
+ aliases: tuple[str, ...] = ()
26
+ args_hint: str = ""
27
+ menu: bool = True
28
+
29
+
30
+COMMAND_REGISTRY: tuple[IntegrationCommandDef, ...] = (
31
+ IntegrationCommandDef("commands", "Show all integration commands.", "Info", aliases=("help",)),
32
+ IntegrationCommandDef(
33
+ "status",
34
+ "Show this chat's project, model, agent, and queue state.",
35
+ "Info",
36
+ ),
37
+ IntegrationCommandDef("new", "Start a fresh chat context.", "Session"),
38
+ IntegrationCommandDef("clear", "Reset the current chat context.", "Session", aliases=("reset",)),
39
+ IntegrationCommandDef(
40
+ "queue",
41
+ "Show or manage queued messages.",
42
+ "Session",
43
+ args_hint="[send|clear]",
44
+ ),
45
+ IntegrationCommandDef("send", "Send queued messages now.", "Session", aliases=("push",)),
46
+ IntegrationCommandDef(
47
+ "steer",
48
+ "Intervene in the currently running task.",
49
+ "Session",
50
+ args_hint="<message>",
51
+ ),
52
+ IntegrationCommandDef("pause", "Pause the active run.", "Session"),
53
+ IntegrationCommandDef("resume", "Resume a paused run.", "Session"),
54
+ IntegrationCommandDef("nudge", "Nudge the active run.", "Session"),
55
+ IntegrationCommandDef(
56
+ "stream",
57
+ "Enable or disable Telegram response streaming.",
58
+ "Configuration",
59
+ args_hint="[on|off]",
60
+ ),
61
+ IntegrationCommandDef(
62
+ "tools",
63
+ "Show or hide Telegram tool progress.",
64
+ "Configuration",
65
+ args_hint="[on|off]",
66
+ ),
67
+ IntegrationCommandDef(
68
+ "project",
69
+ "Show or switch the active project.",
70
+ "Configuration",
71
+ args_hint="[name|none]",
72
+ ),
73
+ IntegrationCommandDef(
74
+ "model",
75
+ "Show or switch the chat model preset.",
76
+ "Configuration",
77
+ aliases=("config", "preset"),
78
+ args_hint="[preset|default]",
79
+ ),
80
+ IntegrationCommandDef(
81
+ "agent",
82
+ "Show or switch the agent profile.",
83
+ "Configuration",
84
+ aliases=("profile",),
85
+ args_hint="[profile]",
86
+ ),
87
+)
88
+
89
+
90
+_COMMAND_LOOKUP = {
91
+ f"/{name}": command
92
+ for command in COMMAND_REGISTRY
93
+ for name in (command.name, *command.aliases)
94
+}
95
96
97
def extract_command_line(text: str) -> str:
@@ -32,11 +109,53 @@ def parse_command(text: str) -> tuple[str, str] | None:
109
return None
110
111
command, _, args = line.partition(" ")
35
- command = command.strip().lower()
36
- if command not in _SUPPORTED_COMMANDS:
112
+ command = _normalize_command_token(command)
113
+ resolved = resolve_command(command)
114
+ if not resolved:
115
return None
116
39
- return command, args.strip()
117
+ return f"/{resolved.name}", args.strip()
118
+
119
+
120
+def resolve_command(command: str) -> IntegrationCommandDef | None:
121
+ normalized = _normalize_command_token(command)
122
+ if not normalized.startswith("/"):
123
+ normalized = f"/{normalized}"
124
+ return _COMMAND_LOOKUP.get(normalized)
125
+
126
+
127
+def telegram_menu_commands() -> list[tuple[str, str]]:
128
+ return [
129
+ (command.name, _telegram_description(command))
130
+ for command in COMMAND_REGISTRY
131
+ if command.menu
132
+ ]
133
+
134
+
135
+def command_names(include_aliases: bool = True) -> list[str]:
136
+ names: list[str] = []
137
+ for command in COMMAND_REGISTRY:
138
+ names.append(command.name)
139
+ if include_aliases:
140
+ names.extend(command.aliases)
141
+ return names
142
+
143
+
144
+def help_text(*, full: bool = False) -> str:
145
+ commands = COMMAND_REGISTRY if full else tuple(c for c in COMMAND_REGISTRY if c.menu)
146
+ lines = ["Available commands:"]
147
+ for command in commands:
148
+ args = f" {command.args_hint}" if command.args_hint else ""
149
+ alias_text = ""
150
+ if command.aliases:
151
+ alias_text = f" (alias: {', '.join('/' + alias for alias in command.aliases)})"
152
+ lines.append(f"/{command.name}{args} - {command.description}{alias_text}")
153
+ return "\n".join(lines)
154
+
155
+
156
+def unknown_command_text(command: str) -> str:
157
+ token = _normalize_command_token(command).split(" ", 1)[0]
158
+ return f"Unknown command: {token}\n\n{help_text(full=True)}"
159
160
161
def try_handle_command(context: "AgentContext", text: str) -> str | None:
@@ -45,17 +164,47 @@ def try_handle_command(context: "AgentContext", text: str) -> str | None:
164
return None
165
166
command, args = parsed
167
+ if command == "/commands":
168
+ return help_text(full=True)
169
+ if command == "/status":
170
+ return _handle_status(context)
171
+ if command in {"/new", "/clear"}:
172
+ return _handle_clear(context, new_chat=(command == "/new"))
173
if command == "/send":
174
return _handle_queue(context, "send")
175
if command == "/queue":
176
return _handle_queue(context, args)
177
+ if command == "/steer":
178
+ return _handle_steer(context, args)
179
+ if command == "/pause":
180
+ return _handle_pause(context)
181
+ if command == "/resume":
182
+ return _handle_resume(context)
183
+ if command == "/nudge":
184
+ return _handle_nudge(context)
185
+ if command == "/stream":
186
+ return _handle_toggle(context, args, "telegram_stream_enabled", "Response streaming")
187
+ if command == "/tools":
188
+ return _handle_toggle(context, args, "telegram_tools_enabled", "Tool progress")
189
if command == "/project":
190
return _handle_project(context, args)
54
- if command in {"/config", "/preset"}:
55
- return _handle_config(context, args)
191
+ if command == "/model":
192
+ return _handle_model(context, args)
193
+ if command == "/agent":
194
+ return _handle_agent(context, args)
195
return None
196
197
198
+def _normalize_command_token(command: str) -> str:
199
+ normalized = command.strip().lower()
200
+ if not normalized:
201
+ return ""
202
+ token, *rest = normalized.split(" ", 1)
203
+ if "@" in token:
204
+ token = token.split("@", 1)[0]
205
+ return f"{token} {rest[0]}".strip() if rest else token
206
+
207
+
208
def _handle_queue(context: "AgentContext", args: str) -> str:
209
queue = mq.get_queue(context)
210
count = len(queue)
@@ -68,8 +217,13 @@ def _handle_queue(context: "AgentContext", args: str) -> str:
217
"Use /send or /queue send to send everything as one batch."
218
)
219
220
+ if action in {"clear", "reset"}:
221
+ mq.remove(context)
222
+ mark_dirty_for_context(context.id, reason="integration_commands.queue_clear")
223
+ return "Queue cleared."
224
+
225
if action not in {"send", "all"}:
72
- return "Unknown queue action. Use /queue send to flush the queue."
226
+ return "Unknown queue action. Use /queue send to flush or /queue clear to clear."
227
228
if count == 0:
229
return "Queue is empty."
@@ -80,6 +234,73 @@ def _handle_queue(context: "AgentContext", args: str) -> str:
234
return f"Sent {sent_count} queued {noun} as one batch."
235
236
237
+def _handle_status(context: "AgentContext") -> str:
238
+ project_name = context.get_data("project") or "none"
239
+ override = context.get_data("chat_model_override")
240
+ agent_profile = getattr(context.agent0.config, "profile", "default")
241
+ running = "running" if context.is_running() else "idle"
242
+ if getattr(context, "paused", False):
243
+ running = "paused"
244
+ queue_count = len(mq.get_queue(context))
245
+ return (
246
+ f"Status: {running}\n"
247
+ f"Project: {project_name}\n"
248
+ f"Model: {_describe_override(override)}\n"
249
+ f"Agent: {agent_profile}\n"
250
+ f"Queued messages: {queue_count}"
251
+ )
252
+
253
+
254
+def _handle_clear(context: "AgentContext", *, new_chat: bool) -> str:
255
+ context.reset()
256
+ mq.remove(context)
257
+ save_tmp_chat(context)
258
+ reason = "integration_commands.new" if new_chat else "integration_commands.clear"
259
+ mark_dirty_for_context(context.id, reason=reason)
260
+ return "Started a fresh chat." if new_chat else "Chat cleared."
261
+
262
+
263
+def _handle_steer(context: "AgentContext", args: str) -> str:
264
+ message = args.strip()
265
+ if not message:
266
+ return "Usage: /steer <message>"
267
+ from agent import UserMessage
268
+
269
+ context.communicate(UserMessage(message=message))
270
+ if context.is_running():
271
+ return "Steering message sent to the active run."
272
+ return "Message sent."
273
+
274
+
275
+def _handle_pause(context: "AgentContext") -> str:
276
+ if not context.is_running():
277
+ return "No active run is currently running."
278
+ context.paused = True
279
+ return "Agent paused."
280
+
281
+
282
+def _handle_resume(context: "AgentContext") -> str:
283
+ context.paused = False
284
+ return "Agent resumed."
285
+
286
+
287
+def _handle_nudge(context: "AgentContext") -> str:
288
+ context.nudge()
289
+ return "Agent nudged."
290
+
291
+
292
+def _handle_toggle(context: "AgentContext", args: str, key: str, label: str) -> str:
293
+ value = _parse_toggle(args)
294
+ current = _get_toggle(context, key)
295
+ if value is None:
296
+ state = "on" if current else "off"
297
+ return f"{label}: {state}. Use /{key.split('_')[1]} on or /{key.split('_')[1]} off."
298
+ context.set_data(key, value)
299
+ save_tmp_chat(context)
300
+ mark_dirty_for_context(context.id, reason=f"integration_commands.{key}")
301
+ return f"{label} {'enabled' if value else 'disabled'}."
302
+
303
+
304
def _handle_project(context: "AgentContext", args: str) -> str:
305
items = projects.get_active_projects_list() or []
306
current_name = context.get_data("project") or ""
@@ -115,7 +336,7 @@ def _handle_project(context: "AgentContext", args: str) -> str:
336
return f"Switched project to {match.get('title') or match['name']}."
337
338
118
-def _handle_config(context: "AgentContext", args: str) -> str:
339
+def _handle_model(context: "AgentContext", args: str) -> str:
340
allowed = model_config.is_chat_override_allowed(context.agent0)
341
presets = [preset for preset in model_config.get_presets() if preset.get("name")]
342
current_override = context.get_data("chat_model_override")
@@ -123,12 +344,12 @@ def _handle_config(context: "AgentContext", args: str) -> str:
344
if not args:
345
current_label = _describe_override(current_override)
346
available = ", ".join(preset["name"] for preset in presets) or "none"
126
- suffix = "Use /config <name> to switch, or /config default to clear it."
347
+ suffix = "Use /model <name> to switch, or /model default to clear it."
348
if not allowed:
349
suffix = "Per-chat config switching is disabled in Model Configuration."
350
return (
130
- f"Current config: {current_label}\n"
131
- f"Available configs: {available}\n"
351
+ f"Current model: {current_label}\n"
352
+ f"Available presets: {available}\n"
353
f"{suffix}"
354
)
355
@@ -159,7 +380,49 @@ def _handle_config(context: "AgentContext", args: str) -> str:
380
context.set_data("chat_model_override", {"preset_name": preset_name})
381
save_tmp_chat(context)
382
mark_dirty_for_context(context.id, reason="integration_commands.config_set")
162
- return f"Switched config to {preset_name}."
383
+ return f"Switched model preset to {preset_name}."
384
+
385
+
386
+def _handle_agent(context: "AgentContext", args: str) -> str:
387
+ from agent import Agent
388
+ from helpers import subagents
389
+ from initialize import initialize_agent
390
+
391
+ items = subagents.get_all_agents_list()
392
+ current = getattr(context.agent0.config, "profile", "default")
393
+ if not args:
394
+ available = ", ".join(_format_agent_entry(item) for item in items) or "none"
395
+ return (
396
+ f"Current agent: {current}\n"
397
+ f"Available agents: {available}\n"
398
+ "Use /agent <profile> to switch after the current run finishes."
399
+ )
400
+
401
+ if context.is_running():
402
+ return "Agent profile can be changed after the current run finishes."
403
+
404
+ desired = _strip_quotes(args)
405
+ match, ambiguous = _match_named_item(items, desired, keys=("key", "label"))
406
+ if ambiguous:
407
+ names = ", ".join(_format_agent_entry(item) for item in ambiguous)
408
+ return f"Agent profile is ambiguous. Matches: {names}"
409
+ if not match:
410
+ available = ", ".join(_format_agent_entry(item) for item in items) or "none"
411
+ return f"Agent profile '{desired}' was not found. Available agents: {available}"
412
+
413
+ profile = str(match["key"])
414
+ if profile == current:
415
+ return f"Already using agent {match.get('label') or profile}."
416
+
417
+ config = initialize_agent(override_settings={"agent_profile": profile})
418
+ context.config = config
419
+ agent = context.agent0
420
+ while agent:
421
+ agent.config = config
422
+ agent = agent.get_data(Agent.DATA_NAME_SUBORDINATE)
423
+ save_tmp_chat(context)
424
+ mark_dirty_for_context(context.id, reason="integration_commands.agent_set")
425
+ return f"Switched agent to {match.get('label') or profile}."
426
427
428
def _format_project_entry(item: dict) -> str:
@@ -170,6 +433,19 @@ def _format_project_entry(item: dict) -> str:
433
return name or title
434
435
436
+def _format_agent_entry(item: dict) -> str:
437
+ key = str(item.get("key", "") or "").strip()
438
+ label = str(item.get("label", "") or "").strip()
439
+ if label and label.lower() != key.lower():
440
+ return f"{label} ({key})"
441
+ return key or label
442
+
443
+
444
+def _telegram_description(command: IntegrationCommandDef) -> str:
445
+ description = command.description.strip()
446
+ return description[:255] if len(description) > 255 else description
447
+
448
+
449
def _describe_project(items: list[dict], current_name: str) -> str:
450
if not current_name:
451
return "none"
@@ -201,6 +477,20 @@ def _normalize_lookup(value: str) -> str:
477
return lowered.strip()
478
479
480
+def _get_toggle(context: "AgentContext", key: str) -> bool:
481
+ value = context.get_data(key)
482
+ return True if value is None else bool(value)
483
+
484
+
485
+def _parse_toggle(args: str) -> bool | None:
486
+ value = _normalize_lookup(args)
487
+ if value in {"on", "enable", "enabled", "yes", "true", "1"}:
488
+ return True
489
+ if value in {"off", "disable", "disabled", "no", "false", "0"}:
490
+ return False
491
+ return None
492
+
493
+
494
def _match_named_item(
495
items: list[dict],
496
desired: str,
plugins/_telegram_integration/README.md
+20
-4
@@ -16,10 +16,16 @@ This plugin connects one or more Telegram bots to Agent Zero. Each bot runs inde
16
- Supports both long-polling and webhook delivery modes.
17
- **Per-user chat sessions**
18
- Each Telegram user gets a dedicated `AgentContext`, persisted across restarts via a JSON state file.
19
- - `/start` creates a context; `/clear` resets it.
19
+ - `/start` creates a context; `/new` starts fresh; `/clear` resets the current context.
20
+ - `/commands` shows the shared integration command menu (`/help` is an alias).
21
+ - `/status` shows project, model, agent profile, and queue state.
22
- `/project <name>` switches the active project for the current chat.
21
- - `/config <preset>` switches the active model preset for the current chat.
22
- - `/send` or `/queue send` flushes the queued messages for the current chat.
23
+ - `/model <preset>` switches the active model preset for the current chat (`/config` remains an alias).
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
+ - `/send` or `/queue send` flushes queued messages for the current chat.
28
+ - `/steer <message>` sends an intervention to the active run.
29
- **Group support**
30
- Three modes: `mention` (respond only when @mentioned or replied to), `all` (respond to every message), `off` (private only).
31
- Optional welcome message for new members.
@@ -27,6 +33,8 @@ This plugin connects one or more Telegram bots to Agent Zero. Each bot runs inde
33
- Extracts text, captions, locations, contacts, stickers, and attachment indicators.
34
- Downloads photos, documents, audio, voice, and video into `usr/uploads/` with configurable auto-cleanup.
35
- **Reply delivery**
36
+ - Streams tool progress and response text through real Telegram messages updated with `editMessageText`.
37
+ - Tool progress and the AI response are separate messages; only the AI response replies to the user message.
38
- `tool_execute_after` intercepts the `response` tool — sends inline progress for `break_loop=false`.
39
- `process_chain_end` auto-sends the final response, with retry logic on failure.
40
- **Formatting**
@@ -36,8 +44,11 @@ This plugin connects one or more Telegram bots to Agent Zero. Each bot runs inde
44
- Agent can attach a `keyboard` array to the `response` tool; button presses feed back as user messages.
45
- **Typing indicator**
46
- Persistent "typing…" action while the agent is processing, cancelled on reply.
47
+- **Busy-run queue**
48
+ - Normal user messages received while a run is active are queued automatically.
49
+ - `/steer <message>` is still delivered immediately as an intervention.
50
- **Notifications**
40
- - Optional WebUI notifications for incoming Telegram messages and `/clear` events.
51
+ - Optional WebUI notifications for incoming Telegram messages.
52
- **Access control**
53
- Per-bot allow-list by Telegram user ID or @username. Empty list = open to all.
54
- **Project binding**
@@ -50,8 +61,13 @@ This plugin connects one or more Telegram bots to Agent Zero. Each bot runs inde
61
- `helpers/handler.py` — Central message routing, context lifecycle, user auth, attachment download, reply sending, typing indicator.
62
- `helpers/bot_manager.py` — Bot creation, polling/webhook lifecycle, bot registry.
63
- `helpers/telegram_client.py` — Low-level Telegram API wrapper: send text/file/photo, Markdown→HTML converter, keyboard builder, message splitting.
64
+ - `helpers/command_ui.py` — Telegram inline keyboard command pickers and callback handling.
65
+ - `helpers/draft_stream.py` — Editable Telegram message streaming for tool progress and response previews.
66
- **Extensions**
67
- `extensions/python/job_loop/_10_telegram_bot.py` — Bot lifecycle manager, starts/stops bots on each tick.
68
+ - `extensions/python/message_loop_start/_45_telegram_draft_start.py` — Initializes Telegram response streaming.
69
+ - `extensions/python/tool_execute_before/_45_telegram_draft_tool.py` — Streams tool-start progress.
70
+ - `extensions/python/response_stream/_45_telegram_draft_response.py` — Streams final response previews.
71
- `extensions/python/system_prompt/_20_telegram_context.py` — Injects Telegram-specific system prompt.
72
- `extensions/python/tool_execute_after/_50_telegram_response.py` — Intercepts `response` tool for inline delivery.
73
- `extensions/python/process_chain_end/_55_telegram_reply.py` — Auto-sends final reply with retry.
plugins/_telegram_integration/extensions/python/job_loop/_10_telegram_bot.py
+2
-3
@@ -31,13 +31,13 @@ class TelegramBotManager(Extension):
31
get_all_bots,
32
create_bot,
33
cache_bot_info,
34
+ register_bot_commands,
35
start_polling,
36
setup_webhook,
37
stop_bot,
38
)
39
from plugins._telegram_integration.helpers.handler import (
40
handle_start,
40
- handle_clear,
41
handle_message,
42
handle_callback_query,
43
handle_new_members,
@@ -73,7 +73,6 @@ class TelegramBotManager(Extension):
73
try:
74
# Create handler closures that capture bot_name and config
75
_on_start = partial(_make_handler(handle_start), bot_name=name, bot_cfg=bot_cfg)
76
- _on_clear = partial(_make_handler(handle_clear), bot_name=name, bot_cfg=bot_cfg)
76
_on_message = partial(_make_handler(handle_message), bot_name=name, bot_cfg=bot_cfg)
77
_on_callback = partial(_make_handler(handle_callback_query), bot_name=name, bot_cfg=bot_cfg)
78
_on_new_members = partial(_make_handler(handle_new_members), bot_name=name, bot_cfg=bot_cfg)
@@ -83,7 +82,6 @@ class TelegramBotManager(Extension):
82
token=bot_cfg["token"],
83
on_message=_on_message,
84
on_command_start=_on_start,
86
- on_command_clear=_on_clear,
85
on_command_control=_on_message,
86
on_callback_query=_on_callback,
87
on_new_members=_on_new_members,
@@ -91,6 +89,7 @@ class TelegramBotManager(Extension):
89
)
90
91
await cache_bot_info(instance)
92
+ await register_bot_commands(instance)
93
94
mode = bot_cfg.get("mode", "polling")
95
if mode == "webhook":
plugins/_telegram_integration/extensions/python/message_loop_start/_45_telegram_draft_start.py
new
+17
@@ -0,0 +1,17 @@
1
+from agent import LoopData
2
+from helpers.extension import Extension
3
+from plugins._telegram_integration.helpers.constants import CTX_TG_BOT
4
+
5
+
6
+class TelegramDraftStart(Extension):
7
+
8
+ async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
9
+ if not self.agent or self.agent.number != 0:
10
+ return
11
+ context = self.agent.context
12
+ if not context.data.get(CTX_TG_BOT):
13
+ return
14
+
15
+ from plugins._telegram_integration.helpers import draft_stream
16
+
17
+ await draft_stream.start(context)
plugins/_telegram_integration/extensions/python/process_chain_end/_55_telegram_reply.py
+3
@@ -41,6 +41,9 @@ class TelegramAutoReply(Extension):
41
typing_stop = context.data.pop(CTX_TG_TYPING_STOP, None)
42
if typing_stop:
43
typing_stop.set()
44
+ from plugins._telegram_integration.helpers import draft_stream
45
+
46
+ draft_stream.clear(context)
47
context.data.pop(CTX_TG_REPLY_TO, None)
48
49
async def _send_reply(
plugins/_telegram_integration/extensions/python/response_stream/_45_telegram_draft_response.py
new
+30
@@ -0,0 +1,30 @@
1
+from agent import LoopData
2
+from helpers.extension import Extension
3
+from plugins._telegram_integration.helpers.constants import CTX_TG_BOT
4
+
5
+
6
+class TelegramDraftResponse(Extension):
7
+
8
+ async def execute(
9
+ self,
10
+ loop_data: LoopData = LoopData(),
11
+ parsed: dict | None = None,
12
+ **kwargs,
13
+ ):
14
+ if not self.agent or self.agent.number != 0:
15
+ return
16
+ context = self.agent.context
17
+ if not context.data.get(CTX_TG_BOT):
18
+ return
19
+
20
+ parsed = parsed or {}
21
+ if parsed.get("tool_name") != "response":
22
+ return
23
+ tool_args = parsed.get("tool_args") or {}
24
+ text = tool_args.get("text") or tool_args.get("message") or ""
25
+ if not text:
26
+ return
27
+
28
+ from plugins._telegram_integration.helpers import draft_stream
29
+
30
+ await draft_stream.update_response(context, str(text))
plugins/_telegram_integration/extensions/python/system_prompt/_20_telegram_context.py
+17
@@ -1,5 +1,6 @@
1
from helpers.extension import Extension
2
from agent import LoopData
3
+from helpers import integration_commands
4
from plugins._telegram_integration.helpers.constants import CTX_TG_BOT, CTX_TG_BOT_CFG
5
6
@@ -18,6 +19,7 @@ class TelegramContextPrompt(Extension):
19
system_prompt.append(
20
self.agent.read_prompt("fw.telegram.system_context_reply.md")
21
)
22
+ system_prompt.append(_telegram_commands_prompt())
23
24
# Inject per-bot agent instructions (once in system prompt)
25
bot_cfg = self.agent.context.data.get(CTX_TG_BOT_CFG, {})
@@ -29,3 +31,18 @@ class TelegramContextPrompt(Extension):
31
instructions=instructions,
32
)
33
)
34
+
35
+
36
+def _telegram_commands_prompt() -> str:
37
+ lines = [
38
+ "Telegram slash commands are handled by the integration before you see the message.",
39
+ "Do not invent command help, unknown-command replies, or pretend to execute slash commands.",
40
+ "If the user asks what commands exist, refer them to /commands.",
41
+ "Current integration commands:",
42
+ ]
43
+ for name in integration_commands.command_names(include_aliases=False):
44
+ definition = integration_commands.resolve_command(name)
45
+ if definition:
46
+ args = f" {definition.args_hint}" if definition.args_hint else ""
47
+ lines.append(f"- /{definition.name}{args}: {definition.description}")
48
+ return "\n".join(lines)
plugins/_telegram_integration/extensions/python/tool_execute_after/_50_telegram_response.py
+6
-2
@@ -13,14 +13,18 @@ class TelegramResponseIntercept(Extension):
13
async def execute(
14
self, tool_name: str = "", response: Response | None = None, **kwargs,
15
):
16
- if tool_name != "response":
17
- return
16
if not self.agent:
17
return
18
context = self.agent.context
19
if not context.data.get(CTX_TG_BOT):
20
return
21
22
+ from plugins._telegram_integration.helpers import draft_stream
23
+
24
+ if tool_name != "response":
25
+ await draft_stream.add_tool_done(context, tool_name, ok=response is not None)
26
+ return
27
+
28
tool = self.agent.loop_data.current_tool
29
if not tool:
30
return
plugins/_telegram_integration/extensions/python/tool_execute_before/_45_telegram_draft_tool.py
new
+18
@@ -0,0 +1,18 @@
1
+from helpers.extension import Extension
2
+from plugins._telegram_integration.helpers.constants import CTX_TG_BOT
3
+
4
+
5
+class TelegramDraftToolStart(Extension):
6
+
7
+ async def execute(self, tool_name: str = "", tool_args: dict | None = None, **kwargs):
8
+ if not self.agent or self.agent.number != 0:
9
+ return
10
+ if (tool_name or "").strip().lower() == "response":
11
+ return
12
+ context = self.agent.context
13
+ if not context.data.get(CTX_TG_BOT):
14
+ return
15
+
16
+ from plugins._telegram_integration.helpers import draft_stream
17
+
18
+ await draft_stream.add_tool_start(context, tool_name, tool_args or {})
plugins/_telegram_integration/helpers/bot_manager.py
+18
-4
@@ -6,7 +6,8 @@ from aiogram import Bot, Dispatcher, Router, F
6
from aiogram.client.default import DefaultBotProperties
7
from aiogram.enums import ParseMode, ChatType, ContentType
8
from aiogram.filters import Command, CommandStart
9
-from aiogram.types import Message
9
+from aiogram.types import BotCommand, Message
10
+from helpers import integration_commands
11
12
from helpers.errors import format_error
13
from helpers.print_style import PrintStyle
@@ -44,7 +45,6 @@ def create_bot(
45
token: str,
46
on_message: Callable[..., Awaitable],
47
on_command_start: Callable[..., Awaitable],
47
- on_command_clear: Callable[..., Awaitable],
48
on_command_control: Callable[..., Awaitable] | None = None,
49
on_callback_query: Callable[..., Awaitable] | None = None,
50
on_new_members: Callable[..., Awaitable] | None = None,
@@ -56,11 +56,10 @@ def create_bot(
56
57
# Register command handlers
58
router.message.register(on_command_start, CommandStart())
59
- router.message.register(on_command_clear, Command("clear"))
59
if on_command_control:
60
router.message.register(
61
on_command_control,
63
- Command(commands=["project", "config", "preset", "queue", "send"]),
62
+ Command(commands=integration_commands.command_names()),
63
)
64
65
if on_callback_query:
@@ -93,6 +92,21 @@ def create_bot(
92
return instance
93
94
95
+async def register_bot_commands(instance: BotInstance) -> None:
96
+ """Register Telegram's native / command menu from the shared integration registry."""
97
+ commands = [
98
+ BotCommand(command=name, description=description)
99
+ for name, description in integration_commands.telegram_menu_commands()
100
+ ]
101
+ if not commands:
102
+ return
103
+ try:
104
+ await instance.bot.set_my_commands(commands)
105
+ PrintStyle.info(f"Telegram ({instance.name}): registered {len(commands)} bot commands")
106
+ except Exception as e:
107
+ PrintStyle.error(f"Telegram ({instance.name}): failed to register bot commands: {format_error(e)}")
108
+
109
+
110
async def cache_bot_info(instance: BotInstance):
111
"""Fetch and cache bot info. Call after create_bot."""
112
if not instance.bot_info:
plugins/_telegram_integration/helpers/command_ui.py
new
+401
@@ -0,0 +1,401 @@
1
+from __future__ import annotations
2
+
3
+from dataclasses import dataclass
4
+
5
+from agent import AgentContext
6
+from helpers import projects, subagents
7
+from helpers import integration_commands
8
+from helpers.persist_chat import save_tmp_chat
9
+from helpers.state_monitor_integration import mark_dirty_for_context
10
+from plugins._model_config.helpers import model_config
11
+from plugins._telegram_integration.helpers import telegram_client as tc
12
+from plugins._telegram_integration.helpers.constants import (
13
+ CTX_TG_STREAM_ENABLED,
14
+ CTX_TG_TOOLS_ENABLED,
15
+)
16
+
17
+PAGE_SIZE = 8
18
+CALLBACK_PREFIX = "tg"
19
+
20
+
21
+@dataclass(frozen=True)
22
+class PickerItem:
23
+ key: str
24
+ label: str
25
+
26
+
27
+async def handle_command(
28
+ context: AgentContext,
29
+ token: str,
30
+ chat_id: int,
31
+ reply_to_message_id: int | None,
32
+ text: str,
33
+) -> bool:
34
+ parsed = integration_commands.parse_command(text or "")
35
+ if not parsed:
36
+ return False
37
+ command, args = parsed
38
+ if command in {"/model", "/config", "/preset"} and not args:
39
+ await send_model_picker(context, token, chat_id, reply_to_message_id, 0)
40
+ return True
41
+ if command == "/project" and not args:
42
+ await send_project_picker(context, token, chat_id, reply_to_message_id, 0)
43
+ return True
44
+ if command in {"/agent", "/profile"} and not args:
45
+ await send_agent_picker(context, token, chat_id, reply_to_message_id, 0)
46
+ return True
47
+ if command == "/stream":
48
+ await send_toggle_picker(
49
+ context,
50
+ token,
51
+ chat_id,
52
+ reply_to_message_id,
53
+ CTX_TG_STREAM_ENABLED,
54
+ "Response streaming",
55
+ args,
56
+ )
57
+ return True
58
+ if command == "/tools":
59
+ await send_toggle_picker(
60
+ context,
61
+ token,
62
+ chat_id,
63
+ reply_to_message_id,
64
+ CTX_TG_TOOLS_ENABLED,
65
+ "Tool progress",
66
+ args,
67
+ )
68
+ return True
69
+ return False
70
+
71
+
72
+async def handle_callback(
73
+ context: AgentContext,
74
+ token: str,
75
+ chat_id: int,
76
+ message_id: int,
77
+ data: str,
78
+) -> bool:
79
+ parts = (data or "").split(":")
80
+ if len(parts) < 3 or parts[0] != CALLBACK_PREFIX:
81
+ return False
82
+ kind, action = parts[1], parts[2]
83
+ value = parts[3] if len(parts) > 3 else ""
84
+ if action == "noop":
85
+ return True
86
+ if action == "page":
87
+ page = _safe_int(value)
88
+ if kind == "model":
89
+ await edit_model_picker(context, token, chat_id, message_id, page)
90
+ elif kind == "project":
91
+ await edit_project_picker(context, token, chat_id, message_id, page)
92
+ elif kind == "agent":
93
+ await edit_agent_picker(context, token, chat_id, message_id, page)
94
+ return True
95
+ if kind == "model" and action in {"set", "clear"}:
96
+ await _select_model(context, _safe_int(value), clear=(action == "clear"))
97
+ await edit_model_picker(context, token, chat_id, message_id, 0, selected=True)
98
+ return True
99
+ if kind == "project" and action in {"set", "clear"}:
100
+ await _select_project(context, _safe_int(value), clear=(action == "clear"))
101
+ await edit_project_picker(context, token, chat_id, message_id, 0, selected=True)
102
+ return True
103
+ if kind == "agent" and action == "set":
104
+ await _select_agent(context, _safe_int(value))
105
+ await edit_agent_picker(context, token, chat_id, message_id, 0, selected=True)
106
+ return True
107
+ if kind in {"stream", "tools"} and action in {"on", "off"}:
108
+ key = CTX_TG_STREAM_ENABLED if kind == "stream" else CTX_TG_TOOLS_ENABLED
109
+ label = "Response streaming" if kind == "stream" else "Tool progress"
110
+ context.set_data(key, action == "on")
111
+ save_tmp_chat(context)
112
+ mark_dirty_for_context(context.id, reason=f"telegram.{kind}_toggle")
113
+ await edit_toggle_picker(context, token, chat_id, message_id, key, label)
114
+ return True
115
+ return True
116
+
117
+
118
+async def send_model_picker(
119
+ context: AgentContext,
120
+ token: str,
121
+ chat_id: int,
122
+ reply_to_message_id: int | None,
123
+ page: int,
124
+) -> None:
125
+ text, markup = _model_view(context, page)
126
+ await tc.raw_send_text(token, chat_id, text, reply_to_message_id, "HTML", markup)
127
+
128
+
129
+async def edit_model_picker(
130
+ context: AgentContext,
131
+ token: str,
132
+ chat_id: int,
133
+ message_id: int,
134
+ page: int,
135
+ *,
136
+ selected: bool = False,
137
+) -> None:
138
+ text, markup = _model_view(context, page, selected=selected)
139
+ await tc.raw_edit_text(token, chat_id, message_id, text, "HTML", markup)
140
+
141
+
142
+async def send_project_picker(
143
+ context: AgentContext,
144
+ token: str,
145
+ chat_id: int,
146
+ reply_to_message_id: int | None,
147
+ page: int,
148
+) -> None:
149
+ text, markup = _project_view(context, page)
150
+ await tc.raw_send_text(token, chat_id, text, reply_to_message_id, "HTML", markup)
151
+
152
+
153
+async def edit_project_picker(
154
+ context: AgentContext,
155
+ token: str,
156
+ chat_id: int,
157
+ message_id: int,
158
+ page: int,
159
+ *,
160
+ selected: bool = False,
161
+) -> None:
162
+ text, markup = _project_view(context, page, selected=selected)
163
+ await tc.raw_edit_text(token, chat_id, message_id, text, "HTML", markup)
164
+
165
+
166
+async def send_agent_picker(
167
+ context: AgentContext,
168
+ token: str,
169
+ chat_id: int,
170
+ reply_to_message_id: int | None,
171
+ page: int,
172
+) -> None:
173
+ text, markup = _agent_view(context, page)
174
+ await tc.raw_send_text(token, chat_id, text, reply_to_message_id, "HTML", markup)
175
+
176
+
177
+async def edit_agent_picker(
178
+ context: AgentContext,
179
+ token: str,
180
+ chat_id: int,
181
+ message_id: int,
182
+ page: int,
183
+ *,
184
+ selected: bool = False,
185
+) -> None:
186
+ text, markup = _agent_view(context, page, selected=selected)
187
+ await tc.raw_edit_text(token, chat_id, message_id, text, "HTML", markup)
188
+
189
+
190
+async def send_toggle_picker(
191
+ context: AgentContext,
192
+ token: str,
193
+ chat_id: int,
194
+ reply_to_message_id: int | None,
195
+ key: str,
196
+ label: str,
197
+ args: str = "",
198
+) -> None:
199
+ desired = _parse_toggle(args)
200
+ if desired is not None:
201
+ context.set_data(key, desired)
202
+ save_tmp_chat(context)
203
+ mark_dirty_for_context(context.id, reason=f"telegram.{key}")
204
+ text, markup = _toggle_view(context, key, label)
205
+ await tc.raw_send_text(token, chat_id, text, reply_to_message_id, "HTML", markup)
206
+
207
+
208
+async def edit_toggle_picker(
209
+ context: AgentContext,
210
+ token: str,
211
+ chat_id: int,
212
+ message_id: int,
213
+ key: str,
214
+ label: str,
215
+) -> None:
216
+ text, markup = _toggle_view(context, key, label)
217
+ await tc.raw_edit_text(token, chat_id, message_id, text, "HTML", markup)
218
+
219
+
220
+def _model_view(
221
+ context: AgentContext,
222
+ page: int,
223
+ *,
224
+ selected: bool = False,
225
+) -> tuple[str, dict | None]:
226
+ presets = [
227
+ PickerItem(str(preset.get("name", "")), str(preset.get("name", "")))
228
+ for preset in model_config.get_presets()
229
+ if isinstance(preset, dict) and preset.get("name")
230
+ ]
231
+ current = context.get_data("chat_model_override")
232
+ current_name = current.get("preset_name") if isinstance(current, dict) else ""
233
+ status = f"Current model: <b>{_html(current_name or 'Default')}</b>"
234
+ if not model_config.is_chat_override_allowed(context.agent0):
235
+ return status + "\nPer-chat model switching is disabled.", None
236
+ if selected:
237
+ status = "Model updated.\n" + status
238
+ rows = _paged_buttons("model", presets, page, current_name)
239
+ rows.append([{"text": "Default", "callback_data": "tg:model:clear"}])
240
+ return status, {"inline_keyboard": rows}
241
+
242
+
243
+def _project_view(
244
+ context: AgentContext,
245
+ page: int,
246
+ *,
247
+ selected: bool = False,
248
+) -> tuple[str, dict | None]:
249
+ items = [
250
+ PickerItem(str(item.get("name", "")), str(item.get("title") or item.get("name") or ""))
251
+ for item in projects.get_active_projects_list() or []
252
+ if item.get("name")
253
+ ]
254
+ current = context.get_data("project") or ""
255
+ status = f"Current project: <b>{_html(_label_for(items, current) or 'none')}</b>"
256
+ if selected:
257
+ status = "Project updated.\n" + status
258
+ rows = _paged_buttons("project", items, page, current)
259
+ rows.append([{"text": "No project", "callback_data": "tg:project:clear"}])
260
+ return status, {"inline_keyboard": rows}
261
+
262
+
263
+def _agent_view(
264
+ context: AgentContext,
265
+ page: int,
266
+ *,
267
+ selected: bool = False,
268
+) -> tuple[str, dict | None]:
269
+ items = [
270
+ PickerItem(str(item.get("key", "")), str(item.get("label") or item.get("key") or ""))
271
+ for item in subagents.get_all_agents_list()
272
+ if item.get("key")
273
+ ]
274
+ current = getattr(context.agent0.config, "profile", "") or "agent0"
275
+ status = f"Current agent: <b>{_html(_label_for(items, current) or current)}</b>"
276
+ if context.is_running():
277
+ status += "\nAgent profile can be changed after the current run finishes."
278
+ elif selected:
279
+ status = "Agent updated.\n" + status
280
+ rows = _paged_buttons("agent", items, page, current, disabled=context.is_running())
281
+ if not rows:
282
+ return status + "\nNo agent profiles were found.", None
283
+ return status, {"inline_keyboard": rows}
284
+
285
+
286
+def _toggle_view(context: AgentContext, key: str, label: str) -> tuple[str, dict]:
287
+ enabled = _toggle_enabled(context, key)
288
+ kind = "stream" if key == CTX_TG_STREAM_ENABLED else "tools"
289
+ state = "enabled" if enabled else "disabled"
290
+ text = f"{_html(label)}: <b>{state}</b>"
291
+ rows = [[
292
+ {"text": ("On" if enabled else "Turn on"), "callback_data": f"tg:{kind}:on"},
293
+ {"text": ("Off" if not enabled else "Turn off"), "callback_data": f"tg:{kind}:off"},
294
+ ]]
295
+ return text, {"inline_keyboard": rows}
296
+
297
+
298
+async def _select_model(context: AgentContext, index: int, *, clear: bool = False) -> None:
299
+ if not model_config.is_chat_override_allowed(context.agent0):
300
+ return
301
+ if clear:
302
+ context.set_data("chat_model_override", None)
303
+ else:
304
+ presets = [preset for preset in model_config.get_presets() if preset.get("name")]
305
+ if index < 0 or index >= len(presets):
306
+ return
307
+ context.set_data("chat_model_override", {"preset_name": presets[index]["name"]})
308
+ save_tmp_chat(context)
309
+ mark_dirty_for_context(context.id, reason="telegram.model_select")
310
+
311
+
312
+async def _select_project(context: AgentContext, index: int, *, clear: bool = False) -> None:
313
+ if clear:
314
+ projects.deactivate_project(context.id)
315
+ return
316
+ items = [item for item in projects.get_active_projects_list() or [] if item.get("name")]
317
+ if index < 0 or index >= len(items):
318
+ return
319
+ projects.activate_project(context.id, str(items[index]["name"]))
320
+
321
+
322
+async def _select_agent(context: AgentContext, index: int) -> None:
323
+ if context.is_running():
324
+ return
325
+ from agent import Agent
326
+ from initialize import initialize_agent
327
+
328
+ items = [item for item in subagents.get_all_agents_list() if item.get("key")]
329
+ if index < 0 or index >= len(items):
330
+ return
331
+ profile = str(items[index]["key"])
332
+ config = initialize_agent(override_settings={"agent_profile": profile})
333
+ context.config = config
334
+ agent = context.agent0
335
+ while agent:
336
+ agent.config = config
337
+ agent = agent.get_data(Agent.DATA_NAME_SUBORDINATE)
338
+ save_tmp_chat(context)
339
+ mark_dirty_for_context(context.id, reason="telegram.agent_select")
340
+
341
+
342
+def _paged_buttons(
343
+ kind: str,
344
+ items: list[PickerItem],
345
+ page: int,
346
+ current: str,
347
+ *,
348
+ disabled: bool = False,
349
+) -> list[list[dict[str, str]]]:
350
+ page = max(0, page)
351
+ total_pages = max(1, (len(items) + PAGE_SIZE - 1) // PAGE_SIZE)
352
+ page = min(page, total_pages - 1)
353
+ start = page * PAGE_SIZE
354
+ rows: list[list[dict[str, str]]] = []
355
+ for offset, item in enumerate(items[start:start + PAGE_SIZE], start=start):
356
+ marker = "• " if item.key == current else ""
357
+ action = "noop" if disabled else "set"
358
+ rows.append([{
359
+ "text": f"{marker}{item.label}"[:64],
360
+ "callback_data": f"tg:{kind}:{action}:{offset}",
361
+ }])
362
+ nav: list[dict[str, str]] = []
363
+ if page > 0:
364
+ nav.append({"text": "Prev", "callback_data": f"tg:{kind}:page:{page - 1}"})
365
+ if page < total_pages - 1:
366
+ nav.append({"text": "Next", "callback_data": f"tg:{kind}:page:{page + 1}"})
367
+ if nav:
368
+ rows.append(nav)
369
+ return rows
370
+
371
+
372
+def _toggle_enabled(context: AgentContext, key: str) -> bool:
373
+ value = context.get_data(key)
374
+ return True if value is None else bool(value)
375
+
376
+
377
+def _parse_toggle(args: str) -> bool | None:
378
+ value = (args or "").strip().lower()
379
+ if value in {"on", "enable", "enabled", "yes", "true", "1"}:
380
+ return True
381
+ if value in {"off", "disable", "disabled", "no", "false", "0"}:
382
+ return False
383
+ return None
384
+
385
+
386
+def _safe_int(value: str) -> int:
387
+ try:
388
+ return int(value)
389
+ except (TypeError, ValueError):
390
+ return 0
391
+
392
+
393
+def _label_for(items: list[PickerItem], key: str) -> str:
394
+ for item in items:
395
+ if item.key == key:
396
+ return item.label
397
+ return key
398
+
399
+
400
+def _html(value: str) -> str:
401
+ return str(value).replace("&", "&").replace("<", "<").replace(">", ">")
plugins/_telegram_integration/helpers/constants.py
+8
@@ -6,11 +6,19 @@ STATE_FILE = "usr/plugins/_telegram_integration/state.json"
6
CTX_TG_BOT = "telegram_bot"
7
CTX_TG_BOT_CFG = "telegram_bot_cfg"
8
CTX_TG_CHAT_ID = "telegram_chat_id"
9
+CTX_TG_CHAT_TYPE = "telegram_chat_type"
10
CTX_TG_USER_ID = "telegram_user_id"
11
CTX_TG_USERNAME = "telegram_username"
12
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
17
# Transient
18
CTX_TG_ATTACHMENTS = "_telegram_response_attachments"
19
CTX_TG_KEYBOARD = "_telegram_response_keyboard"
20
+CTX_TG_PROGRESS_MESSAGE_ID = "_telegram_progress_message_id"
21
+CTX_TG_PROGRESS_LINES = "_telegram_progress_lines"
22
+CTX_TG_RESPONSE_MESSAGE_ID = "_telegram_response_message_id"
23
+CTX_TG_RESPONSE_TEXT = "_telegram_response_text"
24
+CTX_TG_RESPONSE_LAST_UPDATE = "_telegram_response_last_update"
plugins/_telegram_integration/helpers/draft_stream.py
new
+361
@@ -0,0 +1,361 @@
1
+from __future__ import annotations
2
+
3
+import re
4
+import time
5
+
6
+from agent import AgentContext
7
+from helpers.print_style import PrintStyle
8
+from plugins._telegram_integration.helpers import telegram_client as tc
9
+from plugins._telegram_integration.helpers.bot_manager import get_bot
10
+from plugins._telegram_integration.helpers.constants import (
11
+ CTX_TG_BOT,
12
+ CTX_TG_CHAT_ID,
13
+ CTX_TG_PROGRESS_LINES,
14
+ CTX_TG_PROGRESS_MESSAGE_ID,
15
+ CTX_TG_REPLY_TO,
16
+ CTX_TG_RESPONSE_LAST_UPDATE,
17
+ CTX_TG_RESPONSE_MESSAGE_ID,
18
+ CTX_TG_RESPONSE_TEXT,
19
+ CTX_TG_STREAM_ENABLED,
20
+ CTX_TG_TOOLS_ENABLED,
21
+)
22
+
23
+MAX_STREAM_CHARS: int = 3900
24
+MIN_RESPONSE_UPDATE_SECONDS: float = 1.0
25
+MAX_PROGRESS_LINES: int = 12
26
+
27
+TOOL_EMOJIS: dict[str, str] = {
28
+ "browser": "🌐",
29
+ "code": "⌨️",
30
+ "code_execution_tool": "⌨️",
31
+ "duckduckgo_search": "🔎",
32
+ "file": "📄",
33
+ "knowledge_tool": "📚",
34
+ "memory": "🧠",
35
+ "read_file": "📖",
36
+ "search": "🔎",
37
+ "search_engine": "🔎",
38
+ "search_files": "🔎",
39
+ "skill": "📚",
40
+ "skill_view": "📚",
41
+}
42
+
43
+
44
+async def start(context: AgentContext) -> None:
45
+ # Do not pre-create a placeholder Telegram message. Wait until we have
46
+ # real assistant text so the stream starts with meaningful content.
47
+ if not _stream_enabled(context):
48
+ return
49
+
50
+
51
+async def add_tool_start(
52
+ context: AgentContext,
53
+ tool_name: str,
54
+ args: dict | None = None,
55
+) -> None:
56
+ if not _tools_enabled(context):
57
+ return
58
+ label = _tool_progress_label(tool_name, args or {})
59
+ _append_progress_line(context, f"{_tool_emoji(tool_name)} {label}")
60
+ await _send_progress(context)
61
+
62
+
63
+async def add_tool_done(context: AgentContext, tool_name: str, ok: bool = True) -> None:
64
+ if not _tools_enabled(context):
65
+ return
66
+ if ok:
67
+ return
68
+ _mark_tool_failed(context, tool_name)
69
+ await _send_progress(context)
70
+
71
+
72
+async def update_response(context: AgentContext, response_text: str) -> None:
73
+ if not _stream_enabled(context):
74
+ return
75
+ cleaned = _visible_response_text(response_text)
76
+ if not cleaned:
77
+ return
78
+ context.data[CTX_TG_RESPONSE_TEXT] = response_text or ""
79
+ now = time.time()
80
+ last = float(context.data.get(CTX_TG_RESPONSE_LAST_UPDATE) or 0.0)
81
+ if now - last < MIN_RESPONSE_UPDATE_SECONDS:
82
+ return
83
+ await _update_response_message(context, response_text or "")
84
+ context.data[CTX_TG_RESPONSE_LAST_UPDATE] = now
85
+
86
+
87
+async def finalize_response(
88
+ context: AgentContext,
89
+ response_text: str,
90
+ keyboard: list[list[dict]] | None = None,
91
+) -> bool:
92
+ message_id = context.data.get(CTX_TG_RESPONSE_MESSAGE_ID)
93
+ if not message_id:
94
+ return False
95
+ ok = await _update_response_message(
96
+ context,
97
+ response_text or context.data.get(CTX_TG_RESPONSE_TEXT) or "",
98
+ keyboard=keyboard,
99
+ force=True,
100
+ )
101
+ if ok:
102
+ context.data.pop(CTX_TG_RESPONSE_MESSAGE_ID, None)
103
+ context.data.pop(CTX_TG_RESPONSE_TEXT, None)
104
+ context.data.pop(CTX_TG_RESPONSE_LAST_UPDATE, None)
105
+ return ok
106
+
107
+
108
+def clear(context: AgentContext) -> None:
109
+ for key in (
110
+ CTX_TG_PROGRESS_LINES,
111
+ CTX_TG_PROGRESS_MESSAGE_ID,
112
+ CTX_TG_RESPONSE_MESSAGE_ID,
113
+ CTX_TG_RESPONSE_TEXT,
114
+ CTX_TG_RESPONSE_LAST_UPDATE,
115
+ ):
116
+ context.data.pop(key, None)
117
+
118
+
119
+def _stream_enabled(context: AgentContext) -> bool:
120
+ value = context.get_data(CTX_TG_STREAM_ENABLED)
121
+ return True if value is None else bool(value)
122
+
123
+
124
+def _tools_enabled(context: AgentContext) -> bool:
125
+ value = context.get_data(CTX_TG_TOOLS_ENABLED)
126
+ return True if value is None else bool(value)
127
+
128
+
129
+async def _send_progress(context: AgentContext) -> None:
130
+ bot = _bot_instance(context)
131
+ chat_id = context.data.get(CTX_TG_CHAT_ID)
132
+ if not bot or not chat_id:
133
+ return
134
+ text = "\n".join(context.data.get(CTX_TG_PROGRESS_LINES) or [])
135
+ if not text:
136
+ return
137
+ message_id = context.data.get(CTX_TG_PROGRESS_MESSAGE_ID)
138
+ try:
139
+ if message_id:
140
+ await tc.raw_edit_text(bot.bot.token, int(chat_id), int(message_id), text, parse_mode=None)
141
+ return
142
+ sent_id = await tc.raw_send_text(
143
+ bot.bot.token,
144
+ int(chat_id),
145
+ text,
146
+ parse_mode=None,
147
+ )
148
+ if sent_id:
149
+ context.data[CTX_TG_PROGRESS_MESSAGE_ID] = sent_id
150
+ except Exception as e:
151
+ PrintStyle.debug(f"Telegram progress update failed: {e}")
152
+
153
+
154
+async def _ensure_response_message(context: AgentContext, text: str) -> int | None:
155
+ message_id = context.data.get(CTX_TG_RESPONSE_MESSAGE_ID)
156
+ if message_id:
157
+ return int(message_id)
158
+ html = _format_response(text)
159
+ if not html:
160
+ return None
161
+ bot = _bot_instance(context)
162
+ chat_id = context.data.get(CTX_TG_CHAT_ID)
163
+ if not bot or not chat_id:
164
+ return None
165
+ sent_id = await tc.raw_send_text(
166
+ bot.bot.token,
167
+ int(chat_id),
168
+ html,
169
+ reply_to_message_id=_reply_to(context),
170
+ parse_mode="HTML",
171
+ )
172
+ if sent_id:
173
+ context.data[CTX_TG_RESPONSE_MESSAGE_ID] = sent_id
174
+ context.data[CTX_TG_RESPONSE_LAST_UPDATE] = time.time()
175
+ return sent_id
176
+
177
+
178
+async def _update_response_message(
179
+ context: AgentContext,
180
+ text: str,
181
+ *,
182
+ keyboard: list[list[dict]] | None = None,
183
+ force: bool = False,
184
+) -> bool:
185
+ message_id = await _ensure_response_message(context, text)
186
+ bot = _bot_instance(context)
187
+ chat_id = context.data.get(CTX_TG_CHAT_ID)
188
+ if not message_id or not bot or not chat_id:
189
+ return False
190
+ markup = _keyboard_markup(keyboard)
191
+ html = _format_response(text)
192
+ try:
193
+ ok = await tc.raw_edit_text(
194
+ bot.bot.token,
195
+ int(chat_id),
196
+ int(message_id),
197
+ html,
198
+ parse_mode="HTML",
199
+ reply_markup=markup,
200
+ )
201
+ if ok or force:
202
+ return ok
203
+ return False
204
+ except Exception as e:
205
+ PrintStyle.debug(f"Telegram response update failed: {e}")
206
+ return False
207
+
208
+
209
+def _append_progress_line(context: AgentContext, line: str) -> None:
210
+ lines = list(context.data.get(CTX_TG_PROGRESS_LINES) or [])
211
+ if lines and lines[-1] == line:
212
+ return
213
+ lines.append(line)
214
+ context.data[CTX_TG_PROGRESS_LINES] = lines[-MAX_PROGRESS_LINES:]
215
+
216
+
217
+def _mark_tool_failed(context: AgentContext, tool_name: str) -> None:
218
+ lines = list(context.data.get(CTX_TG_PROGRESS_LINES) or [])
219
+ if not lines:
220
+ return
221
+
222
+ label = _tool_label(tool_name)
223
+ for index in range(len(lines) - 1, -1, -1):
224
+ line = lines[index]
225
+ if _line_matches_tool(line, label):
226
+ _, _, suffix = line.partition(" ")
227
+ lines[index] = f"❌ {suffix or label}"
228
+ context.data[CTX_TG_PROGRESS_LINES] = lines[-MAX_PROGRESS_LINES:]
229
+ return
230
+
231
+ lines.append(f"❌ {label}")
232
+ context.data[CTX_TG_PROGRESS_LINES] = lines[-MAX_PROGRESS_LINES:]
233
+
234
+
235
+def _bot_instance(context: AgentContext):
236
+ bot_name = context.data.get(CTX_TG_BOT)
237
+ return get_bot(bot_name) if bot_name else None
238
+
239
+
240
+def _reply_to(context: AgentContext) -> int | None:
241
+ value = context.data.get(CTX_TG_REPLY_TO)
242
+ try:
243
+ return int(value) if value else None
244
+ except (TypeError, ValueError):
245
+ return None
246
+
247
+
248
+def _tool_label(tool_name: str) -> str:
249
+ value = (tool_name or "tool").replace("_", " ").replace("-", " ").strip()
250
+ return value or "tool"
251
+
252
+
253
+def _line_matches_tool(line: str, label: str) -> bool:
254
+ _, _, suffix = line.partition(" ")
255
+ normalized_suffix = suffix.strip().lower()
256
+ normalized_label = label.strip().lower()
257
+ return normalized_suffix == normalized_label or normalized_suffix.startswith(f"{normalized_label}:")
258
+
259
+
260
+def _tool_progress_label(tool_name: str, args: dict) -> str:
261
+ label = _tool_label(tool_name)
262
+ detail = _tool_detail(tool_name, args)
263
+ return f"{label}: {detail}" if detail else label
264
+
265
+
266
+def _tool_detail(tool_name: str, args: dict) -> str:
267
+ if not isinstance(args, dict) or not args:
268
+ return ""
269
+
270
+ normalized = (tool_name or "").strip().lower()
271
+ if "search" in normalized:
272
+ return _first_arg(args, ("query", "q", "search", "term", "keywords", "pattern"))
273
+ if "browser" in normalized:
274
+ return _first_arg(args, ("url", "link", "query", "action"))
275
+ if "file" in normalized:
276
+ return _first_arg(args, ("path", "file", "filename", "query", "pattern"))
277
+ if "skill" in normalized:
278
+ return _first_arg(args, ("skill", "name", "query", "path"))
279
+
280
+ return _first_arg(args, ("action", "method", "operation"))
281
+
282
+
283
+def _first_arg(args: dict, keys: tuple[str, ...]) -> str:
284
+ for key in keys:
285
+ value = args.get(key)
286
+ if value is None:
287
+ continue
288
+ text = _compact_detail(value)
289
+ if text:
290
+ return text
291
+ return ""
292
+
293
+
294
+def _compact_detail(value: object) -> str:
295
+ if isinstance(value, (list, tuple)):
296
+ parts = [_compact_detail(item) for item in value[:2]]
297
+ text = ", ".join(part for part in parts if part)
298
+ if len(value) > 2:
299
+ text = f"{text}, ..."
300
+ elif isinstance(value, dict):
301
+ return ""
302
+ else:
303
+ text = str(value).strip()
304
+
305
+ text = re.sub(r"\s+", " ", text).strip().strip("\"'")
306
+ if len(text) > 80:
307
+ text = f"{text[:77].rstrip()}..."
308
+ return text
309
+
310
+
311
+def _tool_emoji(tool_name: str) -> str:
312
+ normalized = (tool_name or "").strip().lower()
313
+ for key, emoji in TOOL_EMOJIS.items():
314
+ if key in normalized:
315
+ return emoji
316
+ return "🛠️"
317
+
318
+
319
+def _format_response(text: str) -> str:
320
+ value = _visible_response_text(text)
321
+ if not value:
322
+ return ""
323
+ return tc.md_to_telegram_html(value)
324
+
325
+
326
+def _strip_incomplete_tool_markup(text: str) -> str:
327
+ value = text.lstrip()
328
+ value = re.sub(r"^<[^>\n]{0,80}$", "", value)
329
+ value = re.sub(r"^```(?:json|xml)?\s*$", "", value, flags=re.IGNORECASE)
330
+ return value
331
+
332
+
333
+def _visible_response_text(text: str) -> str:
334
+ value = _trim(text or "")
335
+ return _strip_incomplete_tool_markup(value)
336
+
337
+
338
+def _trim(text: str) -> str:
339
+ if len(text) <= MAX_STREAM_CHARS:
340
+ return text
341
+ return text[-MAX_STREAM_CHARS:]
342
+
343
+
344
+def _keyboard_markup(keyboard: list[list[dict]] | None) -> dict | None:
345
+ if not keyboard:
346
+ return None
347
+ rows: list[list[dict[str, str]]] = []
348
+ for row in keyboard:
349
+ out_row: list[dict[str, str]] = []
350
+ for button in row:
351
+ text = str(button.get("text") or "")[:64]
352
+ if not text:
353
+ continue
354
+ if button.get("url"):
355
+ out_row.append({"text": text, "url": str(button["url"])})
356
+ else:
357
+ data = str(button.get("callback_data", text))
358
+ out_row.append({"text": text, "callback_data": data[:64]})
359
+ if out_row:
360
+ rows.append(out_row)
361
+ return {"inline_keyboard": rows} if rows else None
plugins/_telegram_integration/helpers/handler.py
+100
-65
@@ -21,6 +21,7 @@ from helpers.errors import format_error
21
from initialize import initialize_agent
22
23
from plugins._telegram_integration.helpers import telegram_client as tc
24
+from plugins._telegram_integration.helpers import command_ui
25
from plugins._telegram_integration.helpers.bot_manager import get_bot
26
from plugins._telegram_integration.helpers.constants import (
27
PLUGIN_NAME,
@@ -29,6 +30,7 @@ from plugins._telegram_integration.helpers.constants import (
30
CTX_TG_BOT,
31
CTX_TG_BOT_CFG,
32
CTX_TG_CHAT_ID,
33
+ CTX_TG_CHAT_TYPE,
34
CTX_TG_USER_ID,
35
CTX_TG_USERNAME,
36
CTX_TG_TYPING_STOP,
@@ -141,55 +143,15 @@ async def handle_start(message: TgMessage, bot_name: str, bot_cfg: dict):
143
f"\U0001f44b Hello {user.first_name}! I'm connected to Agent Zero.\n\n"
144
"Send me a message and I'll process it.\n"
145
"Use /clear to reset the conversation.\n"
144
- "Use /project, /config, or /send to control the current chat.",
146
+ "Use /project, /model, /agent, or /send to control the current chat.",
147
parse_mode=None,
148
+ reply_to_message_id=message.message_id,
149
)
150
151
# Ensure a chat context exists
152
await _get_or_create_context(bot_name, bot_cfg, message)
153
154
152
-async def handle_clear(message: TgMessage, bot_name: str, bot_cfg: dict):
153
- """Handle /clear command — reset user's chat context."""
154
- user = message.from_user
155
- if not user:
156
- return
157
-
158
- if not _is_allowed(bot_cfg, user.id, user.username):
159
- return
160
-
161
- key = _map_key(bot_name, user.id, message.chat.id)
162
-
163
- with _chat_map_lock:
164
- state = _load_state()
165
- ctx_id = state.get("chats", {}).get(key)
166
- if ctx_id:
167
- ctx = AgentContext.get(ctx_id)
168
- if ctx:
169
- ctx.reset()
170
- PrintStyle.info(f"Telegram ({bot_name}): cleared chat for user {user.id}")
171
-
172
- instance = get_bot(bot_name)
173
- if instance:
174
- await _send_with_temp_bot(
175
- instance.bot.token, message.chat.id,
176
- "Chat cleared. Send a new message to start fresh.",
177
- parse_mode=None,
178
- )
179
-
180
- # Send notification
181
- if bot_cfg.get("notify_messages", False):
182
- username_str = f"@{user.username}" if user.username else str(user.id)
183
- NotificationManager.send_notification(
184
- type=NotificationType.INFO,
185
- priority=NotificationPriority.NORMAL,
186
- title="Telegram: chat cleared",
187
- message=f"{username_str} cleared their chat via /clear",
188
- display_time=5,
189
- group="telegram",
190
- )
191
-
192
-
155
async def handle_message(message: TgMessage, bot_name: str, bot_cfg: dict):
156
"""Handle incoming user message."""
157
user = message.from_user
@@ -212,26 +174,38 @@ async def handle_message(message: TgMessage, bot_name: str, bot_cfg: dict):
174
parse_mode=None,
175
)
176
return
177
+ context.data[CTX_TG_CHAT_TYPE] = str(message.chat.type or "")
178
+ context.data[CTX_TG_REPLY_TO] = message.message_id
179
+
180
+ if await command_ui.handle_command(
181
+ context,
182
+ instance.bot.token,
183
+ message.chat.id,
184
+ message.message_id,
185
+ text,
186
+ ):
187
+ return
188
189
command_reply = integration_commands.try_handle_command(context, text)
190
if command_reply is not None:
218
- await _send_with_temp_bot(instance.bot.token, message.chat.id, command_reply, parse_mode=None)
191
+ await _send_with_temp_bot(
192
+ instance.bot.token,
193
+ message.chat.id,
194
+ command_reply,
195
+ parse_mode=None,
196
+ reply_to_message_id=message.message_id,
197
+ )
198
+ return
199
+ if integration_commands.extract_command_line(text).startswith("/"):
200
+ command = integration_commands.extract_command_line(text).split(" ", 1)[0]
201
+ await _send_with_temp_bot(
202
+ instance.bot.token,
203
+ message.chat.id,
204
+ integration_commands.unknown_command_text(command),
205
+ parse_mode=None,
206
+ reply_to_message_id=message.message_id,
207
+ )
208
return
220
-
221
- # Start persistent typing indicator (thread-based, works across event loops)
222
- typing_stop = _start_typing(instance.bot.token, message.chat.id)
223
-
224
- # Store stop event so send_telegram_reply can cancel typing
225
- context.data[CTX_TG_TYPING_STOP] = typing_stop
226
-
227
- # In group chats, if user replied to the bot's message, reply to the user's message
228
- reply_to_id = None
229
- if message.chat.type != "private" and instance.bot_info:
230
- if (message.reply_to_message
231
- and message.reply_to_message.from_user
232
- and message.reply_to_message.from_user.id == instance.bot_info.id):
233
- reply_to_id = message.message_id
234
- context.data[CTX_TG_REPLY_TO] = reply_to_id
209
210
# Use temp bot for downloads (cross-event-loop safe)
211
async with _temp_bot(instance.bot.token) as dl_bot:
@@ -245,6 +219,24 @@ async def handle_message(message: TgMessage, bot_name: str, bot_cfg: dict):
219
body=text,
220
)
221
222
+ if context.is_running():
223
+ item = mq.add(context, user_msg, attachments)
224
+ save_tmp_chat(context)
225
+ await _send_with_temp_bot(
226
+ instance.bot.token,
227
+ message.chat.id,
228
+ f"Queued message #{item.get('seq', len(mq.get_queue(context)))}. Use /send to flush queued work, or /steer <message> to interrupt the active run.",
229
+ parse_mode=None,
230
+ reply_to_message_id=message.message_id,
231
+ )
232
+ return
233
+
234
+ # Start persistent typing indicator (thread-based, works across event loops)
235
+ typing_stop = _start_typing(instance.bot.token, message.chat.id)
236
+
237
+ # Store stop event so send_telegram_reply can cancel typing
238
+ context.data[CTX_TG_TYPING_STOP] = typing_stop
239
+
240
msg_id = str(uuid.uuid4())
241
mq.log_user_message(context, user_msg, attachments, message_id=msg_id, source=" (telegram)")
242
context.communicate(UserMessage(
@@ -287,16 +279,40 @@ async def handle_callback_query(query: CallbackQuery, bot_name: str, bot_cfg: di
279
return
280
281
context = await _get_or_create_context_from_user(
290
- bot_name, bot_cfg, user.id, user.username, query.message.chat.id,
282
+ bot_name, bot_cfg, user.id, user.username, query.message.chat.id, str(query.message.chat.type or ""),
283
)
284
if not context:
285
return
286
+ context.data[CTX_TG_REPLY_TO] = query.message.message_id
287
+
288
+ instance = get_bot(bot_name)
289
+ if instance:
290
+ try:
291
+ if await command_ui.handle_callback(
292
+ context,
293
+ instance.bot.token,
294
+ query.message.chat.id,
295
+ query.message.message_id,
296
+ text,
297
+ ):
298
+ return
299
+ except Exception as e:
300
+ PrintStyle.error(f"Telegram callback failed: {format_error(e)}")
301
+ if text.startswith("tg:"):
302
+ return
303
+ if text.startswith("tg:"):
304
+ return
305
306
command_reply = integration_commands.try_handle_command(context, text)
307
if command_reply is not None:
297
- instance = get_bot(bot_name)
308
if instance:
299
- await _send_with_temp_bot(instance.bot.token, query.message.chat.id, command_reply, parse_mode=None)
309
+ await _send_with_temp_bot(
310
+ instance.bot.token,
311
+ query.message.chat.id,
312
+ command_reply,
313
+ parse_mode=None,
314
+ reply_to_message_id=query.message.message_id,
315
+ )
316
return
317
318
agent = context.agent0
@@ -347,7 +363,7 @@ async def _get_or_create_context(
363
if not user:
364
return None
365
return await _get_or_create_context_from_user(
350
- bot_name, bot_cfg, user.id, user.username, message.chat.id,
366
+ bot_name, bot_cfg, user.id, user.username, message.chat.id, str(message.chat.type or ""),
367
)
368
369
@@ -357,6 +373,7 @@ async def _get_or_create_context_from_user(
373
user_id: int,
374
username: str | None,
375
chat_id: int,
376
+ chat_type: str = "",
377
) -> AgentContext | None:
378
key = _map_key(bot_name, user_id, chat_id)
379
@@ -369,6 +386,7 @@ async def _get_or_create_context_from_user(
386
if ctx_id:
387
ctx = AgentContext.get(ctx_id)
388
if ctx:
389
+ ctx.data[CTX_TG_CHAT_TYPE] = chat_type or ctx.data.get(CTX_TG_CHAT_TYPE, "")
390
return ctx
391
# Context was garbage collected, remove stale mapping
392
chats.pop(key, None)
@@ -382,6 +400,7 @@ async def _get_or_create_context_from_user(
400
ctx.data[CTX_TG_BOT] = bot_name
401
ctx.data[CTX_TG_BOT_CFG] = bot_cfg
402
ctx.data[CTX_TG_CHAT_ID] = chat_id
403
+ ctx.data[CTX_TG_CHAT_TYPE] = chat_type
404
ctx.data[CTX_TG_USER_ID] = user_id
405
ctx.data[CTX_TG_USERNAME] = username or ""
406
@@ -512,7 +531,11 @@ async def send_telegram_reply(
531
532
if response_text:
533
html_text = tc.md_to_telegram_html(response_text)
515
- if keyboard:
534
+ from plugins._telegram_integration.helpers import draft_stream
535
+
536
+ if await draft_stream.finalize_response(context, response_text, keyboard):
537
+ pass
538
+ elif keyboard:
539
await tc.send_text_with_keyboard(reply_bot, chat_id, html_text, keyboard, reply_to_message_id=reply_to)
540
else:
541
await tc.send_text(reply_bot, chat_id, html_text, reply_to_message_id=reply_to)
@@ -537,10 +560,22 @@ async def _temp_bot(token: str, **kwargs):
560
await bot.session.close()
561
562
540
-async def _send_with_temp_bot(token: str, chat_id: int, text: str, parse_mode: str | None = None):
563
+async def _send_with_temp_bot(
564
+ token: str,
565
+ chat_id: int,
566
+ text: str,
567
+ parse_mode: str | None = None,
568
+ reply_to_message_id: int | None = None,
569
+):
570
"""Send text using a temporary Bot to avoid cross-event-loop session issues."""
571
async with _temp_bot(token) as bot:
543
- await tc.send_text(bot, chat_id, text, parse_mode=parse_mode)
572
+ await tc.send_text(
573
+ bot,
574
+ chat_id,
575
+ text,
576
+ reply_to_message_id=reply_to_message_id,
577
+ parse_mode=parse_mode,
578
+ )
579
580
581
def _start_typing(token: str, chat_id: int) -> threading.Event:
plugins/_telegram_integration/helpers/telegram_client.py
+84
@@ -1,6 +1,7 @@
1
import os
2
import re
3
4
+import aiohttp
5
from aiogram import Bot
6
from aiogram.exceptions import TelegramBadRequest
7
from aiogram.types import (
@@ -17,6 +18,7 @@ _UNSET = object() # sentinel: "not provided" (lets Bot default apply)
18
# Text messages
19
20
MAX_MESSAGE_LENGTH: int = 4096 # Telegram message length limit
21
+TELEGRAM_API_BASE: str = "https://api.telegram.org"
22
23
24
async def send_text(
@@ -171,6 +173,88 @@ async def send_typing(bot: Bot, chat_id: int):
173
except Exception:
174
pass
175
176
+
177
+async def raw_send_text(
178
+ token: str,
179
+ chat_id: int,
180
+ text: str,
181
+ reply_to_message_id: int | None = None,
182
+ parse_mode: str | None = "HTML",
183
+ reply_markup: dict | None = None,
184
+) -> int | None:
185
+ payload: dict[str, object] = {
186
+ "chat_id": chat_id,
187
+ "text": text[:MAX_MESSAGE_LENGTH],
188
+ }
189
+ if parse_mode:
190
+ payload["parse_mode"] = parse_mode
191
+ if reply_to_message_id:
192
+ payload["reply_parameters"] = {"message_id": int(reply_to_message_id)}
193
+ if reply_markup:
194
+ payload["reply_markup"] = reply_markup
195
+ data = await _raw_post(token, "sendMessage", payload)
196
+ result = data.get("result") if isinstance(data, dict) else None
197
+ if isinstance(result, dict):
198
+ return result.get("message_id")
199
+ return None
200
+
201
+
202
+async def raw_edit_text(
203
+ token: str,
204
+ chat_id: int,
205
+ message_id: int,
206
+ text: str,
207
+ parse_mode: str | None = "HTML",
208
+ reply_markup: dict | None = None,
209
+) -> bool:
210
+ payload: dict[str, object] = {
211
+ "chat_id": chat_id,
212
+ "message_id": message_id,
213
+ "text": text[:MAX_MESSAGE_LENGTH],
214
+ }
215
+ if parse_mode:
216
+ payload["parse_mode"] = parse_mode
217
+ if reply_markup:
218
+ payload["reply_markup"] = reply_markup
219
+ data = await _raw_post(token, "editMessageText", payload)
220
+ if not isinstance(data, dict):
221
+ return False
222
+ if data.get("ok"):
223
+ return True
224
+ description = str(data.get("description") or "").lower()
225
+ return "message is not modified" in description
226
+
227
+
228
+async def raw_edit_reply_markup(
229
+ token: str,
230
+ chat_id: int,
231
+ message_id: int,
232
+ reply_markup: dict | None = None,
233
+) -> bool:
234
+ payload: dict[str, object] = {
235
+ "chat_id": chat_id,
236
+ "message_id": message_id,
237
+ }
238
+ if reply_markup:
239
+ payload["reply_markup"] = reply_markup
240
+ data = await _raw_post(token, "editMessageReplyMarkup", payload)
241
+ return bool(isinstance(data, dict) and data.get("ok"))
242
+
243
+
244
+async def _raw_post(token: str, method: str, payload: dict[str, object]) -> dict:
245
+ url = f"{TELEGRAM_API_BASE}/bot{token}/{method}"
246
+ try:
247
+ timeout = aiohttp.ClientTimeout(total=10)
248
+ async with aiohttp.ClientSession(timeout=timeout) as session:
249
+ async with session.post(url, json=payload) as response:
250
+ data = await response.json(content_type=None)
251
+ if response.status != 200 or not data.get("ok"):
252
+ PrintStyle.debug(f"Telegram {method} failed: {data}")
253
+ return data if isinstance(data, dict) else {}
254
+ except Exception as e:
255
+ PrintStyle.debug(f"Telegram {method} failed: {format_error(e)}")
256
+ return {}
257
+
258
# File download
259
260
async def download_file(
plugins/_whatsapp_integration/helpers/handler.py
+18
@@ -207,6 +207,24 @@ async def _route_to_chat(
207
msg_id = str(uuid.uuid4())
208
media_urls = msg.get("mediaUrls", [])
209
attachments = await _save_incoming_media(media_urls) if media_urls else []
210
+
211
+ if context.is_running():
212
+ item = mq.add(context, user_msg, attachments)
213
+ save_tmp_chat(context)
214
+ port = int((plugins.get_plugin_config(PLUGIN_NAME) or {}).get("bridge_port", 3100))
215
+ base_url = bridge_manager.get_bridge_url(port)
216
+ chat_id = context.data.get(CTX_WA_CHAT_ID, "") or msg.get("chatId", "")
217
+ reply_to = msg.get("messageId", "") if context.data.get(CTX_WA_IS_GROUP) else ""
218
+ await wa_client.send_message(
219
+ base_url,
220
+ chat_id,
221
+ f"Queued message #{item.get('seq', len(mq.get_queue(context)))}. Use /send to flush queued work, or /steer <message> to interrupt the active run.",
222
+ reply_to=reply_to,
223
+ )
224
+ await wa_client.send_typing(base_url, chat_id, paused=True)
225
+ context.data[CTX_WA_TYPING_ACTIVE] = False
226
+ return
227
+
228
mq.log_user_message(
229
context, user_msg, attachments, message_id=msg_id, source=" (whatsapp)",
230
)