From 1bbbfa44acd87b9702176dfb88be410bcd861554 Mon Sep 17 00:00:00 2001 From: Anmol Malik Date: Tue, 26 May 2026 20:18:38 +0530 Subject: [PATCH 001/196] Improve Telegram integration command and streaming UX --- helpers/integration_commands.py | 314 +++++++++++++- plugins/_telegram_integration/README.md | 24 +- .../python/job_loop/_10_telegram_bot.py | 5 +- .../_45_telegram_draft_start.py | 17 + .../process_chain_end/_55_telegram_reply.py | 3 + .../_45_telegram_draft_response.py | 30 ++ .../system_prompt/_20_telegram_context.py | 17 + .../_50_telegram_response.py | 8 +- .../_45_telegram_draft_tool.py | 18 + .../helpers/bot_manager.py | 22 +- .../helpers/command_ui.py | 401 ++++++++++++++++++ .../helpers/constants.py | 8 + .../helpers/draft_stream.py | 361 ++++++++++++++++ .../_telegram_integration/helpers/handler.py | 165 ++++--- .../helpers/telegram_client.py | 84 ++++ .../_whatsapp_integration/helpers/handler.py | 18 + 16 files changed, 1405 insertions(+), 90 deletions(-) create mode 100644 plugins/_telegram_integration/extensions/python/message_loop_start/_45_telegram_draft_start.py create mode 100644 plugins/_telegram_integration/extensions/python/response_stream/_45_telegram_draft_response.py create mode 100644 plugins/_telegram_integration/extensions/python/tool_execute_before/_45_telegram_draft_tool.py create mode 100644 plugins/_telegram_integration/helpers/command_ui.py create mode 100644 plugins/_telegram_integration/helpers/draft_stream.py diff --git a/helpers/integration_commands.py b/helpers/integration_commands.py index d78f3ff6e..655a3cc87 100644 --- a/helpers/integration_commands.py +++ b/helpers/integration_commands.py @@ -1,6 +1,7 @@ from __future__ import annotations import re +from dataclasses import dataclass from typing import TYPE_CHECKING from helpers import message_queue as mq @@ -14,7 +15,83 @@ if TYPE_CHECKING: _CLEAR_VALUES = {"", "default", "none", "clear", "off"} -_SUPPORTED_COMMANDS = {"/send", "/queue", "/project", "/config", "/preset"} + + +@dataclass(frozen=True) +class IntegrationCommandDef: + name: str + description: str + category: str + aliases: tuple[str, ...] = () + args_hint: str = "" + menu: bool = True + + +COMMAND_REGISTRY: tuple[IntegrationCommandDef, ...] = ( + IntegrationCommandDef("commands", "Show all integration commands.", "Info", aliases=("help",)), + IntegrationCommandDef( + "status", + "Show this chat's project, model, agent, and queue state.", + "Info", + ), + IntegrationCommandDef("new", "Start a fresh chat context.", "Session"), + IntegrationCommandDef("clear", "Reset the current chat context.", "Session", aliases=("reset",)), + IntegrationCommandDef( + "queue", + "Show or manage queued messages.", + "Session", + args_hint="[send|clear]", + ), + IntegrationCommandDef("send", "Send queued messages now.", "Session", aliases=("push",)), + IntegrationCommandDef( + "steer", + "Intervene in the currently running task.", + "Session", + args_hint="", + ), + IntegrationCommandDef("pause", "Pause the active run.", "Session"), + IntegrationCommandDef("resume", "Resume a paused run.", "Session"), + IntegrationCommandDef("nudge", "Nudge the active run.", "Session"), + IntegrationCommandDef( + "stream", + "Enable or disable Telegram response streaming.", + "Configuration", + args_hint="[on|off]", + ), + IntegrationCommandDef( + "tools", + "Show or hide Telegram tool progress.", + "Configuration", + args_hint="[on|off]", + ), + IntegrationCommandDef( + "project", + "Show or switch the active project.", + "Configuration", + args_hint="[name|none]", + ), + IntegrationCommandDef( + "model", + "Show or switch the chat model preset.", + "Configuration", + aliases=("config", "preset"), + args_hint="[preset|default]", + ), + IntegrationCommandDef( + "agent", + "Show or switch the agent profile.", + "Configuration", + aliases=("profile",), + args_hint="[profile]", + ), +) + + +_COMMAND_LOOKUP = { + f"/{name}": command + for command in COMMAND_REGISTRY + for name in (command.name, *command.aliases) +} def extract_command_line(text: str) -> str: @@ -32,11 +109,53 @@ def parse_command(text: str) -> tuple[str, str] | None: return None command, _, args = line.partition(" ") - command = command.strip().lower() - if command not in _SUPPORTED_COMMANDS: + command = _normalize_command_token(command) + resolved = resolve_command(command) + if not resolved: return None - return command, args.strip() + return f"/{resolved.name}", args.strip() + + +def resolve_command(command: str) -> IntegrationCommandDef | None: + normalized = _normalize_command_token(command) + if not normalized.startswith("/"): + normalized = f"/{normalized}" + return _COMMAND_LOOKUP.get(normalized) + + +def telegram_menu_commands() -> list[tuple[str, str]]: + return [ + (command.name, _telegram_description(command)) + for command in COMMAND_REGISTRY + if command.menu + ] + + +def command_names(include_aliases: bool = True) -> list[str]: + names: list[str] = [] + for command in COMMAND_REGISTRY: + names.append(command.name) + if include_aliases: + names.extend(command.aliases) + return names + + +def help_text(*, full: bool = False) -> str: + commands = COMMAND_REGISTRY if full else tuple(c for c in COMMAND_REGISTRY if c.menu) + lines = ["Available commands:"] + for command in commands: + args = f" {command.args_hint}" if command.args_hint else "" + alias_text = "" + if command.aliases: + alias_text = f" (alias: {', '.join('/' + alias for alias in command.aliases)})" + lines.append(f"/{command.name}{args} - {command.description}{alias_text}") + return "\n".join(lines) + + +def unknown_command_text(command: str) -> str: + token = _normalize_command_token(command).split(" ", 1)[0] + return f"Unknown command: {token}\n\n{help_text(full=True)}" def try_handle_command(context: "AgentContext", text: str) -> str | None: @@ -45,17 +164,47 @@ def try_handle_command(context: "AgentContext", text: str) -> str | None: return None command, args = parsed + if command == "/commands": + return help_text(full=True) + if command == "/status": + return _handle_status(context) + if command in {"/new", "/clear"}: + return _handle_clear(context, new_chat=(command == "/new")) if command == "/send": return _handle_queue(context, "send") if command == "/queue": return _handle_queue(context, args) + if command == "/steer": + return _handle_steer(context, args) + if command == "/pause": + return _handle_pause(context) + if command == "/resume": + return _handle_resume(context) + if command == "/nudge": + return _handle_nudge(context) + if command == "/stream": + return _handle_toggle(context, args, "telegram_stream_enabled", "Response streaming") + if command == "/tools": + return _handle_toggle(context, args, "telegram_tools_enabled", "Tool progress") if command == "/project": return _handle_project(context, args) - if command in {"/config", "/preset"}: - return _handle_config(context, args) + if command == "/model": + return _handle_model(context, args) + if command == "/agent": + return _handle_agent(context, args) return None +def _normalize_command_token(command: str) -> str: + normalized = command.strip().lower() + if not normalized: + return "" + token, *rest = normalized.split(" ", 1) + if "@" in token: + token = token.split("@", 1)[0] + return f"{token} {rest[0]}".strip() if rest else token + + def _handle_queue(context: "AgentContext", args: str) -> str: queue = mq.get_queue(context) count = len(queue) @@ -68,8 +217,13 @@ def _handle_queue(context: "AgentContext", args: str) -> str: "Use /send or /queue send to send everything as one batch." ) + if action in {"clear", "reset"}: + mq.remove(context) + mark_dirty_for_context(context.id, reason="integration_commands.queue_clear") + return "Queue cleared." + if action not in {"send", "all"}: - return "Unknown queue action. Use /queue send to flush the queue." + return "Unknown queue action. Use /queue send to flush or /queue clear to clear." if count == 0: return "Queue is empty." @@ -80,6 +234,73 @@ def _handle_queue(context: "AgentContext", args: str) -> str: return f"Sent {sent_count} queued {noun} as one batch." +def _handle_status(context: "AgentContext") -> str: + project_name = context.get_data("project") or "none" + override = context.get_data("chat_model_override") + agent_profile = getattr(context.agent0.config, "profile", "default") + running = "running" if context.is_running() else "idle" + if getattr(context, "paused", False): + running = "paused" + queue_count = len(mq.get_queue(context)) + return ( + f"Status: {running}\n" + f"Project: {project_name}\n" + f"Model: {_describe_override(override)}\n" + f"Agent: {agent_profile}\n" + f"Queued messages: {queue_count}" + ) + + +def _handle_clear(context: "AgentContext", *, new_chat: bool) -> str: + context.reset() + mq.remove(context) + save_tmp_chat(context) + reason = "integration_commands.new" if new_chat else "integration_commands.clear" + mark_dirty_for_context(context.id, reason=reason) + return "Started a fresh chat." if new_chat else "Chat cleared." + + +def _handle_steer(context: "AgentContext", args: str) -> str: + message = args.strip() + if not message: + return "Usage: /steer " + from agent import UserMessage + + context.communicate(UserMessage(message=message)) + if context.is_running(): + return "Steering message sent to the active run." + return "Message sent." + + +def _handle_pause(context: "AgentContext") -> str: + if not context.is_running(): + return "No active run is currently running." + context.paused = True + return "Agent paused." + + +def _handle_resume(context: "AgentContext") -> str: + context.paused = False + return "Agent resumed." + + +def _handle_nudge(context: "AgentContext") -> str: + context.nudge() + return "Agent nudged." + + +def _handle_toggle(context: "AgentContext", args: str, key: str, label: str) -> str: + value = _parse_toggle(args) + current = _get_toggle(context, key) + if value is None: + state = "on" if current else "off" + return f"{label}: {state}. Use /{key.split('_')[1]} on or /{key.split('_')[1]} off." + context.set_data(key, value) + save_tmp_chat(context) + mark_dirty_for_context(context.id, reason=f"integration_commands.{key}") + return f"{label} {'enabled' if value else 'disabled'}." + + def _handle_project(context: "AgentContext", args: str) -> str: items = projects.get_active_projects_list() or [] current_name = context.get_data("project") or "" @@ -115,7 +336,7 @@ def _handle_project(context: "AgentContext", args: str) -> str: return f"Switched project to {match.get('title') or match['name']}." -def _handle_config(context: "AgentContext", args: str) -> str: +def _handle_model(context: "AgentContext", args: str) -> str: allowed = model_config.is_chat_override_allowed(context.agent0) presets = [preset for preset in model_config.get_presets() if preset.get("name")] current_override = context.get_data("chat_model_override") @@ -123,12 +344,12 @@ def _handle_config(context: "AgentContext", args: str) -> str: if not args: current_label = _describe_override(current_override) available = ", ".join(preset["name"] for preset in presets) or "none" - suffix = "Use /config to switch, or /config default to clear it." + suffix = "Use /model to switch, or /model default to clear it." if not allowed: suffix = "Per-chat config switching is disabled in Model Configuration." return ( - f"Current config: {current_label}\n" - f"Available configs: {available}\n" + f"Current model: {current_label}\n" + f"Available presets: {available}\n" f"{suffix}" ) @@ -159,7 +380,49 @@ def _handle_config(context: "AgentContext", args: str) -> str: context.set_data("chat_model_override", {"preset_name": preset_name}) save_tmp_chat(context) mark_dirty_for_context(context.id, reason="integration_commands.config_set") - return f"Switched config to {preset_name}." + return f"Switched model preset to {preset_name}." + + +def _handle_agent(context: "AgentContext", args: str) -> str: + from agent import Agent + from helpers import subagents + from initialize import initialize_agent + + items = subagents.get_all_agents_list() + current = getattr(context.agent0.config, "profile", "default") + if not args: + available = ", ".join(_format_agent_entry(item) for item in items) or "none" + return ( + f"Current agent: {current}\n" + f"Available agents: {available}\n" + "Use /agent to switch after the current run finishes." + ) + + if context.is_running(): + return "Agent profile can be changed after the current run finishes." + + desired = _strip_quotes(args) + match, ambiguous = _match_named_item(items, desired, keys=("key", "label")) + if ambiguous: + names = ", ".join(_format_agent_entry(item) for item in ambiguous) + return f"Agent profile is ambiguous. Matches: {names}" + if not match: + available = ", ".join(_format_agent_entry(item) for item in items) or "none" + return f"Agent profile '{desired}' was not found. Available agents: {available}" + + profile = str(match["key"]) + if profile == current: + return f"Already using agent {match.get('label') or profile}." + + config = initialize_agent(override_settings={"agent_profile": profile}) + context.config = config + agent = context.agent0 + while agent: + agent.config = config + agent = agent.get_data(Agent.DATA_NAME_SUBORDINATE) + save_tmp_chat(context) + mark_dirty_for_context(context.id, reason="integration_commands.agent_set") + return f"Switched agent to {match.get('label') or profile}." def _format_project_entry(item: dict) -> str: @@ -170,6 +433,19 @@ def _format_project_entry(item: dict) -> str: return name or title +def _format_agent_entry(item: dict) -> str: + key = str(item.get("key", "") or "").strip() + label = str(item.get("label", "") or "").strip() + if label and label.lower() != key.lower(): + return f"{label} ({key})" + return key or label + + +def _telegram_description(command: IntegrationCommandDef) -> str: + description = command.description.strip() + return description[:255] if len(description) > 255 else description + + def _describe_project(items: list[dict], current_name: str) -> str: if not current_name: return "none" @@ -201,6 +477,20 @@ def _normalize_lookup(value: str) -> str: return lowered.strip() +def _get_toggle(context: "AgentContext", key: str) -> bool: + value = context.get_data(key) + return True if value is None else bool(value) + + +def _parse_toggle(args: str) -> bool | None: + value = _normalize_lookup(args) + if value in {"on", "enable", "enabled", "yes", "true", "1"}: + return True + if value in {"off", "disable", "disabled", "no", "false", "0"}: + return False + return None + + def _match_named_item( items: list[dict], desired: str, diff --git a/plugins/_telegram_integration/README.md b/plugins/_telegram_integration/README.md index 8201ab882..bf486184e 100644 --- a/plugins/_telegram_integration/README.md +++ b/plugins/_telegram_integration/README.md @@ -16,10 +16,16 @@ This plugin connects one or more Telegram bots to Agent Zero. Each bot runs inde - Supports both long-polling and webhook delivery modes. - **Per-user chat sessions** - Each Telegram user gets a dedicated `AgentContext`, persisted across restarts via a JSON state file. - - `/start` creates a context; `/clear` resets it. + - `/start` creates a context; `/new` starts fresh; `/clear` resets the current context. + - `/commands` shows the shared integration command menu (`/help` is an alias). + - `/status` shows project, model, agent profile, and queue state. - `/project ` switches the active project for the current chat. - - `/config ` switches the active model preset for the current chat. - - `/send` or `/queue send` flushes the queued messages for the current chat. + - `/model ` switches the active model preset for the current chat (`/config` remains an alias). + - `/agent ` switches the active agent profile when the current run is idle. + - `/model`, `/project`, and `/agent` show Telegram inline keyboard pickers when used without arguments. + - `/stream` toggles live response streaming; `/tools` toggles tool progress messages. + - `/send` or `/queue send` flushes queued messages for the current chat. + - `/steer ` sends an intervention to the active run. - **Group support** - Three modes: `mention` (respond only when @mentioned or replied to), `all` (respond to every message), `off` (private only). - 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 - Extracts text, captions, locations, contacts, stickers, and attachment indicators. - Downloads photos, documents, audio, voice, and video into `usr/uploads/` with configurable auto-cleanup. - **Reply delivery** + - Streams tool progress and response text through real Telegram messages updated with `editMessageText`. + - Tool progress and the AI response are separate messages; only the AI response replies to the user message. - `tool_execute_after` intercepts the `response` tool — sends inline progress for `break_loop=false`. - `process_chain_end` auto-sends the final response, with retry logic on failure. - **Formatting** @@ -36,8 +44,11 @@ This plugin connects one or more Telegram bots to Agent Zero. Each bot runs inde - Agent can attach a `keyboard` array to the `response` tool; button presses feed back as user messages. - **Typing indicator** - Persistent "typing…" action while the agent is processing, cancelled on reply. +- **Busy-run queue** + - Normal user messages received while a run is active are queued automatically. + - `/steer ` is still delivered immediately as an intervention. - **Notifications** - - Optional WebUI notifications for incoming Telegram messages and `/clear` events. + - Optional WebUI notifications for incoming Telegram messages. - **Access control** - Per-bot allow-list by Telegram user ID or @username. Empty list = open to all. - **Project binding** @@ -50,8 +61,13 @@ This plugin connects one or more Telegram bots to Agent Zero. Each bot runs inde - `helpers/handler.py` — Central message routing, context lifecycle, user auth, attachment download, reply sending, typing indicator. - `helpers/bot_manager.py` — Bot creation, polling/webhook lifecycle, bot registry. - `helpers/telegram_client.py` — Low-level Telegram API wrapper: send text/file/photo, Markdown→HTML converter, keyboard builder, message splitting. + - `helpers/command_ui.py` — Telegram inline keyboard command pickers and callback handling. + - `helpers/draft_stream.py` — Editable Telegram message streaming for tool progress and response previews. - **Extensions** - `extensions/python/job_loop/_10_telegram_bot.py` — Bot lifecycle manager, starts/stops bots on each tick. + - `extensions/python/message_loop_start/_45_telegram_draft_start.py` — Initializes Telegram response streaming. + - `extensions/python/tool_execute_before/_45_telegram_draft_tool.py` — Streams tool-start progress. + - `extensions/python/response_stream/_45_telegram_draft_response.py` — Streams final response previews. - `extensions/python/system_prompt/_20_telegram_context.py` — Injects Telegram-specific system prompt. - `extensions/python/tool_execute_after/_50_telegram_response.py` — Intercepts `response` tool for inline delivery. - `extensions/python/process_chain_end/_55_telegram_reply.py` — Auto-sends final reply with retry. diff --git a/plugins/_telegram_integration/extensions/python/job_loop/_10_telegram_bot.py b/plugins/_telegram_integration/extensions/python/job_loop/_10_telegram_bot.py index 6355a9a96..dff9222ef 100644 --- a/plugins/_telegram_integration/extensions/python/job_loop/_10_telegram_bot.py +++ b/plugins/_telegram_integration/extensions/python/job_loop/_10_telegram_bot.py @@ -31,13 +31,13 @@ class TelegramBotManager(Extension): get_all_bots, create_bot, cache_bot_info, + register_bot_commands, start_polling, setup_webhook, stop_bot, ) from plugins._telegram_integration.helpers.handler import ( handle_start, - handle_clear, handle_message, handle_callback_query, handle_new_members, @@ -73,7 +73,6 @@ class TelegramBotManager(Extension): try: # Create handler closures that capture bot_name and config _on_start = partial(_make_handler(handle_start), bot_name=name, bot_cfg=bot_cfg) - _on_clear = partial(_make_handler(handle_clear), bot_name=name, bot_cfg=bot_cfg) _on_message = partial(_make_handler(handle_message), bot_name=name, bot_cfg=bot_cfg) _on_callback = partial(_make_handler(handle_callback_query), bot_name=name, bot_cfg=bot_cfg) _on_new_members = partial(_make_handler(handle_new_members), bot_name=name, bot_cfg=bot_cfg) @@ -83,7 +82,6 @@ class TelegramBotManager(Extension): token=bot_cfg["token"], on_message=_on_message, on_command_start=_on_start, - on_command_clear=_on_clear, on_command_control=_on_message, on_callback_query=_on_callback, on_new_members=_on_new_members, @@ -91,6 +89,7 @@ class TelegramBotManager(Extension): ) await cache_bot_info(instance) + await register_bot_commands(instance) mode = bot_cfg.get("mode", "polling") if mode == "webhook": diff --git a/plugins/_telegram_integration/extensions/python/message_loop_start/_45_telegram_draft_start.py b/plugins/_telegram_integration/extensions/python/message_loop_start/_45_telegram_draft_start.py new file mode 100644 index 000000000..af773207d --- /dev/null +++ b/plugins/_telegram_integration/extensions/python/message_loop_start/_45_telegram_draft_start.py @@ -0,0 +1,17 @@ +from agent import LoopData +from helpers.extension import Extension +from plugins._telegram_integration.helpers.constants import CTX_TG_BOT + + +class TelegramDraftStart(Extension): + + async def execute(self, loop_data: LoopData = LoopData(), **kwargs): + if not self.agent or self.agent.number != 0: + return + context = self.agent.context + if not context.data.get(CTX_TG_BOT): + return + + from plugins._telegram_integration.helpers import draft_stream + + await draft_stream.start(context) diff --git a/plugins/_telegram_integration/extensions/python/process_chain_end/_55_telegram_reply.py b/plugins/_telegram_integration/extensions/python/process_chain_end/_55_telegram_reply.py index d0730a6dc..dcd28665e 100644 --- a/plugins/_telegram_integration/extensions/python/process_chain_end/_55_telegram_reply.py +++ b/plugins/_telegram_integration/extensions/python/process_chain_end/_55_telegram_reply.py @@ -41,6 +41,9 @@ class TelegramAutoReply(Extension): typing_stop = context.data.pop(CTX_TG_TYPING_STOP, None) if typing_stop: typing_stop.set() + from plugins._telegram_integration.helpers import draft_stream + + draft_stream.clear(context) context.data.pop(CTX_TG_REPLY_TO, None) async def _send_reply( diff --git a/plugins/_telegram_integration/extensions/python/response_stream/_45_telegram_draft_response.py b/plugins/_telegram_integration/extensions/python/response_stream/_45_telegram_draft_response.py new file mode 100644 index 000000000..47e647d36 --- /dev/null +++ b/plugins/_telegram_integration/extensions/python/response_stream/_45_telegram_draft_response.py @@ -0,0 +1,30 @@ +from agent import LoopData +from helpers.extension import Extension +from plugins._telegram_integration.helpers.constants import CTX_TG_BOT + + +class TelegramDraftResponse(Extension): + + async def execute( + self, + loop_data: LoopData = LoopData(), + parsed: dict | None = None, + **kwargs, + ): + if not self.agent or self.agent.number != 0: + return + context = self.agent.context + if not context.data.get(CTX_TG_BOT): + return + + parsed = parsed or {} + if parsed.get("tool_name") != "response": + return + tool_args = parsed.get("tool_args") or {} + text = tool_args.get("text") or tool_args.get("message") or "" + if not text: + return + + from plugins._telegram_integration.helpers import draft_stream + + await draft_stream.update_response(context, str(text)) diff --git a/plugins/_telegram_integration/extensions/python/system_prompt/_20_telegram_context.py b/plugins/_telegram_integration/extensions/python/system_prompt/_20_telegram_context.py index bf580a43c..9d337cf8d 100644 --- a/plugins/_telegram_integration/extensions/python/system_prompt/_20_telegram_context.py +++ b/plugins/_telegram_integration/extensions/python/system_prompt/_20_telegram_context.py @@ -1,5 +1,6 @@ from helpers.extension import Extension from agent import LoopData +from helpers import integration_commands from plugins._telegram_integration.helpers.constants import CTX_TG_BOT, CTX_TG_BOT_CFG @@ -18,6 +19,7 @@ class TelegramContextPrompt(Extension): system_prompt.append( self.agent.read_prompt("fw.telegram.system_context_reply.md") ) + system_prompt.append(_telegram_commands_prompt()) # Inject per-bot agent instructions (once in system prompt) bot_cfg = self.agent.context.data.get(CTX_TG_BOT_CFG, {}) @@ -29,3 +31,18 @@ class TelegramContextPrompt(Extension): instructions=instructions, ) ) + + +def _telegram_commands_prompt() -> str: + lines = [ + "Telegram slash commands are handled by the integration before you see the message.", + "Do not invent command help, unknown-command replies, or pretend to execute slash commands.", + "If the user asks what commands exist, refer them to /commands.", + "Current integration commands:", + ] + for name in integration_commands.command_names(include_aliases=False): + definition = integration_commands.resolve_command(name) + if definition: + args = f" {definition.args_hint}" if definition.args_hint else "" + lines.append(f"- /{definition.name}{args}: {definition.description}") + return "\n".join(lines) diff --git a/plugins/_telegram_integration/extensions/python/tool_execute_after/_50_telegram_response.py b/plugins/_telegram_integration/extensions/python/tool_execute_after/_50_telegram_response.py index 14968c623..43fcbae24 100644 --- a/plugins/_telegram_integration/extensions/python/tool_execute_after/_50_telegram_response.py +++ b/plugins/_telegram_integration/extensions/python/tool_execute_after/_50_telegram_response.py @@ -13,14 +13,18 @@ class TelegramResponseIntercept(Extension): async def execute( self, tool_name: str = "", response: Response | None = None, **kwargs, ): - if tool_name != "response": - return if not self.agent: return context = self.agent.context if not context.data.get(CTX_TG_BOT): return + from plugins._telegram_integration.helpers import draft_stream + + if tool_name != "response": + await draft_stream.add_tool_done(context, tool_name, ok=response is not None) + return + tool = self.agent.loop_data.current_tool if not tool: return diff --git a/plugins/_telegram_integration/extensions/python/tool_execute_before/_45_telegram_draft_tool.py b/plugins/_telegram_integration/extensions/python/tool_execute_before/_45_telegram_draft_tool.py new file mode 100644 index 000000000..4496cd422 --- /dev/null +++ b/plugins/_telegram_integration/extensions/python/tool_execute_before/_45_telegram_draft_tool.py @@ -0,0 +1,18 @@ +from helpers.extension import Extension +from plugins._telegram_integration.helpers.constants import CTX_TG_BOT + + +class TelegramDraftToolStart(Extension): + + async def execute(self, tool_name: str = "", tool_args: dict | None = None, **kwargs): + if not self.agent or self.agent.number != 0: + return + if (tool_name or "").strip().lower() == "response": + return + context = self.agent.context + if not context.data.get(CTX_TG_BOT): + return + + from plugins._telegram_integration.helpers import draft_stream + + await draft_stream.add_tool_start(context, tool_name, tool_args or {}) diff --git a/plugins/_telegram_integration/helpers/bot_manager.py b/plugins/_telegram_integration/helpers/bot_manager.py index b59c68664..757804c21 100644 --- a/plugins/_telegram_integration/helpers/bot_manager.py +++ b/plugins/_telegram_integration/helpers/bot_manager.py @@ -6,7 +6,8 @@ from aiogram import Bot, Dispatcher, Router, F from aiogram.client.default import DefaultBotProperties from aiogram.enums import ParseMode, ChatType, ContentType from aiogram.filters import Command, CommandStart -from aiogram.types import Message +from aiogram.types import BotCommand, Message +from helpers import integration_commands from helpers.errors import format_error from helpers.print_style import PrintStyle @@ -44,7 +45,6 @@ def create_bot( token: str, on_message: Callable[..., Awaitable], on_command_start: Callable[..., Awaitable], - on_command_clear: Callable[..., Awaitable], on_command_control: Callable[..., Awaitable] | None = None, on_callback_query: Callable[..., Awaitable] | None = None, on_new_members: Callable[..., Awaitable] | None = None, @@ -56,11 +56,10 @@ def create_bot( # Register command handlers router.message.register(on_command_start, CommandStart()) - router.message.register(on_command_clear, Command("clear")) if on_command_control: router.message.register( on_command_control, - Command(commands=["project", "config", "preset", "queue", "send"]), + Command(commands=integration_commands.command_names()), ) if on_callback_query: @@ -93,6 +92,21 @@ def create_bot( return instance +async def register_bot_commands(instance: BotInstance) -> None: + """Register Telegram's native / command menu from the shared integration registry.""" + commands = [ + BotCommand(command=name, description=description) + for name, description in integration_commands.telegram_menu_commands() + ] + if not commands: + return + try: + await instance.bot.set_my_commands(commands) + PrintStyle.info(f"Telegram ({instance.name}): registered {len(commands)} bot commands") + except Exception as e: + PrintStyle.error(f"Telegram ({instance.name}): failed to register bot commands: {format_error(e)}") + + async def cache_bot_info(instance: BotInstance): """Fetch and cache bot info. Call after create_bot.""" if not instance.bot_info: diff --git a/plugins/_telegram_integration/helpers/command_ui.py b/plugins/_telegram_integration/helpers/command_ui.py new file mode 100644 index 000000000..0ce9a5ae6 --- /dev/null +++ b/plugins/_telegram_integration/helpers/command_ui.py @@ -0,0 +1,401 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from agent import AgentContext +from helpers import projects, subagents +from helpers import integration_commands +from helpers.persist_chat import save_tmp_chat +from helpers.state_monitor_integration import mark_dirty_for_context +from plugins._model_config.helpers import model_config +from plugins._telegram_integration.helpers import telegram_client as tc +from plugins._telegram_integration.helpers.constants import ( + CTX_TG_STREAM_ENABLED, + CTX_TG_TOOLS_ENABLED, +) + +PAGE_SIZE = 8 +CALLBACK_PREFIX = "tg" + + +@dataclass(frozen=True) +class PickerItem: + key: str + label: str + + +async def handle_command( + context: AgentContext, + token: str, + chat_id: int, + reply_to_message_id: int | None, + text: str, +) -> bool: + parsed = integration_commands.parse_command(text or "") + if not parsed: + return False + command, args = parsed + if command in {"/model", "/config", "/preset"} and not args: + await send_model_picker(context, token, chat_id, reply_to_message_id, 0) + return True + if command == "/project" and not args: + await send_project_picker(context, token, chat_id, reply_to_message_id, 0) + return True + if command in {"/agent", "/profile"} and not args: + await send_agent_picker(context, token, chat_id, reply_to_message_id, 0) + return True + if command == "/stream": + await send_toggle_picker( + context, + token, + chat_id, + reply_to_message_id, + CTX_TG_STREAM_ENABLED, + "Response streaming", + args, + ) + return True + if command == "/tools": + await send_toggle_picker( + context, + token, + chat_id, + reply_to_message_id, + CTX_TG_TOOLS_ENABLED, + "Tool progress", + args, + ) + return True + return False + + +async def handle_callback( + context: AgentContext, + token: str, + chat_id: int, + message_id: int, + data: str, +) -> bool: + parts = (data or "").split(":") + if len(parts) < 3 or parts[0] != CALLBACK_PREFIX: + return False + kind, action = parts[1], parts[2] + value = parts[3] if len(parts) > 3 else "" + if action == "noop": + return True + if action == "page": + page = _safe_int(value) + if kind == "model": + await edit_model_picker(context, token, chat_id, message_id, page) + elif kind == "project": + await edit_project_picker(context, token, chat_id, message_id, page) + elif kind == "agent": + await edit_agent_picker(context, token, chat_id, message_id, page) + return True + if kind == "model" and action in {"set", "clear"}: + await _select_model(context, _safe_int(value), clear=(action == "clear")) + await edit_model_picker(context, token, chat_id, message_id, 0, selected=True) + return True + if kind == "project" and action in {"set", "clear"}: + await _select_project(context, _safe_int(value), clear=(action == "clear")) + await edit_project_picker(context, token, chat_id, message_id, 0, selected=True) + return True + if kind == "agent" and action == "set": + await _select_agent(context, _safe_int(value)) + await edit_agent_picker(context, token, chat_id, message_id, 0, selected=True) + return True + if kind in {"stream", "tools"} and action in {"on", "off"}: + key = CTX_TG_STREAM_ENABLED if kind == "stream" else CTX_TG_TOOLS_ENABLED + label = "Response streaming" if kind == "stream" else "Tool progress" + context.set_data(key, action == "on") + save_tmp_chat(context) + mark_dirty_for_context(context.id, reason=f"telegram.{kind}_toggle") + await edit_toggle_picker(context, token, chat_id, message_id, key, label) + return True + return True + + +async def send_model_picker( + context: AgentContext, + token: str, + chat_id: int, + reply_to_message_id: int | None, + page: int, +) -> None: + text, markup = _model_view(context, page) + await tc.raw_send_text(token, chat_id, text, reply_to_message_id, "HTML", markup) + + +async def edit_model_picker( + context: AgentContext, + token: str, + chat_id: int, + message_id: int, + page: int, + *, + selected: bool = False, +) -> None: + text, markup = _model_view(context, page, selected=selected) + await tc.raw_edit_text(token, chat_id, message_id, text, "HTML", markup) + + +async def send_project_picker( + context: AgentContext, + token: str, + chat_id: int, + reply_to_message_id: int | None, + page: int, +) -> None: + text, markup = _project_view(context, page) + await tc.raw_send_text(token, chat_id, text, reply_to_message_id, "HTML", markup) + + +async def edit_project_picker( + context: AgentContext, + token: str, + chat_id: int, + message_id: int, + page: int, + *, + selected: bool = False, +) -> None: + text, markup = _project_view(context, page, selected=selected) + await tc.raw_edit_text(token, chat_id, message_id, text, "HTML", markup) + + +async def send_agent_picker( + context: AgentContext, + token: str, + chat_id: int, + reply_to_message_id: int | None, + page: int, +) -> None: + text, markup = _agent_view(context, page) + await tc.raw_send_text(token, chat_id, text, reply_to_message_id, "HTML", markup) + + +async def edit_agent_picker( + context: AgentContext, + token: str, + chat_id: int, + message_id: int, + page: int, + *, + selected: bool = False, +) -> None: + text, markup = _agent_view(context, page, selected=selected) + await tc.raw_edit_text(token, chat_id, message_id, text, "HTML", markup) + + +async def send_toggle_picker( + context: AgentContext, + token: str, + chat_id: int, + reply_to_message_id: int | None, + key: str, + label: str, + args: str = "", +) -> None: + desired = _parse_toggle(args) + if desired is not None: + context.set_data(key, desired) + save_tmp_chat(context) + mark_dirty_for_context(context.id, reason=f"telegram.{key}") + text, markup = _toggle_view(context, key, label) + await tc.raw_send_text(token, chat_id, text, reply_to_message_id, "HTML", markup) + + +async def edit_toggle_picker( + context: AgentContext, + token: str, + chat_id: int, + message_id: int, + key: str, + label: str, +) -> None: + text, markup = _toggle_view(context, key, label) + await tc.raw_edit_text(token, chat_id, message_id, text, "HTML", markup) + + +def _model_view( + context: AgentContext, + page: int, + *, + selected: bool = False, +) -> tuple[str, dict | None]: + presets = [ + PickerItem(str(preset.get("name", "")), str(preset.get("name", ""))) + for preset in model_config.get_presets() + if isinstance(preset, dict) and preset.get("name") + ] + current = context.get_data("chat_model_override") + current_name = current.get("preset_name") if isinstance(current, dict) else "" + status = f"Current model: {_html(current_name or 'Default')}" + if not model_config.is_chat_override_allowed(context.agent0): + return status + "\nPer-chat model switching is disabled.", None + if selected: + status = "Model updated.\n" + status + rows = _paged_buttons("model", presets, page, current_name) + rows.append([{"text": "Default", "callback_data": "tg:model:clear"}]) + return status, {"inline_keyboard": rows} + + +def _project_view( + context: AgentContext, + page: int, + *, + selected: bool = False, +) -> tuple[str, dict | None]: + items = [ + PickerItem(str(item.get("name", "")), str(item.get("title") or item.get("name") or "")) + for item in projects.get_active_projects_list() or [] + if item.get("name") + ] + current = context.get_data("project") or "" + status = f"Current project: {_html(_label_for(items, current) or 'none')}" + if selected: + status = "Project updated.\n" + status + rows = _paged_buttons("project", items, page, current) + rows.append([{"text": "No project", "callback_data": "tg:project:clear"}]) + return status, {"inline_keyboard": rows} + + +def _agent_view( + context: AgentContext, + page: int, + *, + selected: bool = False, +) -> tuple[str, dict | None]: + items = [ + PickerItem(str(item.get("key", "")), str(item.get("label") or item.get("key") or "")) + for item in subagents.get_all_agents_list() + if item.get("key") + ] + current = getattr(context.agent0.config, "profile", "") or "agent0" + status = f"Current agent: {_html(_label_for(items, current) or current)}" + if context.is_running(): + status += "\nAgent profile can be changed after the current run finishes." + elif selected: + status = "Agent updated.\n" + status + rows = _paged_buttons("agent", items, page, current, disabled=context.is_running()) + if not rows: + return status + "\nNo agent profiles were found.", None + return status, {"inline_keyboard": rows} + + +def _toggle_view(context: AgentContext, key: str, label: str) -> tuple[str, dict]: + enabled = _toggle_enabled(context, key) + kind = "stream" if key == CTX_TG_STREAM_ENABLED else "tools" + state = "enabled" if enabled else "disabled" + text = f"{_html(label)}: {state}" + rows = [[ + {"text": ("On" if enabled else "Turn on"), "callback_data": f"tg:{kind}:on"}, + {"text": ("Off" if not enabled else "Turn off"), "callback_data": f"tg:{kind}:off"}, + ]] + return text, {"inline_keyboard": rows} + + +async def _select_model(context: AgentContext, index: int, *, clear: bool = False) -> None: + if not model_config.is_chat_override_allowed(context.agent0): + return + if clear: + context.set_data("chat_model_override", None) + else: + presets = [preset for preset in model_config.get_presets() if preset.get("name")] + if index < 0 or index >= len(presets): + return + context.set_data("chat_model_override", {"preset_name": presets[index]["name"]}) + save_tmp_chat(context) + mark_dirty_for_context(context.id, reason="telegram.model_select") + + +async def _select_project(context: AgentContext, index: int, *, clear: bool = False) -> None: + if clear: + projects.deactivate_project(context.id) + return + items = [item for item in projects.get_active_projects_list() or [] if item.get("name")] + if index < 0 or index >= len(items): + return + projects.activate_project(context.id, str(items[index]["name"])) + + +async def _select_agent(context: AgentContext, index: int) -> None: + if context.is_running(): + return + from agent import Agent + from initialize import initialize_agent + + items = [item for item in subagents.get_all_agents_list() if item.get("key")] + if index < 0 or index >= len(items): + return + profile = str(items[index]["key"]) + config = initialize_agent(override_settings={"agent_profile": profile}) + context.config = config + agent = context.agent0 + while agent: + agent.config = config + agent = agent.get_data(Agent.DATA_NAME_SUBORDINATE) + save_tmp_chat(context) + mark_dirty_for_context(context.id, reason="telegram.agent_select") + + +def _paged_buttons( + kind: str, + items: list[PickerItem], + page: int, + current: str, + *, + disabled: bool = False, +) -> list[list[dict[str, str]]]: + page = max(0, page) + total_pages = max(1, (len(items) + PAGE_SIZE - 1) // PAGE_SIZE) + page = min(page, total_pages - 1) + start = page * PAGE_SIZE + rows: list[list[dict[str, str]]] = [] + for offset, item in enumerate(items[start:start + PAGE_SIZE], start=start): + marker = "• " if item.key == current else "" + action = "noop" if disabled else "set" + rows.append([{ + "text": f"{marker}{item.label}"[:64], + "callback_data": f"tg:{kind}:{action}:{offset}", + }]) + nav: list[dict[str, str]] = [] + if page > 0: + nav.append({"text": "Prev", "callback_data": f"tg:{kind}:page:{page - 1}"}) + if page < total_pages - 1: + nav.append({"text": "Next", "callback_data": f"tg:{kind}:page:{page + 1}"}) + if nav: + rows.append(nav) + return rows + + +def _toggle_enabled(context: AgentContext, key: str) -> bool: + value = context.get_data(key) + return True if value is None else bool(value) + + +def _parse_toggle(args: str) -> bool | None: + value = (args or "").strip().lower() + if value in {"on", "enable", "enabled", "yes", "true", "1"}: + return True + if value in {"off", "disable", "disabled", "no", "false", "0"}: + return False + return None + + +def _safe_int(value: str) -> int: + try: + return int(value) + except (TypeError, ValueError): + return 0 + + +def _label_for(items: list[PickerItem], key: str) -> str: + for item in items: + if item.key == key: + return item.label + return key + + +def _html(value: str) -> str: + return str(value).replace("&", "&").replace("<", "<").replace(">", ">") diff --git a/plugins/_telegram_integration/helpers/constants.py b/plugins/_telegram_integration/helpers/constants.py index 830e41547..abab54ed2 100644 --- a/plugins/_telegram_integration/helpers/constants.py +++ b/plugins/_telegram_integration/helpers/constants.py @@ -6,11 +6,19 @@ STATE_FILE = "usr/plugins/_telegram_integration/state.json" CTX_TG_BOT = "telegram_bot" CTX_TG_BOT_CFG = "telegram_bot_cfg" CTX_TG_CHAT_ID = "telegram_chat_id" +CTX_TG_CHAT_TYPE = "telegram_chat_type" CTX_TG_USER_ID = "telegram_user_id" CTX_TG_USERNAME = "telegram_username" CTX_TG_TYPING_STOP = "_telegram_typing_stop" CTX_TG_REPLY_TO = "_telegram_reply_to_message_id" +CTX_TG_STREAM_ENABLED = "telegram_stream_enabled" +CTX_TG_TOOLS_ENABLED = "telegram_tools_enabled" # Transient CTX_TG_ATTACHMENTS = "_telegram_response_attachments" CTX_TG_KEYBOARD = "_telegram_response_keyboard" +CTX_TG_PROGRESS_MESSAGE_ID = "_telegram_progress_message_id" +CTX_TG_PROGRESS_LINES = "_telegram_progress_lines" +CTX_TG_RESPONSE_MESSAGE_ID = "_telegram_response_message_id" +CTX_TG_RESPONSE_TEXT = "_telegram_response_text" +CTX_TG_RESPONSE_LAST_UPDATE = "_telegram_response_last_update" diff --git a/plugins/_telegram_integration/helpers/draft_stream.py b/plugins/_telegram_integration/helpers/draft_stream.py new file mode 100644 index 000000000..491c7d2a2 --- /dev/null +++ b/plugins/_telegram_integration/helpers/draft_stream.py @@ -0,0 +1,361 @@ +from __future__ import annotations + +import re +import time + +from agent import AgentContext +from helpers.print_style import PrintStyle +from plugins._telegram_integration.helpers import telegram_client as tc +from plugins._telegram_integration.helpers.bot_manager import get_bot +from plugins._telegram_integration.helpers.constants import ( + CTX_TG_BOT, + CTX_TG_CHAT_ID, + CTX_TG_PROGRESS_LINES, + CTX_TG_PROGRESS_MESSAGE_ID, + CTX_TG_REPLY_TO, + CTX_TG_RESPONSE_LAST_UPDATE, + CTX_TG_RESPONSE_MESSAGE_ID, + CTX_TG_RESPONSE_TEXT, + CTX_TG_STREAM_ENABLED, + CTX_TG_TOOLS_ENABLED, +) + +MAX_STREAM_CHARS: int = 3900 +MIN_RESPONSE_UPDATE_SECONDS: float = 1.0 +MAX_PROGRESS_LINES: int = 12 + +TOOL_EMOJIS: dict[str, str] = { + "browser": "🌐", + "code": "⌨️", + "code_execution_tool": "⌨️", + "duckduckgo_search": "🔎", + "file": "📄", + "knowledge_tool": "📚", + "memory": "🧠", + "read_file": "📖", + "search": "🔎", + "search_engine": "🔎", + "search_files": "🔎", + "skill": "📚", + "skill_view": "📚", +} + + +async def start(context: AgentContext) -> None: + # Do not pre-create a placeholder Telegram message. Wait until we have + # real assistant text so the stream starts with meaningful content. + if not _stream_enabled(context): + return + + +async def add_tool_start( + context: AgentContext, + tool_name: str, + args: dict | None = None, +) -> None: + if not _tools_enabled(context): + return + label = _tool_progress_label(tool_name, args or {}) + _append_progress_line(context, f"{_tool_emoji(tool_name)} {label}") + await _send_progress(context) + + +async def add_tool_done(context: AgentContext, tool_name: str, ok: bool = True) -> None: + if not _tools_enabled(context): + return + if ok: + return + _mark_tool_failed(context, tool_name) + await _send_progress(context) + + +async def update_response(context: AgentContext, response_text: str) -> None: + if not _stream_enabled(context): + return + cleaned = _visible_response_text(response_text) + if not cleaned: + return + context.data[CTX_TG_RESPONSE_TEXT] = response_text or "" + now = time.time() + last = float(context.data.get(CTX_TG_RESPONSE_LAST_UPDATE) or 0.0) + if now - last < MIN_RESPONSE_UPDATE_SECONDS: + return + await _update_response_message(context, response_text or "") + context.data[CTX_TG_RESPONSE_LAST_UPDATE] = now + + +async def finalize_response( + context: AgentContext, + response_text: str, + keyboard: list[list[dict]] | None = None, +) -> bool: + message_id = context.data.get(CTX_TG_RESPONSE_MESSAGE_ID) + if not message_id: + return False + ok = await _update_response_message( + context, + response_text or context.data.get(CTX_TG_RESPONSE_TEXT) or "", + keyboard=keyboard, + force=True, + ) + if ok: + context.data.pop(CTX_TG_RESPONSE_MESSAGE_ID, None) + context.data.pop(CTX_TG_RESPONSE_TEXT, None) + context.data.pop(CTX_TG_RESPONSE_LAST_UPDATE, None) + return ok + + +def clear(context: AgentContext) -> None: + for key in ( + CTX_TG_PROGRESS_LINES, + CTX_TG_PROGRESS_MESSAGE_ID, + CTX_TG_RESPONSE_MESSAGE_ID, + CTX_TG_RESPONSE_TEXT, + CTX_TG_RESPONSE_LAST_UPDATE, + ): + context.data.pop(key, None) + + +def _stream_enabled(context: AgentContext) -> bool: + value = context.get_data(CTX_TG_STREAM_ENABLED) + return True if value is None else bool(value) + + +def _tools_enabled(context: AgentContext) -> bool: + value = context.get_data(CTX_TG_TOOLS_ENABLED) + return True if value is None else bool(value) + + +async def _send_progress(context: AgentContext) -> None: + bot = _bot_instance(context) + chat_id = context.data.get(CTX_TG_CHAT_ID) + if not bot or not chat_id: + return + text = "\n".join(context.data.get(CTX_TG_PROGRESS_LINES) or []) + if not text: + return + message_id = context.data.get(CTX_TG_PROGRESS_MESSAGE_ID) + try: + if message_id: + await tc.raw_edit_text(bot.bot.token, int(chat_id), int(message_id), text, parse_mode=None) + return + sent_id = await tc.raw_send_text( + bot.bot.token, + int(chat_id), + text, + parse_mode=None, + ) + if sent_id: + context.data[CTX_TG_PROGRESS_MESSAGE_ID] = sent_id + except Exception as e: + PrintStyle.debug(f"Telegram progress update failed: {e}") + + +async def _ensure_response_message(context: AgentContext, text: str) -> int | None: + message_id = context.data.get(CTX_TG_RESPONSE_MESSAGE_ID) + if message_id: + return int(message_id) + html = _format_response(text) + if not html: + return None + bot = _bot_instance(context) + chat_id = context.data.get(CTX_TG_CHAT_ID) + if not bot or not chat_id: + return None + sent_id = await tc.raw_send_text( + bot.bot.token, + int(chat_id), + html, + reply_to_message_id=_reply_to(context), + parse_mode="HTML", + ) + if sent_id: + context.data[CTX_TG_RESPONSE_MESSAGE_ID] = sent_id + context.data[CTX_TG_RESPONSE_LAST_UPDATE] = time.time() + return sent_id + + +async def _update_response_message( + context: AgentContext, + text: str, + *, + keyboard: list[list[dict]] | None = None, + force: bool = False, +) -> bool: + message_id = await _ensure_response_message(context, text) + bot = _bot_instance(context) + chat_id = context.data.get(CTX_TG_CHAT_ID) + if not message_id or not bot or not chat_id: + return False + markup = _keyboard_markup(keyboard) + html = _format_response(text) + try: + ok = await tc.raw_edit_text( + bot.bot.token, + int(chat_id), + int(message_id), + html, + parse_mode="HTML", + reply_markup=markup, + ) + if ok or force: + return ok + return False + except Exception as e: + PrintStyle.debug(f"Telegram response update failed: {e}") + return False + + +def _append_progress_line(context: AgentContext, line: str) -> None: + lines = list(context.data.get(CTX_TG_PROGRESS_LINES) or []) + if lines and lines[-1] == line: + return + lines.append(line) + context.data[CTX_TG_PROGRESS_LINES] = lines[-MAX_PROGRESS_LINES:] + + +def _mark_tool_failed(context: AgentContext, tool_name: str) -> None: + lines = list(context.data.get(CTX_TG_PROGRESS_LINES) or []) + if not lines: + return + + label = _tool_label(tool_name) + for index in range(len(lines) - 1, -1, -1): + line = lines[index] + if _line_matches_tool(line, label): + _, _, suffix = line.partition(" ") + lines[index] = f"❌ {suffix or label}" + context.data[CTX_TG_PROGRESS_LINES] = lines[-MAX_PROGRESS_LINES:] + return + + lines.append(f"❌ {label}") + context.data[CTX_TG_PROGRESS_LINES] = lines[-MAX_PROGRESS_LINES:] + + +def _bot_instance(context: AgentContext): + bot_name = context.data.get(CTX_TG_BOT) + return get_bot(bot_name) if bot_name else None + + +def _reply_to(context: AgentContext) -> int | None: + value = context.data.get(CTX_TG_REPLY_TO) + try: + return int(value) if value else None + except (TypeError, ValueError): + return None + + +def _tool_label(tool_name: str) -> str: + value = (tool_name or "tool").replace("_", " ").replace("-", " ").strip() + return value or "tool" + + +def _line_matches_tool(line: str, label: str) -> bool: + _, _, suffix = line.partition(" ") + normalized_suffix = suffix.strip().lower() + normalized_label = label.strip().lower() + return normalized_suffix == normalized_label or normalized_suffix.startswith(f"{normalized_label}:") + + +def _tool_progress_label(tool_name: str, args: dict) -> str: + label = _tool_label(tool_name) + detail = _tool_detail(tool_name, args) + return f"{label}: {detail}" if detail else label + + +def _tool_detail(tool_name: str, args: dict) -> str: + if not isinstance(args, dict) or not args: + return "" + + normalized = (tool_name or "").strip().lower() + if "search" in normalized: + return _first_arg(args, ("query", "q", "search", "term", "keywords", "pattern")) + if "browser" in normalized: + return _first_arg(args, ("url", "link", "query", "action")) + if "file" in normalized: + return _first_arg(args, ("path", "file", "filename", "query", "pattern")) + if "skill" in normalized: + return _first_arg(args, ("skill", "name", "query", "path")) + + return _first_arg(args, ("action", "method", "operation")) + + +def _first_arg(args: dict, keys: tuple[str, ...]) -> str: + for key in keys: + value = args.get(key) + if value is None: + continue + text = _compact_detail(value) + if text: + return text + return "" + + +def _compact_detail(value: object) -> str: + if isinstance(value, (list, tuple)): + parts = [_compact_detail(item) for item in value[:2]] + text = ", ".join(part for part in parts if part) + if len(value) > 2: + text = f"{text}, ..." + elif isinstance(value, dict): + return "" + else: + text = str(value).strip() + + text = re.sub(r"\s+", " ", text).strip().strip("\"'") + if len(text) > 80: + text = f"{text[:77].rstrip()}..." + return text + + +def _tool_emoji(tool_name: str) -> str: + normalized = (tool_name or "").strip().lower() + for key, emoji in TOOL_EMOJIS.items(): + if key in normalized: + return emoji + return "🛠️" + + +def _format_response(text: str) -> str: + value = _visible_response_text(text) + if not value: + return "" + return tc.md_to_telegram_html(value) + + +def _strip_incomplete_tool_markup(text: str) -> str: + value = text.lstrip() + value = re.sub(r"^<[^>\n]{0,80}$", "", value) + value = re.sub(r"^```(?:json|xml)?\s*$", "", value, flags=re.IGNORECASE) + return value + + +def _visible_response_text(text: str) -> str: + value = _trim(text or "") + return _strip_incomplete_tool_markup(value) + + +def _trim(text: str) -> str: + if len(text) <= MAX_STREAM_CHARS: + return text + return text[-MAX_STREAM_CHARS:] + + +def _keyboard_markup(keyboard: list[list[dict]] | None) -> dict | None: + if not keyboard: + return None + rows: list[list[dict[str, str]]] = [] + for row in keyboard: + out_row: list[dict[str, str]] = [] + for button in row: + text = str(button.get("text") or "")[:64] + if not text: + continue + if button.get("url"): + out_row.append({"text": text, "url": str(button["url"])}) + else: + data = str(button.get("callback_data", text)) + out_row.append({"text": text, "callback_data": data[:64]}) + if out_row: + rows.append(out_row) + return {"inline_keyboard": rows} if rows else None diff --git a/plugins/_telegram_integration/helpers/handler.py b/plugins/_telegram_integration/helpers/handler.py index ea2dbd055..07c241478 100644 --- a/plugins/_telegram_integration/helpers/handler.py +++ b/plugins/_telegram_integration/helpers/handler.py @@ -21,6 +21,7 @@ from helpers.errors import format_error from initialize import initialize_agent from plugins._telegram_integration.helpers import telegram_client as tc +from plugins._telegram_integration.helpers import command_ui from plugins._telegram_integration.helpers.bot_manager import get_bot from plugins._telegram_integration.helpers.constants import ( PLUGIN_NAME, @@ -29,6 +30,7 @@ from plugins._telegram_integration.helpers.constants import ( CTX_TG_BOT, CTX_TG_BOT_CFG, CTX_TG_CHAT_ID, + CTX_TG_CHAT_TYPE, CTX_TG_USER_ID, CTX_TG_USERNAME, CTX_TG_TYPING_STOP, @@ -141,55 +143,15 @@ async def handle_start(message: TgMessage, bot_name: str, bot_cfg: dict): f"\U0001f44b Hello {user.first_name}! I'm connected to Agent Zero.\n\n" "Send me a message and I'll process it.\n" "Use /clear to reset the conversation.\n" - "Use /project, /config, or /send to control the current chat.", + "Use /project, /model, /agent, or /send to control the current chat.", parse_mode=None, + reply_to_message_id=message.message_id, ) # Ensure a chat context exists await _get_or_create_context(bot_name, bot_cfg, message) -async def handle_clear(message: TgMessage, bot_name: str, bot_cfg: dict): - """Handle /clear command — reset user's chat context.""" - user = message.from_user - if not user: - return - - if not _is_allowed(bot_cfg, user.id, user.username): - return - - key = _map_key(bot_name, user.id, message.chat.id) - - with _chat_map_lock: - state = _load_state() - ctx_id = state.get("chats", {}).get(key) - if ctx_id: - ctx = AgentContext.get(ctx_id) - if ctx: - ctx.reset() - PrintStyle.info(f"Telegram ({bot_name}): cleared chat for user {user.id}") - - instance = get_bot(bot_name) - if instance: - await _send_with_temp_bot( - instance.bot.token, message.chat.id, - "Chat cleared. Send a new message to start fresh.", - parse_mode=None, - ) - - # Send notification - if bot_cfg.get("notify_messages", False): - username_str = f"@{user.username}" if user.username else str(user.id) - NotificationManager.send_notification( - type=NotificationType.INFO, - priority=NotificationPriority.NORMAL, - title="Telegram: chat cleared", - message=f"{username_str} cleared their chat via /clear", - display_time=5, - group="telegram", - ) - - async def handle_message(message: TgMessage, bot_name: str, bot_cfg: dict): """Handle incoming user message.""" user = message.from_user @@ -212,26 +174,38 @@ async def handle_message(message: TgMessage, bot_name: str, bot_cfg: dict): parse_mode=None, ) return + context.data[CTX_TG_CHAT_TYPE] = str(message.chat.type or "") + context.data[CTX_TG_REPLY_TO] = message.message_id + + if await command_ui.handle_command( + context, + instance.bot.token, + message.chat.id, + message.message_id, + text, + ): + return command_reply = integration_commands.try_handle_command(context, text) if command_reply is not None: - await _send_with_temp_bot(instance.bot.token, message.chat.id, command_reply, parse_mode=None) + await _send_with_temp_bot( + instance.bot.token, + message.chat.id, + command_reply, + parse_mode=None, + reply_to_message_id=message.message_id, + ) + return + if integration_commands.extract_command_line(text).startswith("/"): + command = integration_commands.extract_command_line(text).split(" ", 1)[0] + await _send_with_temp_bot( + instance.bot.token, + message.chat.id, + integration_commands.unknown_command_text(command), + parse_mode=None, + reply_to_message_id=message.message_id, + ) return - - # Start persistent typing indicator (thread-based, works across event loops) - typing_stop = _start_typing(instance.bot.token, message.chat.id) - - # Store stop event so send_telegram_reply can cancel typing - context.data[CTX_TG_TYPING_STOP] = typing_stop - - # In group chats, if user replied to the bot's message, reply to the user's message - reply_to_id = None - if message.chat.type != "private" and instance.bot_info: - if (message.reply_to_message - and message.reply_to_message.from_user - and message.reply_to_message.from_user.id == instance.bot_info.id): - reply_to_id = message.message_id - context.data[CTX_TG_REPLY_TO] = reply_to_id # Use temp bot for downloads (cross-event-loop safe) 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): body=text, ) + if context.is_running(): + item = mq.add(context, user_msg, attachments) + save_tmp_chat(context) + await _send_with_temp_bot( + instance.bot.token, + message.chat.id, + f"Queued message #{item.get('seq', len(mq.get_queue(context)))}. Use /send to flush queued work, or /steer to interrupt the active run.", + parse_mode=None, + reply_to_message_id=message.message_id, + ) + return + + # Start persistent typing indicator (thread-based, works across event loops) + typing_stop = _start_typing(instance.bot.token, message.chat.id) + + # Store stop event so send_telegram_reply can cancel typing + context.data[CTX_TG_TYPING_STOP] = typing_stop + msg_id = str(uuid.uuid4()) mq.log_user_message(context, user_msg, attachments, message_id=msg_id, source=" (telegram)") context.communicate(UserMessage( @@ -287,16 +279,40 @@ async def handle_callback_query(query: CallbackQuery, bot_name: str, bot_cfg: di return context = await _get_or_create_context_from_user( - bot_name, bot_cfg, user.id, user.username, query.message.chat.id, + bot_name, bot_cfg, user.id, user.username, query.message.chat.id, str(query.message.chat.type or ""), ) if not context: return + context.data[CTX_TG_REPLY_TO] = query.message.message_id + + instance = get_bot(bot_name) + if instance: + try: + if await command_ui.handle_callback( + context, + instance.bot.token, + query.message.chat.id, + query.message.message_id, + text, + ): + return + except Exception as e: + PrintStyle.error(f"Telegram callback failed: {format_error(e)}") + if text.startswith("tg:"): + return + if text.startswith("tg:"): + return command_reply = integration_commands.try_handle_command(context, text) if command_reply is not None: - instance = get_bot(bot_name) if instance: - await _send_with_temp_bot(instance.bot.token, query.message.chat.id, command_reply, parse_mode=None) + await _send_with_temp_bot( + instance.bot.token, + query.message.chat.id, + command_reply, + parse_mode=None, + reply_to_message_id=query.message.message_id, + ) return agent = context.agent0 @@ -347,7 +363,7 @@ async def _get_or_create_context( if not user: return None return await _get_or_create_context_from_user( - bot_name, bot_cfg, user.id, user.username, message.chat.id, + bot_name, bot_cfg, user.id, user.username, message.chat.id, str(message.chat.type or ""), ) @@ -357,6 +373,7 @@ async def _get_or_create_context_from_user( user_id: int, username: str | None, chat_id: int, + chat_type: str = "", ) -> AgentContext | None: key = _map_key(bot_name, user_id, chat_id) @@ -369,6 +386,7 @@ async def _get_or_create_context_from_user( if ctx_id: ctx = AgentContext.get(ctx_id) if ctx: + ctx.data[CTX_TG_CHAT_TYPE] = chat_type or ctx.data.get(CTX_TG_CHAT_TYPE, "") return ctx # Context was garbage collected, remove stale mapping chats.pop(key, None) @@ -382,6 +400,7 @@ async def _get_or_create_context_from_user( ctx.data[CTX_TG_BOT] = bot_name ctx.data[CTX_TG_BOT_CFG] = bot_cfg ctx.data[CTX_TG_CHAT_ID] = chat_id + ctx.data[CTX_TG_CHAT_TYPE] = chat_type ctx.data[CTX_TG_USER_ID] = user_id ctx.data[CTX_TG_USERNAME] = username or "" @@ -512,7 +531,11 @@ async def send_telegram_reply( if response_text: html_text = tc.md_to_telegram_html(response_text) - if keyboard: + from plugins._telegram_integration.helpers import draft_stream + + if await draft_stream.finalize_response(context, response_text, keyboard): + pass + elif keyboard: await tc.send_text_with_keyboard(reply_bot, chat_id, html_text, keyboard, reply_to_message_id=reply_to) else: 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): await bot.session.close() -async def _send_with_temp_bot(token: str, chat_id: int, text: str, parse_mode: str | None = None): +async def _send_with_temp_bot( + token: str, + chat_id: int, + text: str, + parse_mode: str | None = None, + reply_to_message_id: int | None = None, +): """Send text using a temporary Bot to avoid cross-event-loop session issues.""" async with _temp_bot(token) as bot: - await tc.send_text(bot, chat_id, text, parse_mode=parse_mode) + await tc.send_text( + bot, + chat_id, + text, + reply_to_message_id=reply_to_message_id, + parse_mode=parse_mode, + ) def _start_typing(token: str, chat_id: int) -> threading.Event: diff --git a/plugins/_telegram_integration/helpers/telegram_client.py b/plugins/_telegram_integration/helpers/telegram_client.py index 4ea104123..45db75977 100644 --- a/plugins/_telegram_integration/helpers/telegram_client.py +++ b/plugins/_telegram_integration/helpers/telegram_client.py @@ -1,6 +1,7 @@ import os import re +import aiohttp from aiogram import Bot from aiogram.exceptions import TelegramBadRequest from aiogram.types import ( @@ -17,6 +18,7 @@ _UNSET = object() # sentinel: "not provided" (lets Bot default apply) # Text messages MAX_MESSAGE_LENGTH: int = 4096 # Telegram message length limit +TELEGRAM_API_BASE: str = "https://api.telegram.org" async def send_text( @@ -171,6 +173,88 @@ async def send_typing(bot: Bot, chat_id: int): except Exception: pass + +async def raw_send_text( + token: str, + chat_id: int, + text: str, + reply_to_message_id: int | None = None, + parse_mode: str | None = "HTML", + reply_markup: dict | None = None, +) -> int | None: + payload: dict[str, object] = { + "chat_id": chat_id, + "text": text[:MAX_MESSAGE_LENGTH], + } + if parse_mode: + payload["parse_mode"] = parse_mode + if reply_to_message_id: + payload["reply_parameters"] = {"message_id": int(reply_to_message_id)} + if reply_markup: + payload["reply_markup"] = reply_markup + data = await _raw_post(token, "sendMessage", payload) + result = data.get("result") if isinstance(data, dict) else None + if isinstance(result, dict): + return result.get("message_id") + return None + + +async def raw_edit_text( + token: str, + chat_id: int, + message_id: int, + text: str, + parse_mode: str | None = "HTML", + reply_markup: dict | None = None, +) -> bool: + payload: dict[str, object] = { + "chat_id": chat_id, + "message_id": message_id, + "text": text[:MAX_MESSAGE_LENGTH], + } + if parse_mode: + payload["parse_mode"] = parse_mode + if reply_markup: + payload["reply_markup"] = reply_markup + data = await _raw_post(token, "editMessageText", payload) + if not isinstance(data, dict): + return False + if data.get("ok"): + return True + description = str(data.get("description") or "").lower() + return "message is not modified" in description + + +async def raw_edit_reply_markup( + token: str, + chat_id: int, + message_id: int, + reply_markup: dict | None = None, +) -> bool: + payload: dict[str, object] = { + "chat_id": chat_id, + "message_id": message_id, + } + if reply_markup: + payload["reply_markup"] = reply_markup + data = await _raw_post(token, "editMessageReplyMarkup", payload) + return bool(isinstance(data, dict) and data.get("ok")) + + +async def _raw_post(token: str, method: str, payload: dict[str, object]) -> dict: + url = f"{TELEGRAM_API_BASE}/bot{token}/{method}" + try: + timeout = aiohttp.ClientTimeout(total=10) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(url, json=payload) as response: + data = await response.json(content_type=None) + if response.status != 200 or not data.get("ok"): + PrintStyle.debug(f"Telegram {method} failed: {data}") + return data if isinstance(data, dict) else {} + except Exception as e: + PrintStyle.debug(f"Telegram {method} failed: {format_error(e)}") + return {} + # File download async def download_file( diff --git a/plugins/_whatsapp_integration/helpers/handler.py b/plugins/_whatsapp_integration/helpers/handler.py index e1efe415c..9c9e19eb6 100644 --- a/plugins/_whatsapp_integration/helpers/handler.py +++ b/plugins/_whatsapp_integration/helpers/handler.py @@ -207,6 +207,24 @@ async def _route_to_chat( msg_id = str(uuid.uuid4()) media_urls = msg.get("mediaUrls", []) attachments = await _save_incoming_media(media_urls) if media_urls else [] + + if context.is_running(): + item = mq.add(context, user_msg, attachments) + save_tmp_chat(context) + port = int((plugins.get_plugin_config(PLUGIN_NAME) or {}).get("bridge_port", 3100)) + base_url = bridge_manager.get_bridge_url(port) + chat_id = context.data.get(CTX_WA_CHAT_ID, "") or msg.get("chatId", "") + reply_to = msg.get("messageId", "") if context.data.get(CTX_WA_IS_GROUP) else "" + await wa_client.send_message( + base_url, + chat_id, + f"Queued message #{item.get('seq', len(mq.get_queue(context)))}. Use /send to flush queued work, or /steer to interrupt the active run.", + reply_to=reply_to, + ) + await wa_client.send_typing(base_url, chat_id, paused=True) + context.data[CTX_WA_TYPING_ACTIVE] = False + return + mq.log_user_message( context, user_msg, attachments, message_id=msg_id, source=" (whatsapp)", ) From 8bc81c7a6a5030f4899f3e4810480915b0950c3c Mon Sep 17 00:00:00 2001 From: Anmol Malik Date: Thu, 28 May 2026 21:24:28 +0530 Subject: [PATCH 002/196] Send Telegram intermediate responses separately --- plugins/_telegram_integration/README.md | 2 +- .../_50_telegram_response.py | 8 ++- .../helpers/draft_stream.py | 26 +++++++ tests/test_telegram_intermediate_response.py | 70 +++++++++++++++++++ 4 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 tests/test_telegram_intermediate_response.py diff --git a/plugins/_telegram_integration/README.md b/plugins/_telegram_integration/README.md index bf486184e..a9793fa35 100644 --- a/plugins/_telegram_integration/README.md +++ b/plugins/_telegram_integration/README.md @@ -35,7 +35,7 @@ This plugin connects one or more Telegram bots to Agent Zero. Each bot runs inde - **Reply delivery** - Streams tool progress and response text through real Telegram messages updated with `editMessageText`. - Tool progress and the AI response are separate messages; only the AI response replies to the user message. - - `tool_execute_after` intercepts the `response` tool — sends inline progress for `break_loop=false`. + - `tool_execute_after` intercepts the `response` tool — sends `break_loop=false` updates as separate intermediate Telegram messages. - `process_chain_end` auto-sends the final response, with retry logic on failure. - **Formatting** - Converts Markdown output to Telegram-compatible HTML (bold, italic, strikethrough, code, links, blockquotes, lists). diff --git a/plugins/_telegram_integration/extensions/python/tool_execute_after/_50_telegram_response.py b/plugins/_telegram_integration/extensions/python/tool_execute_after/_50_telegram_response.py index 43fcbae24..c959eaf52 100644 --- a/plugins/_telegram_integration/extensions/python/tool_execute_after/_50_telegram_response.py +++ b/plugins/_telegram_integration/extensions/python/tool_execute_after/_50_telegram_response.py @@ -47,6 +47,7 @@ class TelegramResponseIntercept(Extension): async def _send_inline(self, context, tool, response: Response): ensure_dependencies() from plugins._telegram_integration.helpers.handler import send_telegram_reply + from plugins._telegram_integration.helpers import draft_stream agent = self.agent assert agent is not None @@ -55,7 +56,12 @@ class TelegramResponseIntercept(Extension): attachments = context.data.pop(CTX_TG_ATTACHMENTS, []) keyboard = context.data.pop(CTX_TG_KEYBOARD, None) - error = await send_telegram_reply(context, text, attachments or None, keyboard) + if attachments: + error = await send_telegram_reply(context, text, attachments or None, keyboard) + elif await draft_stream.send_intermediate_response(context, text, keyboard): + error = None + else: + error = "Telegram intermediate update was not sent" if error: result = agent.read_prompt("fw.telegram.update_error.md", error=error) diff --git a/plugins/_telegram_integration/helpers/draft_stream.py b/plugins/_telegram_integration/helpers/draft_stream.py index 491c7d2a2..b246f8ab3 100644 --- a/plugins/_telegram_integration/helpers/draft_stream.py +++ b/plugins/_telegram_integration/helpers/draft_stream.py @@ -84,6 +84,32 @@ async def update_response(context: AgentContext, response_text: str) -> None: context.data[CTX_TG_RESPONSE_LAST_UPDATE] = now +async def send_intermediate_response( + context: AgentContext, + response_text: str, + keyboard: list[list[dict]] | None = None, +) -> bool: + html = _format_response(response_text) + if not html: + return False + bot = _bot_instance(context) + chat_id = context.data.get(CTX_TG_CHAT_ID) + if not bot or not chat_id: + return False + try: + sent_id = await tc.raw_send_text( + bot.bot.token, + int(chat_id), + html, + parse_mode="HTML", + reply_markup=_keyboard_markup(keyboard), + ) + return bool(sent_id) + except Exception as e: + PrintStyle.debug(f"Telegram intermediate response failed: {e}") + return False + + async def finalize_response( context: AgentContext, response_text: str, diff --git a/tests/test_telegram_intermediate_response.py b/tests/test_telegram_intermediate_response.py new file mode 100644 index 000000000..cb56946ed --- /dev/null +++ b/tests/test_telegram_intermediate_response.py @@ -0,0 +1,70 @@ +import asyncio + +from plugins._telegram_integration.helpers import draft_stream +from plugins._telegram_integration.helpers.constants import ( + CTX_TG_BOT, + CTX_TG_CHAT_ID, + CTX_TG_REPLY_TO, + CTX_TG_RESPONSE_MESSAGE_ID, +) + + +class FakeBot: + class Bot: + token = "token" + + bot = Bot() + + +class FakeContext: + def __init__(self): + self.data = { + CTX_TG_BOT: "main", + CTX_TG_CHAT_ID: 123, + CTX_TG_REPLY_TO: 456, + } + + def get_data(self, key): + return self.data.get(key) + + +def test_intermediate_response_sends_separate_non_reply_message(monkeypatch): + calls = [] + + async def fake_send(token, chat_id, text, reply_to_message_id=None, parse_mode="HTML", reply_markup=None): + calls.append( + { + "token": token, + "chat_id": chat_id, + "text": text, + "reply_to_message_id": reply_to_message_id, + "parse_mode": parse_mode, + "reply_markup": reply_markup, + } + ) + return 789 + + context = FakeContext() + monkeypatch.setattr(draft_stream, "_bot_instance", lambda ctx: FakeBot()) + monkeypatch.setattr(draft_stream.tc, "raw_send_text", fake_send) + + sent = asyncio.run( + draft_stream.send_intermediate_response( + context, + "**Working** on the brief.", + keyboard=[[{"text": "Open", "callback_data": "open"}]], + ) + ) + + assert sent is True + assert calls == [ + { + "token": "token", + "chat_id": 123, + "text": "Working on the brief.", + "reply_to_message_id": None, + "parse_mode": "HTML", + "reply_markup": {"inline_keyboard": [[{"text": "Open", "callback_data": "open"}]]}, + } + ] + assert CTX_TG_RESPONSE_MESSAGE_ID not in context.data From a17cef5b03f500dee1e9e9c3fa0bcdb0c03f3eee Mon Sep 17 00:00:00 2001 From: Anmol Malik Date: Thu, 28 May 2026 23:00:44 +0530 Subject: [PATCH 003/196] Group Telegram tool streams by response --- .../helpers/draft_stream.py | 21 ++++- tests/test_telegram_intermediate_response.py | 82 +++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/plugins/_telegram_integration/helpers/draft_stream.py b/plugins/_telegram_integration/helpers/draft_stream.py index b246f8ab3..a64cb47a6 100644 --- a/plugins/_telegram_integration/helpers/draft_stream.py +++ b/plugins/_telegram_integration/helpers/draft_stream.py @@ -29,10 +29,10 @@ TOOL_EMOJIS: dict[str, str] = { "code": "⌨️", "code_execution_tool": "⌨️", "duckduckgo_search": "🔎", + "read_file": "📖", "file": "📄", "knowledge_tool": "📚", "memory": "🧠", - "read_file": "📖", "search": "🔎", "search_engine": "🔎", "search_files": "🔎", @@ -89,6 +89,12 @@ async def send_intermediate_response( response_text: str, keyboard: list[list[dict]] | None = None, ) -> bool: + if context.data.get(CTX_TG_RESPONSE_MESSAGE_ID): + sent = await finalize_response(context, response_text, keyboard) + if sent: + _reset_progress_group(context) + return sent + html = _format_response(response_text) if not html: return False @@ -104,7 +110,10 @@ async def send_intermediate_response( parse_mode="HTML", reply_markup=_keyboard_markup(keyboard), ) - return bool(sent_id) + sent = bool(sent_id) + if sent: + _reset_progress_group(context) + return sent except Exception as e: PrintStyle.debug(f"Telegram intermediate response failed: {e}") return False @@ -142,6 +151,11 @@ def clear(context: AgentContext) -> None: context.data.pop(key, None) +def _reset_progress_group(context: AgentContext) -> None: + context.data.pop(CTX_TG_PROGRESS_LINES, None) + context.data.pop(CTX_TG_PROGRESS_MESSAGE_ID, None) + + def _stream_enabled(context: AgentContext) -> bool: value = context.get_data(CTX_TG_STREAM_ENABLED) return True if value is None else bool(value) @@ -208,6 +222,7 @@ async def _update_response_message( keyboard: list[list[dict]] | None = None, force: bool = False, ) -> bool: + had_message = bool(context.data.get(CTX_TG_RESPONSE_MESSAGE_ID)) message_id = await _ensure_response_message(context, text) bot = _bot_instance(context) chat_id = context.data.get(CTX_TG_CHAT_ID) @@ -215,6 +230,8 @@ async def _update_response_message( return False markup = _keyboard_markup(keyboard) html = _format_response(text) + if not had_message and not markup and not force: + return True try: ok = await tc.raw_edit_text( bot.bot.token, diff --git a/tests/test_telegram_intermediate_response.py b/tests/test_telegram_intermediate_response.py index cb56946ed..c8c572557 100644 --- a/tests/test_telegram_intermediate_response.py +++ b/tests/test_telegram_intermediate_response.py @@ -4,6 +4,8 @@ from plugins._telegram_integration.helpers import draft_stream from plugins._telegram_integration.helpers.constants import ( CTX_TG_BOT, CTX_TG_CHAT_ID, + CTX_TG_PROGRESS_LINES, + CTX_TG_PROGRESS_MESSAGE_ID, CTX_TG_REPLY_TO, CTX_TG_RESPONSE_MESSAGE_ID, ) @@ -68,3 +70,83 @@ def test_intermediate_response_sends_separate_non_reply_message(monkeypatch): } ] assert CTX_TG_RESPONSE_MESSAGE_ID not in context.data + + +def test_intermediate_response_finalizes_active_stream_and_starts_next_tool_group(monkeypatch): + calls = [] + next_message_id = 100 + + async def fake_send(token, chat_id, text, reply_to_message_id=None, parse_mode="HTML", reply_markup=None): + nonlocal next_message_id + calls.append( + { + "method": "send", + "text": text, + "reply_to_message_id": reply_to_message_id, + "parse_mode": parse_mode, + "reply_markup": reply_markup, + "message_id": next_message_id, + } + ) + next_message_id += 1 + return next_message_id - 1 + + async def fake_edit(token, chat_id, message_id, text, parse_mode="HTML", reply_markup=None): + calls.append( + { + "method": "edit", + "message_id": message_id, + "text": text, + "parse_mode": parse_mode, + "reply_markup": reply_markup, + } + ) + return True + + context = FakeContext() + monkeypatch.setattr(draft_stream, "_bot_instance", lambda ctx: FakeBot()) + monkeypatch.setattr(draft_stream.tc, "raw_send_text", fake_send) + monkeypatch.setattr(draft_stream.tc, "raw_edit_text", fake_edit) + + asyncio.run(draft_stream.add_tool_start(context, "search_engine", {"query": "telegram bot api"})) + asyncio.run(draft_stream.update_response(context, "Found the docs.")) + sent = asyncio.run(draft_stream.send_intermediate_response(context, "Found the docs.")) + asyncio.run(draft_stream.add_tool_start(context, "read_file", {"path": "notes.md"})) + + assert sent is True + assert calls == [ + { + "method": "send", + "text": "🔎 search engine: telegram bot api", + "reply_to_message_id": None, + "parse_mode": None, + "reply_markup": None, + "message_id": 100, + }, + { + "method": "send", + "text": "Found the docs.", + "reply_to_message_id": 456, + "parse_mode": "HTML", + "reply_markup": None, + "message_id": 101, + }, + { + "method": "edit", + "message_id": 101, + "text": "Found the docs.", + "parse_mode": "HTML", + "reply_markup": None, + }, + { + "method": "send", + "text": "📖 read file: notes.md", + "reply_to_message_id": None, + "parse_mode": None, + "reply_markup": None, + "message_id": 102, + }, + ] + assert context.data[CTX_TG_PROGRESS_MESSAGE_ID] == 102 + assert context.data[CTX_TG_PROGRESS_LINES] == ["📖 read file: notes.md"] + assert CTX_TG_RESPONSE_MESSAGE_ID not in context.data From 97ef4797e0ecd0d3e53843f094787bff9a5680d0 Mon Sep 17 00:00:00 2001 From: Anmol Malik Date: Thu, 28 May 2026 23:07:44 +0530 Subject: [PATCH 004/196] Send Telegram media attachments natively --- .../_telegram_integration/helpers/handler.py | 6 + .../helpers/telegram_client.py | 93 +++++++++++++++ tests/test_telegram_media_delivery.py | 108 ++++++++++++++++++ 3 files changed, 207 insertions(+) create mode 100644 tests/test_telegram_media_delivery.py diff --git a/plugins/_telegram_integration/helpers/handler.py b/plugins/_telegram_integration/helpers/handler.py index 07c241478..3fd0f8c82 100644 --- a/plugins/_telegram_integration/helpers/handler.py +++ b/plugins/_telegram_integration/helpers/handler.py @@ -526,6 +526,12 @@ async def send_telegram_reply( local_path = files.fix_dev_path(path) if tc.is_image_file(local_path): await tc.send_photo(reply_bot, chat_id, local_path, reply_to_message_id=reply_to) + elif tc.is_voice_file(local_path): + await tc.send_voice(reply_bot, chat_id, local_path, reply_to_message_id=reply_to) + elif tc.is_audio_file(local_path): + await tc.send_audio(reply_bot, chat_id, local_path, reply_to_message_id=reply_to) + elif tc.is_video_file(local_path): + await tc.send_video(reply_bot, chat_id, local_path, reply_to_message_id=reply_to) else: await tc.send_file(reply_bot, chat_id, local_path, reply_to_message_id=reply_to) diff --git a/plugins/_telegram_integration/helpers/telegram_client.py b/plugins/_telegram_integration/helpers/telegram_client.py index 45db75977..2fc9f69f1 100644 --- a/plugins/_telegram_integration/helpers/telegram_client.py +++ b/plugins/_telegram_integration/helpers/telegram_client.py @@ -115,6 +115,81 @@ async def send_photo( return None +async def send_voice( + bot: Bot, + chat_id: int, + voice_path: str, + caption: str = "", + reply_to_message_id: int | None = None, +) -> int | None: + """Send a voice message from local path. Returns message_id or None on error.""" + try: + if not os.path.isfile(voice_path): + PrintStyle.error(f"Telegram: voice file not found: {voice_path}") + return None + input_file = FSInputFile(voice_path) + msg = await bot.send_voice( + chat_id=chat_id, + voice=input_file, + caption=caption[:1024] if caption else None, + reply_to_message_id=reply_to_message_id, + ) + return msg.message_id + except Exception as e: + PrintStyle.error(f"Telegram send_voice failed: {format_error(e)}") + return None + + +async def send_audio( + bot: Bot, + chat_id: int, + audio_path: str, + caption: str = "", + reply_to_message_id: int | None = None, +) -> int | None: + """Send an audio message from local path. Returns message_id or None on error.""" + try: + if not os.path.isfile(audio_path): + PrintStyle.error(f"Telegram: audio file not found: {audio_path}") + return None + input_file = FSInputFile(audio_path) + msg = await bot.send_audio( + chat_id=chat_id, + audio=input_file, + caption=caption[:1024] if caption else None, + reply_to_message_id=reply_to_message_id, + ) + return msg.message_id + except Exception as e: + PrintStyle.error(f"Telegram send_audio failed: {format_error(e)}") + return None + + +async def send_video( + bot: Bot, + chat_id: int, + video_path: str, + caption: str = "", + reply_to_message_id: int | None = None, +) -> int | None: + """Send a video message from local path. Returns message_id or None on error.""" + try: + if not os.path.isfile(video_path): + PrintStyle.error(f"Telegram: video file not found: {video_path}") + return None + input_file = FSInputFile(video_path) + msg = await bot.send_video( + chat_id=chat_id, + video=input_file, + caption=caption[:1024] if caption else None, + reply_to_message_id=reply_to_message_id, + ) + return msg.message_id + except Exception as e: + PrintStyle.error(f"Telegram send_video failed: {format_error(e)}") + return None + + # Inline keyboards def build_inline_keyboard( @@ -294,6 +369,9 @@ def _split_text(text: str, max_len: int) -> list[str]: _IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"} +_VOICE_EXTENSIONS = {".ogg", ".oga", ".opus"} +_AUDIO_EXTENSIONS = {".mp3", ".m4a", ".aac", ".wav", ".flac"} +_VIDEO_EXTENSIONS = {".mp4", ".m4v", ".mov", ".webm"} def is_image_file(path: str) -> bool: @@ -301,6 +379,21 @@ def is_image_file(path: str) -> bool: return ext in _IMAGE_EXTENSIONS +def is_voice_file(path: str) -> bool: + _, ext = os.path.splitext(path.lower()) + return ext in _VOICE_EXTENSIONS + + +def is_audio_file(path: str) -> bool: + _, ext = os.path.splitext(path.lower()) + return ext in _AUDIO_EXTENSIONS + + +def is_video_file(path: str) -> bool: + _, ext = os.path.splitext(path.lower()) + return ext in _VIDEO_EXTENSIONS + + def md_to_telegram_html(text: str) -> str: """Convert Markdown to Telegram-compatible HTML.""" stash: list[str] = [] diff --git a/tests/test_telegram_media_delivery.py b/tests/test_telegram_media_delivery.py new file mode 100644 index 000000000..bbdb2a2bc --- /dev/null +++ b/tests/test_telegram_media_delivery.py @@ -0,0 +1,108 @@ +import asyncio + +from plugins._telegram_integration.helpers import handler +from plugins._telegram_integration.helpers import telegram_client as tc +from plugins._telegram_integration.helpers.constants import ( + CTX_TG_BOT, + CTX_TG_CHAT_ID, + CTX_TG_REPLY_TO, +) + + +class FakeBotInstance: + class Bot: + token = "token" + + bot = Bot() + + +class FakeContext: + def __init__(self): + self.data = { + CTX_TG_BOT: "main", + CTX_TG_CHAT_ID: 123, + CTX_TG_REPLY_TO: 456, + } + + +class FakeTempBot: + async def __aenter__(self): + return object() + + async def __aexit__(self, exc_type, exc, tb): + return False + + +class FakeMediaBot: + def __init__(self): + self.calls = [] + + async def send_voice(self, **kwargs): + self.calls.append(("voice", kwargs)) + return type("Message", (), {"message_id": 11})() + + async def send_audio(self, **kwargs): + self.calls.append(("audio", kwargs)) + return type("Message", (), {"message_id": 12})() + + async def send_video(self, **kwargs): + self.calls.append(("video", kwargs)) + return type("Message", (), {"message_id": 13})() + + +def test_telegram_client_native_media_helpers(monkeypatch): + bot = FakeMediaBot() + monkeypatch.setattr(tc.os.path, "isfile", lambda path: True) + + voice_id = asyncio.run(tc.send_voice(bot, 123, "voice.ogg", reply_to_message_id=456)) + audio_id = asyncio.run(tc.send_audio(bot, 123, "song.mp3", reply_to_message_id=456)) + video_id = asyncio.run(tc.send_video(bot, 123, "clip.mp4", reply_to_message_id=456)) + + assert (voice_id, audio_id, video_id) == (11, 12, 13) + assert [kind for kind, _ in bot.calls] == ["voice", "audio", "video"] + assert bot.calls[0][1]["chat_id"] == 123 + assert bot.calls[0][1]["reply_to_message_id"] == 456 + + +def test_telegram_reply_routes_native_media_attachments(monkeypatch): + calls = [] + + def fake_temp_bot(*args, **kwargs): + return FakeTempBot() + + async def record(kind, bot, chat_id, path, reply_to_message_id=None, **kwargs): + calls.append( + { + "kind": kind, + "chat_id": chat_id, + "path": path, + "reply_to_message_id": reply_to_message_id, + } + ) + return len(calls) + + monkeypatch.setattr(handler, "get_bot", lambda name: FakeBotInstance()) + monkeypatch.setattr(handler, "_temp_bot", fake_temp_bot) + monkeypatch.setattr(handler.files, "fix_dev_path", lambda path: path) + monkeypatch.setattr(handler.tc, "send_photo", lambda *a, **kw: record("photo", *a, **kw)) + monkeypatch.setattr(handler.tc, "send_voice", lambda *a, **kw: record("voice", *a, **kw)) + monkeypatch.setattr(handler.tc, "send_audio", lambda *a, **kw: record("audio", *a, **kw)) + monkeypatch.setattr(handler.tc, "send_video", lambda *a, **kw: record("video", *a, **kw)) + monkeypatch.setattr(handler.tc, "send_file", lambda *a, **kw: record("document", *a, **kw)) + + error = asyncio.run( + handler.send_telegram_reply( + FakeContext(), + "", + ["image.png", "voice.ogg", "song.mp3", "clip.mp4", "archive.zip"], + ) + ) + + assert error is None + assert calls == [ + {"kind": "photo", "chat_id": 123, "path": "image.png", "reply_to_message_id": 456}, + {"kind": "voice", "chat_id": 123, "path": "voice.ogg", "reply_to_message_id": 456}, + {"kind": "audio", "chat_id": 123, "path": "song.mp3", "reply_to_message_id": 456}, + {"kind": "video", "chat_id": 123, "path": "clip.mp4", "reply_to_message_id": 456}, + {"kind": "document", "chat_id": 123, "path": "archive.zip", "reply_to_message_id": 456}, + ] From 92b728c3d770b5e79b54d836c49771a45ad210d3 Mon Sep 17 00:00:00 2001 From: Anmol Malik Date: Thu, 28 May 2026 23:11:55 +0530 Subject: [PATCH 005/196] Show friendly Telegram errors --- .../end/_85_telegram_error.py | 42 ++++++++ .../helpers/constants.py | 1 + .../helpers/draft_stream.py | 2 + .../_telegram_integration/helpers/error_ui.py | 100 ++++++++++++++++++ tests/test_telegram_error_ui.py | 96 +++++++++++++++++ 5 files changed, 241 insertions(+) create mode 100644 plugins/_telegram_integration/extensions/python/_functions/agent/Agent/handle_exception/end/_85_telegram_error.py create mode 100644 plugins/_telegram_integration/helpers/error_ui.py create mode 100644 tests/test_telegram_error_ui.py diff --git a/plugins/_telegram_integration/extensions/python/_functions/agent/Agent/handle_exception/end/_85_telegram_error.py b/plugins/_telegram_integration/extensions/python/_functions/agent/Agent/handle_exception/end/_85_telegram_error.py new file mode 100644 index 000000000..443df0d74 --- /dev/null +++ b/plugins/_telegram_integration/extensions/python/_functions/agent/Agent/handle_exception/end/_85_telegram_error.py @@ -0,0 +1,42 @@ +from helpers.errors import HandledException +from helpers.extension import Extension +from helpers.print_style import PrintStyle +from plugins._telegram_integration.helpers.constants import ( + CTX_TG_BOT, + CTX_TG_ERROR_SENT, + CTX_TG_REPLY_TO, + CTX_TG_TYPING_STOP, +) + + +class TelegramFriendlyError(Extension): + async def execute(self, data: dict = {}, **kwargs): + if not self.agent or self.agent.number != 0: + return + + context = self.agent.context + if not context.data.get(CTX_TG_BOT): + return + + exception = data.get("exception") + if not exception or isinstance(exception, HandledException): + return + + if context.data.get(CTX_TG_ERROR_SENT): + return + + context.data[CTX_TG_ERROR_SENT] = True + + from plugins._telegram_integration.helpers import draft_stream, error_ui + from plugins._telegram_integration.helpers.handler import send_telegram_reply + + text = error_ui.friendly_error_message(exception) + error = await send_telegram_reply(context, text) + if error: + PrintStyle.debug(f"Telegram error reply failed: {error}") + + typing_stop = context.data.pop(CTX_TG_TYPING_STOP, None) + if typing_stop: + typing_stop.set() + draft_stream.clear(context) + context.data.pop(CTX_TG_REPLY_TO, None) diff --git a/plugins/_telegram_integration/helpers/constants.py b/plugins/_telegram_integration/helpers/constants.py index abab54ed2..1ef80c81a 100644 --- a/plugins/_telegram_integration/helpers/constants.py +++ b/plugins/_telegram_integration/helpers/constants.py @@ -22,3 +22,4 @@ CTX_TG_PROGRESS_LINES = "_telegram_progress_lines" CTX_TG_RESPONSE_MESSAGE_ID = "_telegram_response_message_id" CTX_TG_RESPONSE_TEXT = "_telegram_response_text" CTX_TG_RESPONSE_LAST_UPDATE = "_telegram_response_last_update" +CTX_TG_ERROR_SENT = "_telegram_error_sent" diff --git a/plugins/_telegram_integration/helpers/draft_stream.py b/plugins/_telegram_integration/helpers/draft_stream.py index a64cb47a6..22390fdff 100644 --- a/plugins/_telegram_integration/helpers/draft_stream.py +++ b/plugins/_telegram_integration/helpers/draft_stream.py @@ -18,6 +18,7 @@ from plugins._telegram_integration.helpers.constants import ( CTX_TG_RESPONSE_TEXT, CTX_TG_STREAM_ENABLED, CTX_TG_TOOLS_ENABLED, + CTX_TG_ERROR_SENT, ) MAX_STREAM_CHARS: int = 3900 @@ -42,6 +43,7 @@ TOOL_EMOJIS: dict[str, str] = { async def start(context: AgentContext) -> None: + context.data.pop(CTX_TG_ERROR_SENT, None) # Do not pre-create a placeholder Telegram message. Wait until we have # real assistant text so the stream starts with meaningful content. if not _stream_enabled(context): diff --git a/plugins/_telegram_integration/helpers/error_ui.py b/plugins/_telegram_integration/helpers/error_ui.py new file mode 100644 index 000000000..b9db82ebb --- /dev/null +++ b/plugins/_telegram_integration/helpers/error_ui.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import re + + +MAX_DETAIL_CHARS = 500 + + +def friendly_error_message(exception: BaseException) -> str: + summary = _error_summary(exception) + category = _classify_error(exception, summary) + + if category == "auth": + return ( + "**Agent Zero hit a provider setup issue.**\n\n" + "The model provider rejected the request because an API key or credential is missing, invalid, or unauthorized.\n\n" + f"Details: `{summary}`\n\n" + "Please check the model/API key settings, then send the message again." + ) + + if category == "rate_limit": + return ( + "**Agent Zero was rate limited by the model provider.**\n\n" + "The provider is asking us to slow down before trying again.\n\n" + f"Details: `{summary}`" + ) + + if category == "timeout": + return ( + "**Agent Zero did not get a response in time.**\n\n" + "The provider or tool call timed out before the agent could finish this request.\n\n" + f"Details: `{summary}`" + ) + + if category == "provider": + return ( + "**Agent Zero could not complete the model request.**\n\n" + "The model provider returned an error before the agent could finish.\n\n" + f"Details: `{summary}`" + ) + + return ( + "**Agent Zero ran into an error while working on this.**\n\n" + f"Details: `{summary}`" + ) + + +def _classify_error(exception: BaseException, summary: str) -> str: + text = f"{type(exception).__name__} {summary}".lower() + + if any( + marker in text + for marker in ( + "api key", + "api_key", + "apikey", + "auth", + "credential", + "unauthorized", + "forbidden", + "invalid key", + "missing key", + "permission denied", + "401", + "403", + ) + ): + return "auth" + + if any(marker in text for marker in ("rate limit", "ratelimit", "too many requests", "429")): + return "rate_limit" + + if any(marker in text for marker in ("timeout", "timed out", "deadline", "read timed out")): + return "timeout" + + if any( + marker in text + for marker in ( + "litellm", + "openai", + "anthropic", + "model", + "provider", + "badrequest", + "service unavailable", + "overloaded", + "503", + ) + ): + return "provider" + + return "generic" + + +def _error_summary(exception: BaseException) -> str: + text = str(exception).strip() or type(exception).__name__ + text = re.sub(r"\s+", " ", text) + if len(text) > MAX_DETAIL_CHARS: + text = f"{text[: MAX_DETAIL_CHARS - 3].rstrip()}..." + return text diff --git a/tests/test_telegram_error_ui.py b/tests/test_telegram_error_ui.py new file mode 100644 index 000000000..3b73fdf5b --- /dev/null +++ b/tests/test_telegram_error_ui.py @@ -0,0 +1,96 @@ +import asyncio + +from plugins._telegram_integration.extensions.python._functions.agent.Agent.handle_exception.end import ( + _85_telegram_error, +) +from plugins._telegram_integration.helpers import error_ui +from plugins._telegram_integration.helpers.constants import ( + CTX_TG_BOT, + CTX_TG_ERROR_SENT, + CTX_TG_REPLY_TO, + CTX_TG_TYPING_STOP, +) + + +class FakeContext: + def __init__(self): + self.data = { + CTX_TG_BOT: "main", + CTX_TG_REPLY_TO: 456, + } + + +class FakeAgent: + number = 0 + + def __init__(self): + self.context = FakeContext() + + +def test_friendly_error_message_for_missing_api_key(): + message = error_ui.friendly_error_message( + RuntimeError("OPENAI_API_KEY is missing or invalid") + ) + + assert "provider setup issue" in message + assert "API key" in message + assert "OPENAI_API_KEY is missing or invalid" in message + + +def test_friendly_error_message_for_rate_limit(): + message = error_ui.friendly_error_message( + RuntimeError("Rate limit exceeded: too many requests") + ) + + assert "rate limited" in message + assert "too many requests" in message + + +def test_telegram_exception_hook_sends_once_and_cleans_stream_state(monkeypatch): + sends = [] + cleared = [] + typing_stopped = [] + + async def fake_send(context, text, attachments=None, keyboard=None): + sends.append( + { + "text": text, + "reply_to": context.data.get(CTX_TG_REPLY_TO), + "attachments": attachments, + "keyboard": keyboard, + } + ) + return None + + def fake_clear(context): + cleared.append(True) + + class FakeStop: + def set(self): + typing_stopped.append(True) + + agent = FakeAgent() + agent.context.data[CTX_TG_TYPING_STOP] = FakeStop() + + monkeypatch.setattr( + "plugins._telegram_integration.helpers.handler.send_telegram_reply", + fake_send, + ) + monkeypatch.setattr( + "plugins._telegram_integration.helpers.draft_stream.clear", + fake_clear, + ) + + extension = _85_telegram_error.TelegramFriendlyError(agent=agent) + data = {"exception": RuntimeError("provider returned 503 service unavailable")} + + asyncio.run(extension.execute(data=data)) + asyncio.run(extension.execute(data=data)) + + assert len(sends) == 1 + assert "could not complete the model request" in sends[0]["text"] + assert sends[0]["reply_to"] == 456 + assert cleared == [True] + assert typing_stopped == [True] + assert agent.context.data[CTX_TG_ERROR_SENT] is True + assert CTX_TG_REPLY_TO not in agent.context.data From 99207d60a2ce3a086f124ad9d27d096b09971657 Mon Sep 17 00:00:00 2001 From: Anmol Malik Date: Thu, 28 May 2026 23:14:23 +0530 Subject: [PATCH 006/196] Add Telegram long-run status updates --- .../end/_85_telegram_error.py | 3 +- .../_45_telegram_draft_start.py | 3 +- .../process_chain_end/_55_telegram_reply.py | 8 +- .../helpers/constants.py | 2 + .../helpers/heartbeat.py | 101 ++++++++++++++++++ tests/test_telegram_heartbeat.py | 71 ++++++++++++ 6 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 plugins/_telegram_integration/helpers/heartbeat.py create mode 100644 tests/test_telegram_heartbeat.py diff --git a/plugins/_telegram_integration/extensions/python/_functions/agent/Agent/handle_exception/end/_85_telegram_error.py b/plugins/_telegram_integration/extensions/python/_functions/agent/Agent/handle_exception/end/_85_telegram_error.py index 443df0d74..b711ba350 100644 --- a/plugins/_telegram_integration/extensions/python/_functions/agent/Agent/handle_exception/end/_85_telegram_error.py +++ b/plugins/_telegram_integration/extensions/python/_functions/agent/Agent/handle_exception/end/_85_telegram_error.py @@ -27,7 +27,7 @@ class TelegramFriendlyError(Extension): context.data[CTX_TG_ERROR_SENT] = True - from plugins._telegram_integration.helpers import draft_stream, error_ui + from plugins._telegram_integration.helpers import draft_stream, error_ui, heartbeat from plugins._telegram_integration.helpers.handler import send_telegram_reply text = error_ui.friendly_error_message(exception) @@ -38,5 +38,6 @@ class TelegramFriendlyError(Extension): typing_stop = context.data.pop(CTX_TG_TYPING_STOP, None) if typing_stop: typing_stop.set() + await heartbeat.stop(context) draft_stream.clear(context) context.data.pop(CTX_TG_REPLY_TO, None) diff --git a/plugins/_telegram_integration/extensions/python/message_loop_start/_45_telegram_draft_start.py b/plugins/_telegram_integration/extensions/python/message_loop_start/_45_telegram_draft_start.py index af773207d..6fc4fe89c 100644 --- a/plugins/_telegram_integration/extensions/python/message_loop_start/_45_telegram_draft_start.py +++ b/plugins/_telegram_integration/extensions/python/message_loop_start/_45_telegram_draft_start.py @@ -12,6 +12,7 @@ class TelegramDraftStart(Extension): if not context.data.get(CTX_TG_BOT): return - from plugins._telegram_integration.helpers import draft_stream + from plugins._telegram_integration.helpers import draft_stream, heartbeat await draft_stream.start(context) + await heartbeat.start(context) diff --git a/plugins/_telegram_integration/extensions/python/process_chain_end/_55_telegram_reply.py b/plugins/_telegram_integration/extensions/python/process_chain_end/_55_telegram_reply.py index dcd28665e..508c3acf4 100644 --- a/plugins/_telegram_integration/extensions/python/process_chain_end/_55_telegram_reply.py +++ b/plugins/_telegram_integration/extensions/python/process_chain_end/_55_telegram_reply.py @@ -26,14 +26,13 @@ class TelegramAutoReply(Extension): return response_text = _extract_last_response(context) - if not response_text: - return attachments = context.data.pop(CTX_TG_ATTACHMENTS, []) keyboard = context.data.pop(CTX_TG_KEYBOARD, None) try: - await self._send_reply(context, response_text, attachments, keyboard) + if response_text: + await self._send_reply(context, response_text, attachments, keyboard) except Exception as e: PrintStyle.error(f"Telegram auto-reply error: {format_error(e)}") finally: @@ -41,8 +40,9 @@ class TelegramAutoReply(Extension): typing_stop = context.data.pop(CTX_TG_TYPING_STOP, None) if typing_stop: typing_stop.set() - from plugins._telegram_integration.helpers import draft_stream + from plugins._telegram_integration.helpers import draft_stream, heartbeat + await heartbeat.stop(context) draft_stream.clear(context) context.data.pop(CTX_TG_REPLY_TO, None) diff --git a/plugins/_telegram_integration/helpers/constants.py b/plugins/_telegram_integration/helpers/constants.py index 1ef80c81a..190ee7fa4 100644 --- a/plugins/_telegram_integration/helpers/constants.py +++ b/plugins/_telegram_integration/helpers/constants.py @@ -23,3 +23,5 @@ CTX_TG_RESPONSE_MESSAGE_ID = "_telegram_response_message_id" CTX_TG_RESPONSE_TEXT = "_telegram_response_text" CTX_TG_RESPONSE_LAST_UPDATE = "_telegram_response_last_update" CTX_TG_ERROR_SENT = "_telegram_error_sent" +CTX_TG_HEARTBEAT_TASK = "_telegram_heartbeat_task" +CTX_TG_HEARTBEAT_STOP = "_telegram_heartbeat_stop" diff --git a/plugins/_telegram_integration/helpers/heartbeat.py b/plugins/_telegram_integration/helpers/heartbeat.py new file mode 100644 index 000000000..188127e41 --- /dev/null +++ b/plugins/_telegram_integration/helpers/heartbeat.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import asyncio +import re +import time +from contextlib import suppress + +from agent import AgentContext +from helpers.print_style import PrintStyle +from plugins._telegram_integration.helpers import telegram_client as tc +from plugins._telegram_integration.helpers.bot_manager import get_bot +from plugins._telegram_integration.helpers.constants import ( + CTX_TG_BOT, + CTX_TG_CHAT_ID, + CTX_TG_HEARTBEAT_STOP, + CTX_TG_HEARTBEAT_TASK, +) + + +HEARTBEAT_INTERVAL_SECONDS = 180 + + +async def start(context: AgentContext) -> None: + task = context.data.get(CTX_TG_HEARTBEAT_TASK) + if task and not task.done(): + return + + stop_event = asyncio.Event() + context.data[CTX_TG_HEARTBEAT_STOP] = stop_event + context.data[CTX_TG_HEARTBEAT_TASK] = asyncio.create_task( + _heartbeat_loop(context, stop_event, time.monotonic()) + ) + + +async def stop(context: AgentContext) -> None: + stop_event = context.data.pop(CTX_TG_HEARTBEAT_STOP, None) + if stop_event: + stop_event.set() + + task = context.data.pop(CTX_TG_HEARTBEAT_TASK, None) + if task and not task.done(): + task.cancel() + with suppress(asyncio.CancelledError): + await task + + +async def _heartbeat_loop( + context: AgentContext, + stop_event: asyncio.Event, + started_at: float, +) -> None: + try: + while True: + try: + await asyncio.wait_for(stop_event.wait(), timeout=HEARTBEAT_INTERVAL_SECONDS) + return + except asyncio.TimeoutError: + await _send_heartbeat(context, time.monotonic() - started_at) + except asyncio.CancelledError: + raise + except Exception as exc: + PrintStyle.debug(f"Telegram heartbeat stopped: {exc}") + + +async def _send_heartbeat(context: AgentContext, elapsed_seconds: float) -> bool: + bot_name = context.data.get(CTX_TG_BOT) + chat_id = context.data.get(CTX_TG_CHAT_ID) + bot = get_bot(bot_name) if bot_name else None + if not bot or not chat_id: + return False + + text = heartbeat_text(elapsed_seconds, _current_reason(context)) + sent_id = await tc.raw_send_text( + bot.bot.token, + int(chat_id), + text, + parse_mode=None, + ) + return bool(sent_id) + + +def heartbeat_text(elapsed_seconds: float, reason: str = "") -> str: + minutes = max(1, int(round(elapsed_seconds / 60))) + detail = reason or "working on your request" + return f"Still working... ({minutes} min elapsed - {detail})" + + +def _current_reason(context: AgentContext) -> str: + progress = str(getattr(getattr(context, "log", None), "progress", "") or "") + progress = _clean_progress(progress) + if not progress or progress.lower() == "waiting for input": + return "working on your request" + return progress + + +def _clean_progress(text: str) -> str: + value = re.sub(r"icon://[a-zA-Z0-9_]+(?:\[[^\]]*\])?\s*", "", text) + value = re.sub(r"\s+", " ", value).strip() + if len(value) > 80: + value = f"{value[:77].rstrip()}..." + return value diff --git a/tests/test_telegram_heartbeat.py b/tests/test_telegram_heartbeat.py new file mode 100644 index 000000000..e9626ed42 --- /dev/null +++ b/tests/test_telegram_heartbeat.py @@ -0,0 +1,71 @@ +import asyncio + +from plugins._telegram_integration.helpers import heartbeat +from plugins._telegram_integration.helpers.constants import ( + CTX_TG_BOT, + CTX_TG_CHAT_ID, + CTX_TG_HEARTBEAT_STOP, + CTX_TG_HEARTBEAT_TASK, +) + + +class FakeBot: + class Bot: + token = "token" + + bot = Bot() + + +class FakeLog: + progress = "icon://search[Search] A0: Searching official docs" + + +class FakeContext: + def __init__(self): + self.data = { + CTX_TG_BOT: "main", + CTX_TG_CHAT_ID: 123, + } + self.log = FakeLog() + + +def test_heartbeat_text_omits_iteration_count(): + text = heartbeat.heartbeat_text(180, "waiting for provider response") + + assert text == "Still working... (3 min elapsed - waiting for provider response)" + assert "iteration" not in text + + +def test_heartbeat_sends_periodic_status_and_stops(monkeypatch): + calls = [] + + async def fake_send(token, chat_id, text, parse_mode=None, **kwargs): + calls.append( + { + "token": token, + "chat_id": chat_id, + "text": text, + "parse_mode": parse_mode, + } + ) + return len(calls) + + context = FakeContext() + monkeypatch.setattr(heartbeat, "HEARTBEAT_INTERVAL_SECONDS", 0.01) + monkeypatch.setattr(heartbeat, "get_bot", lambda name: FakeBot()) + monkeypatch.setattr(heartbeat.tc, "raw_send_text", fake_send) + + async def run(): + await heartbeat.start(context) + await asyncio.sleep(0.025) + await heartbeat.stop(context) + + asyncio.run(run()) + + assert calls + assert calls[0]["chat_id"] == 123 + assert calls[0]["parse_mode"] is None + assert "Still working..." in calls[0]["text"] + assert "A0: Searching official docs" in calls[0]["text"] + assert CTX_TG_HEARTBEAT_TASK not in context.data + assert CTX_TG_HEARTBEAT_STOP not in context.data From 1ff45e71c24e369beaf8c01bbe8b41c8a80e14b0 Mon Sep 17 00:00:00 2001 From: Anmol Malik Date: Thu, 28 May 2026 23:17:46 +0530 Subject: [PATCH 007/196] Add Telegram session picker --- helpers/integration_commands.py | 26 +++ .../helpers/command_ui.py | 178 +++++++++++++++++- tests/test_telegram_sessions_picker.py | 78 ++++++++ 3 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 tests/test_telegram_sessions_picker.py diff --git a/helpers/integration_commands.py b/helpers/integration_commands.py index 655a3cc87..e4a09b32b 100644 --- a/helpers/integration_commands.py +++ b/helpers/integration_commands.py @@ -35,6 +35,12 @@ COMMAND_REGISTRY: tuple[IntegrationCommandDef, ...] = ( "Info", ), IntegrationCommandDef("new", "Start a fresh chat context.", "Session"), + IntegrationCommandDef( + "sessions", + "Show or switch recent chat sessions.", + "Session", + aliases=("session",), + ), IntegrationCommandDef("clear", "Reset the current chat context.", "Session", aliases=("reset",)), IntegrationCommandDef( "queue", @@ -168,6 +174,8 @@ def try_handle_command(context: "AgentContext", text: str) -> str | None: return help_text(full=True) if command == "/status": return _handle_status(context) + if command == "/sessions": + return _handle_sessions(context) if command in {"/new", "/clear"}: return _handle_clear(context, new_chat=(command == "/new")) if command == "/send": @@ -251,6 +259,24 @@ def _handle_status(context: "AgentContext") -> str: ) +def _handle_sessions(context: "AgentContext") -> str: + from agent import AgentContext + + contexts = sorted( + AgentContext.all(), + key=lambda item: str(item.output().get("last_message") or ""), + reverse=True, + ) + lines = ["Recent sessions:"] + for item in contexts[:4]: + marker = " (current)" if item.id == context.id else "" + running = " - running" if item.is_running() else "" + lines.append(f"- {item.name or item.id}{marker}{running}") + if len(contexts) > 4: + lines.append(f"And {len(contexts) - 4} more. Use Telegram buttons to page through them.") + return "\n".join(lines) + + def _handle_clear(context: "AgentContext", *, new_chat: bool) -> str: context.reset() mq.remove(context) diff --git a/plugins/_telegram_integration/helpers/command_ui.py b/plugins/_telegram_integration/helpers/command_ui.py index 0ce9a5ae6..a0f108d0f 100644 --- a/plugins/_telegram_integration/helpers/command_ui.py +++ b/plugins/_telegram_integration/helpers/command_ui.py @@ -1,9 +1,10 @@ from __future__ import annotations +import json from dataclasses import dataclass from agent import AgentContext -from helpers import projects, subagents +from helpers import files, projects, subagents from helpers import integration_commands from helpers.persist_chat import save_tmp_chat from helpers.state_monitor_integration import mark_dirty_for_context @@ -12,9 +13,16 @@ from plugins._telegram_integration.helpers import telegram_client as tc from plugins._telegram_integration.helpers.constants import ( CTX_TG_STREAM_ENABLED, CTX_TG_TOOLS_ENABLED, + CTX_TG_BOT, + CTX_TG_CHAT_ID, + CTX_TG_CHAT_TYPE, + CTX_TG_USER_ID, + CTX_TG_USERNAME, + STATE_FILE, ) PAGE_SIZE = 8 +SESSION_PAGE_SIZE = 4 CALLBACK_PREFIX = "tg" @@ -24,6 +32,14 @@ class PickerItem: label: str +@dataclass(frozen=True) +class SessionItem: + context: AgentContext + label: str + last_message: str + running: bool + + async def handle_command( context: AgentContext, token: str, @@ -44,6 +60,9 @@ async def handle_command( if command in {"/agent", "/profile"} and not args: await send_agent_picker(context, token, chat_id, reply_to_message_id, 0) return True + if command in {"/sessions", "/session"} and not args: + await send_session_picker(context, token, chat_id, reply_to_message_id, 0) + return True if command == "/stream": await send_toggle_picker( context, @@ -91,6 +110,8 @@ async def handle_callback( await edit_project_picker(context, token, chat_id, message_id, page) elif kind == "agent": await edit_agent_picker(context, token, chat_id, message_id, page) + elif kind == "session": + await edit_session_picker(context, token, chat_id, message_id, page) return True if kind == "model" and action in {"set", "clear"}: await _select_model(context, _safe_int(value), clear=(action == "clear")) @@ -104,6 +125,10 @@ async def handle_callback( await _select_agent(context, _safe_int(value)) await edit_agent_picker(context, token, chat_id, message_id, 0, selected=True) return True + if kind == "session" and action == "set": + selected_context = await _select_session(context, _safe_int(value)) + await edit_session_picker(selected_context or context, token, chat_id, message_id, 0, selected=bool(selected_context)) + return True if kind in {"stream", "tools"} and action in {"on", "off"}: key = CTX_TG_STREAM_ENABLED if kind == "stream" else CTX_TG_TOOLS_ENABLED label = "Response streaming" if kind == "stream" else "Tool progress" @@ -217,6 +242,30 @@ async def edit_toggle_picker( await tc.raw_edit_text(token, chat_id, message_id, text, "HTML", markup) +async def send_session_picker( + context: AgentContext, + token: str, + chat_id: int, + reply_to_message_id: int | None, + page: int, +) -> None: + text, markup = _session_view(context, page) + await tc.raw_send_text(token, chat_id, text, reply_to_message_id, "HTML", markup) + + +async def edit_session_picker( + context: AgentContext, + token: str, + chat_id: int, + message_id: int, + page: int, + *, + selected: bool = False, +) -> None: + text, markup = _session_view(context, page, selected=selected) + await tc.raw_edit_text(token, chat_id, message_id, text, "HTML", markup) + + def _model_view( context: AgentContext, page: int, @@ -295,6 +344,48 @@ def _toggle_view(context: AgentContext, key: str, label: str) -> tuple[str, dict return text, {"inline_keyboard": rows} +def _session_view( + context: AgentContext, + page: int, + *, + selected: bool = False, +) -> tuple[str, dict | None]: + items = _session_items() + current_label = _session_label(context) + status = f"Current session: {_html(current_label)}" + if context.is_running(): + status += "\nSession switching is available after the current run finishes." + elif selected: + status = "Session switched.\n" + status + if not items: + return status + "\nNo sessions were found.", None + + total = len(items) + page = _clamp_page(page, total, SESSION_PAGE_SIZE) + start = page * SESSION_PAGE_SIZE + end = min(start + SESSION_PAGE_SIZE, total) + rows: list[list[dict[str, str]]] = [] + for index, item in enumerate(items[start:end], start=start): + marker = "• " if item.context.id == context.id else "" + suffix = " (running)" if item.running else "" + action = "noop" if context.is_running() or item.running else "set" + rows.append([{ + "text": f"{marker}{item.label}{suffix}"[:64], + "callback_data": f"tg:session:{action}:{index}", + }]) + + nav: list[dict[str, str]] = [] + if page > 0: + nav.append({"text": "Prev", "callback_data": f"tg:session:page:{page - 1}"}) + if end < total: + nav.append({"text": "Next", "callback_data": f"tg:session:page:{page + 1}"}) + if nav: + rows.append(nav) + + range_text = f"\nShowing {start + 1}-{end} of {total}." + return status + range_text, {"inline_keyboard": rows} + + async def _select_model(context: AgentContext, index: int, *, clear: bool = False) -> None: if not model_config.is_chat_override_allowed(context.agent0): return @@ -339,6 +430,86 @@ async def _select_agent(context: AgentContext, index: int) -> None: mark_dirty_for_context(context.id, reason="telegram.agent_select") +async def _select_session(context: AgentContext, index: int) -> AgentContext | None: + if context.is_running(): + return None + items = _session_items() + if index < 0 or index >= len(items): + return None + target = items[index].context + if target.is_running(): + return None + + _copy_telegram_binding(context, target) + _set_session_mapping(target) + save_tmp_chat(target) + mark_dirty_for_context(context.id, reason="telegram.session_unselect") + mark_dirty_for_context(target.id, reason="telegram.session_select") + return target + + +def _session_items() -> list[SessionItem]: + contexts = sorted( + AgentContext.all(), + key=lambda item: str(item.output().get("last_message") or ""), + reverse=True, + ) + return [ + SessionItem( + context=item, + label=_session_label(item), + last_message=str(item.output().get("last_message") or ""), + running=item.is_running(), + ) + for item in contexts + ] + + +def _session_label(context: AgentContext) -> str: + return str(context.name or context.id or "Session") + + +def _copy_telegram_binding(source: AgentContext, target: AgentContext) -> None: + for key in ( + CTX_TG_BOT, + CTX_TG_CHAT_ID, + CTX_TG_CHAT_TYPE, + CTX_TG_USER_ID, + CTX_TG_USERNAME, + ): + if key in source.data: + target.data[key] = source.data[key] + + +def _set_session_mapping(context: AgentContext) -> None: + bot_name = str(context.data.get(CTX_TG_BOT) or "") + user_id = context.data.get(CTX_TG_USER_ID) + chat_id = context.data.get(CTX_TG_CHAT_ID) + if not bot_name or user_id is None or chat_id is None: + return + key = f"{bot_name}:{int(user_id)}:{int(chat_id)}" + state = _load_telegram_state() + chats = state.setdefault("chats", {}) + chats[key] = context.id + _save_telegram_state(state) + + +def _load_telegram_state() -> dict: + path = files.get_abs_path(STATE_FILE) + if not files.exists(path): + return {} + try: + return json.loads(files.read_file(path)) + except Exception: + return {} + + +def _save_telegram_state(state: dict) -> None: + path = files.get_abs_path(STATE_FILE) + files.make_dirs(path) + files.write_file(path, json.dumps(state)) + + def _paged_buttons( kind: str, items: list[PickerItem], @@ -390,6 +561,11 @@ def _safe_int(value: str) -> int: return 0 +def _clamp_page(page: int, total_items: int, page_size: int) -> int: + total_pages = max(1, (total_items + page_size - 1) // page_size) + return min(max(0, page), total_pages - 1) + + def _label_for(items: list[PickerItem], key: str) -> str: for item in items: if item.key == key: diff --git a/tests/test_telegram_sessions_picker.py b/tests/test_telegram_sessions_picker.py new file mode 100644 index 000000000..588ecbc7e --- /dev/null +++ b/tests/test_telegram_sessions_picker.py @@ -0,0 +1,78 @@ +import asyncio +from types import SimpleNamespace + +from plugins._telegram_integration.helpers import command_ui +from plugins._telegram_integration.helpers.constants import ( + CTX_TG_BOT, + CTX_TG_CHAT_ID, + CTX_TG_USER_ID, + CTX_TG_USERNAME, +) + + +class FakeContext: + def __init__(self, id, name, last_message, *, running=False): + self.id = id + self.name = name + self.data = {} + self._last_message = last_message + self._running = running + + def output(self): + return {"last_message": self._last_message} + + def is_running(self): + return self._running + + +def test_session_picker_shows_four_recent_sessions(monkeypatch): + contexts = [ + FakeContext("ctx-1", "One", "2026-05-01T10:00:00"), + FakeContext("ctx-2", "Two", "2026-05-02T10:00:00"), + FakeContext("ctx-3", "Three", "2026-05-03T10:00:00"), + FakeContext("ctx-4", "Four", "2026-05-04T10:00:00"), + FakeContext("ctx-5", "Five", "2026-05-05T10:00:00"), + ] + current = contexts[4] + monkeypatch.setattr(command_ui, "AgentContext", SimpleNamespace(all=lambda: contexts)) + + text, markup = command_ui._session_view(current, 0) + + assert "Current session: Five" in text + assert "Showing 1-4 of 5." in text + rows = markup["inline_keyboard"] + assert [row[0]["text"] for row in rows[:4]] == ["• Five", "Four", "Three", "Two"] + assert rows[-1] == [{"text": "Next", "callback_data": "tg:session:page:1"}] + + +def test_select_session_remaps_telegram_chat(monkeypatch): + current = FakeContext("ctx-current", "Current", "2026-05-03T10:00:00") + target = FakeContext("ctx-target", "Target", "2026-05-04T10:00:00") + current.data.update( + { + CTX_TG_BOT: "main", + CTX_TG_CHAT_ID: 123, + CTX_TG_USER_ID: 456, + CTX_TG_USERNAME: "anmol", + } + ) + saved = [] + dirty = [] + state_out = {} + + monkeypatch.setattr(command_ui, "AgentContext", SimpleNamespace(all=lambda: [current, target])) + monkeypatch.setattr(command_ui, "save_tmp_chat", lambda context: saved.append(context.id)) + monkeypatch.setattr(command_ui, "mark_dirty_for_context", lambda context_id, *, reason=None: dirty.append((context_id, reason))) + monkeypatch.setattr(command_ui, "_load_telegram_state", lambda: {"chats": {}}) + monkeypatch.setattr(command_ui, "_save_telegram_state", lambda state: state_out.update(state)) + + selected = asyncio.run(command_ui._select_session(current, 0)) + + assert selected is target + assert target.data[CTX_TG_BOT] == "main" + assert target.data[CTX_TG_CHAT_ID] == 123 + assert target.data[CTX_TG_USER_ID] == 456 + assert target.data[CTX_TG_USERNAME] == "anmol" + assert state_out["chats"]["main:456:123"] == "ctx-target" + assert saved == ["ctx-target"] + assert ("ctx-target", "telegram.session_select") in dirty From 709b8653996508eeda42ebfbdc54a282d5d3c92e Mon Sep 17 00:00:00 2001 From: Anmol Malik Date: Thu, 28 May 2026 23:35:44 +0530 Subject: [PATCH 008/196] Add Telegram speech mode --- helpers/integration_commands.py | 8 ++ plugins/_telegram_integration/README.md | 3 + .../process_chain_end/_55_telegram_reply.py | 10 ++ .../helpers/command_ui.py | 37 +++++- .../helpers/constants.py | 2 + .../_telegram_integration/helpers/speech.py | 80 +++++++++++++ tests/test_telegram_speech_mode.py | 112 ++++++++++++++++++ 7 files changed, 248 insertions(+), 4 deletions(-) create mode 100644 plugins/_telegram_integration/helpers/speech.py create mode 100644 tests/test_telegram_speech_mode.py diff --git a/helpers/integration_commands.py b/helpers/integration_commands.py index e4a09b32b..b555d2af0 100644 --- a/helpers/integration_commands.py +++ b/helpers/integration_commands.py @@ -70,6 +70,12 @@ COMMAND_REGISTRY: tuple[IntegrationCommandDef, ...] = ( "Configuration", args_hint="[on|off]", ), + IntegrationCommandDef( + "speech", + "Enable or disable Telegram spoken replies.", + "Configuration", + args_hint="[on|off]", + ), IntegrationCommandDef( "project", "Show or switch the active project.", @@ -194,6 +200,8 @@ def try_handle_command(context: "AgentContext", text: str) -> str | None: return _handle_toggle(context, args, "telegram_stream_enabled", "Response streaming") if command == "/tools": return _handle_toggle(context, args, "telegram_tools_enabled", "Tool progress") + if command == "/speech": + return _handle_toggle(context, args, "telegram_speech_enabled", "Speech replies") if command == "/project": return _handle_project(context, args) if command == "/model": diff --git a/plugins/_telegram_integration/README.md b/plugins/_telegram_integration/README.md index a9793fa35..c3c13aae2 100644 --- a/plugins/_telegram_integration/README.md +++ b/plugins/_telegram_integration/README.md @@ -24,6 +24,7 @@ This plugin connects one or more Telegram bots to Agent Zero. Each bot runs inde - `/agent ` switches the active agent profile when the current run is idle. - `/model`, `/project`, and `/agent` show Telegram inline keyboard pickers when used without arguments. - `/stream` toggles live response streaming; `/tools` toggles tool progress messages. + - `/speech` toggles spoken replies. When enabled, final text replies also include Kokoro-generated Telegram audio when Kokoro TTS is available. - `/send` or `/queue send` flushes queued messages for the current chat. - `/steer ` sends an intervention to the active run. - **Group support** @@ -35,6 +36,7 @@ This plugin connects one or more Telegram bots to Agent Zero. Each bot runs inde - **Reply delivery** - Streams tool progress and response text through real Telegram messages updated with `editMessageText`. - Tool progress and the AI response are separate messages; only the AI response replies to the user message. + - Sends native Telegram media: photos, voice/audio files, videos, and document fallback. - `tool_execute_after` intercepts the `response` tool — sends `break_loop=false` updates as separate intermediate Telegram messages. - `process_chain_end` auto-sends the final response, with retry logic on failure. - **Formatting** @@ -63,6 +65,7 @@ This plugin connects one or more Telegram bots to Agent Zero. Each bot runs inde - `helpers/telegram_client.py` — Low-level Telegram API wrapper: send text/file/photo, Markdown→HTML converter, keyboard builder, message splitting. - `helpers/command_ui.py` — Telegram inline keyboard command pickers and callback handling. - `helpers/draft_stream.py` — Editable Telegram message streaming for tool progress and response previews. + - `helpers/speech.py` — Telegram speech-mode TTS synthesis and audio attachment generation. - **Extensions** - `extensions/python/job_loop/_10_telegram_bot.py` — Bot lifecycle manager, starts/stops bots on each tick. - `extensions/python/message_loop_start/_45_telegram_draft_start.py` — Initializes Telegram response streaming. diff --git a/plugins/_telegram_integration/extensions/python/process_chain_end/_55_telegram_reply.py b/plugins/_telegram_integration/extensions/python/process_chain_end/_55_telegram_reply.py index 508c3acf4..582d444b2 100644 --- a/plugins/_telegram_integration/extensions/python/process_chain_end/_55_telegram_reply.py +++ b/plugins/_telegram_integration/extensions/python/process_chain_end/_55_telegram_reply.py @@ -32,7 +32,17 @@ class TelegramAutoReply(Extension): try: if response_text: + from plugins._telegram_integration.helpers import speech + + speech_attachment, speech_warning = await speech.synthesize_attachment( + context, response_text + ) + if speech_attachment: + attachments = [*attachments, speech_attachment] await self._send_reply(context, response_text, attachments, keyboard) + warning = speech.consume_warning(context, speech_warning) + if warning: + await self._send_reply(context, warning, [], None) except Exception as e: PrintStyle.error(f"Telegram auto-reply error: {format_error(e)}") finally: diff --git a/plugins/_telegram_integration/helpers/command_ui.py b/plugins/_telegram_integration/helpers/command_ui.py index a0f108d0f..6ceb3fe6e 100644 --- a/plugins/_telegram_integration/helpers/command_ui.py +++ b/plugins/_telegram_integration/helpers/command_ui.py @@ -13,6 +13,7 @@ from plugins._telegram_integration.helpers import telegram_client as tc from plugins._telegram_integration.helpers.constants import ( CTX_TG_STREAM_ENABLED, CTX_TG_TOOLS_ENABLED, + CTX_TG_SPEECH_ENABLED, CTX_TG_BOT, CTX_TG_CHAT_ID, CTX_TG_CHAT_TYPE, @@ -85,6 +86,17 @@ async def handle_command( args, ) return True + if command == "/speech": + await send_toggle_picker( + context, + token, + chat_id, + reply_to_message_id, + CTX_TG_SPEECH_ENABLED, + "Speech replies", + args, + ) + return True return False @@ -129,9 +141,8 @@ async def handle_callback( selected_context = await _select_session(context, _safe_int(value)) await edit_session_picker(selected_context or context, token, chat_id, message_id, 0, selected=bool(selected_context)) return True - if kind in {"stream", "tools"} and action in {"on", "off"}: - key = CTX_TG_STREAM_ENABLED if kind == "stream" else CTX_TG_TOOLS_ENABLED - label = "Response streaming" if kind == "stream" else "Tool progress" + if kind in {"stream", "tools", "speech"} and action in {"on", "off"}: + key, label = _toggle_key_label(kind) context.set_data(key, action == "on") save_tmp_chat(context) mark_dirty_for_context(context.id, reason=f"telegram.{kind}_toggle") @@ -334,7 +345,7 @@ def _agent_view( def _toggle_view(context: AgentContext, key: str, label: str) -> tuple[str, dict]: enabled = _toggle_enabled(context, key) - kind = "stream" if key == CTX_TG_STREAM_ENABLED else "tools" + kind = _toggle_kind(key) state = "enabled" if enabled else "disabled" text = f"{_html(label)}: {state}" rows = [[ @@ -542,9 +553,27 @@ def _paged_buttons( def _toggle_enabled(context: AgentContext, key: str) -> bool: value = context.get_data(key) + if key == CTX_TG_SPEECH_ENABLED: + return bool(value) return True if value is None else bool(value) +def _toggle_kind(key: str) -> str: + if key == CTX_TG_STREAM_ENABLED: + return "stream" + if key == CTX_TG_TOOLS_ENABLED: + return "tools" + return "speech" + + +def _toggle_key_label(kind: str) -> tuple[str, str]: + if kind == "stream": + return CTX_TG_STREAM_ENABLED, "Response streaming" + if kind == "tools": + return CTX_TG_TOOLS_ENABLED, "Tool progress" + return CTX_TG_SPEECH_ENABLED, "Speech replies" + + def _parse_toggle(args: str) -> bool | None: value = (args or "").strip().lower() if value in {"on", "enable", "enabled", "yes", "true", "1"}: diff --git a/plugins/_telegram_integration/helpers/constants.py b/plugins/_telegram_integration/helpers/constants.py index 190ee7fa4..9696f2661 100644 --- a/plugins/_telegram_integration/helpers/constants.py +++ b/plugins/_telegram_integration/helpers/constants.py @@ -13,6 +13,7 @@ CTX_TG_TYPING_STOP = "_telegram_typing_stop" CTX_TG_REPLY_TO = "_telegram_reply_to_message_id" CTX_TG_STREAM_ENABLED = "telegram_stream_enabled" CTX_TG_TOOLS_ENABLED = "telegram_tools_enabled" +CTX_TG_SPEECH_ENABLED = "telegram_speech_enabled" # Transient CTX_TG_ATTACHMENTS = "_telegram_response_attachments" @@ -25,3 +26,4 @@ CTX_TG_RESPONSE_LAST_UPDATE = "_telegram_response_last_update" CTX_TG_ERROR_SENT = "_telegram_error_sent" CTX_TG_HEARTBEAT_TASK = "_telegram_heartbeat_task" CTX_TG_HEARTBEAT_STOP = "_telegram_heartbeat_stop" +CTX_TG_SPEECH_WARNING_SENT = "_telegram_speech_warning_sent" diff --git a/plugins/_telegram_integration/helpers/speech.py b/plugins/_telegram_integration/helpers/speech.py new file mode 100644 index 000000000..eb74a5e30 --- /dev/null +++ b/plugins/_telegram_integration/helpers/speech.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import re +import uuid + +from agent import AgentContext +from helpers import files +from plugins._telegram_integration.helpers.constants import ( + CTX_TG_SPEECH_ENABLED, + CTX_TG_SPEECH_WARNING_SENT, + DOWNLOAD_FOLDER, +) + + +MAX_SPEECH_CHARS = 1800 + + +def is_enabled(context: AgentContext) -> bool: + return bool(context.get_data(CTX_TG_SPEECH_ENABLED)) + + +async def synthesize_attachment(context: AgentContext, text: str) -> tuple[str | None, str | None]: + if not is_enabled(context): + return None, None + + speech_text = _speech_text(text) + if not speech_text: + return None, "Speech mode is enabled, but there was no readable text to speak." + + try: + from plugins._kokoro_tts.helpers import runtime + except Exception: + return None, _setup_message() + + if not runtime.is_globally_enabled(): + return None, _setup_message() + + try: + audio = await runtime.synthesize_sentences([speech_text]) + except Exception as exc: + return None, f"Speech mode is enabled, but text-to-speech failed: {exc}" + + if not audio: + return None, "Speech mode is enabled, but text-to-speech returned no audio." + + name = f"telegram_speech_{context.id}_{uuid.uuid4().hex[:10]}.wav" + relative_path = f"{DOWNLOAD_FOLDER}/{name}" + files.write_file_base64(relative_path, audio) + return files.get_abs_path_dockerized(relative_path), None + + +def consume_warning(context: AgentContext, warning: str | None) -> str | None: + if not warning: + context.data.pop(CTX_TG_SPEECH_WARNING_SENT, None) + return None + if context.data.get(CTX_TG_SPEECH_WARNING_SENT): + return None + context.data[CTX_TG_SPEECH_WARNING_SENT] = True + return warning + + +def _setup_message() -> str: + return ( + "Speech mode is enabled, but Kokoro TTS is not available. " + "Enable the Kokoro TTS plugin from Agent Zero settings, then try again." + ) + + +def _speech_text(text: str) -> str: + value = str(text or "") + value = re.sub(r"```.*?```", " ", value, flags=re.DOTALL) + value = re.sub(r"`([^`]+)`", r"\1", value) + value = re.sub(r"!\[([^\]]*)\]\([^)]+\)", r"\1", value) + value = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", value) + value = re.sub(r"<[^>]+>", " ", value) + value = re.sub(r"[*_#>\-]+", " ", value) + value = re.sub(r"\s+", " ", value).strip() + if len(value) > MAX_SPEECH_CHARS: + value = value[:MAX_SPEECH_CHARS].rstrip() + return value diff --git a/tests/test_telegram_speech_mode.py b/tests/test_telegram_speech_mode.py new file mode 100644 index 000000000..575cc2777 --- /dev/null +++ b/tests/test_telegram_speech_mode.py @@ -0,0 +1,112 @@ +import asyncio + +from plugins._telegram_integration.extensions.python.process_chain_end import ( + _55_telegram_reply, +) +from plugins._telegram_integration.helpers import command_ui, speech +from plugins._telegram_integration.helpers.constants import ( + CTX_TG_BOT, + CTX_TG_REPLY_TO, + CTX_TG_SPEECH_ENABLED, +) + + +class FakeContext: + id = "ctx-speech" + + def __init__(self): + self.data = {} + + def get_data(self, key): + return self.data.get(key) + + def set_data(self, key, value): + self.data[key] = value + + +class FakeAgent: + number = 0 + + def __init__(self): + self.context = FakeContext() + self.context.data[CTX_TG_BOT] = "main" + self.context.data[CTX_TG_REPLY_TO] = 456 + + +def test_speech_toggle_defaults_off(): + context = FakeContext() + + text, markup = command_ui._toggle_view(context, CTX_TG_SPEECH_ENABLED, "Speech replies") + + assert "Speech replies: disabled" == text + assert markup["inline_keyboard"][0][0]["callback_data"] == "tg:speech:on" + assert markup["inline_keyboard"][0][1]["callback_data"] == "tg:speech:off" + + +def test_speech_text_is_cleaned_for_tts(): + cleaned = speech._speech_text( + "**Hello** [`Agent Zero`](https://example.com)\n\n```python\nprint('skip')\n```" + ) + + assert cleaned == "Hello Agent Zero" + + +def test_speech_synthesis_writes_audio_attachment(monkeypatch): + context = FakeContext() + context.data[CTX_TG_SPEECH_ENABLED] = True + writes = [] + + class FakeRuntime: + @staticmethod + def is_globally_enabled(): + return True + + @staticmethod + async def synthesize_sentences(sentences): + assert sentences == ["Hello from Agent Zero."] + return "UklGRg==" + + monkeypatch.setattr(speech.files, "write_file_base64", lambda path, audio: writes.append((path, audio))) + monkeypatch.setattr(speech.files, "get_abs_path_dockerized", lambda path: f"/a0/{path}") + monkeypatch.setitem(__import__("sys").modules, "plugins._kokoro_tts.helpers.runtime", FakeRuntime) + + path, warning = asyncio.run(speech.synthesize_attachment(context, "Hello from Agent Zero.")) + + assert warning is None + assert path.startswith("/a0/usr/uploads/telegram_speech_ctx-speech_") + assert path.endswith(".wav") + assert writes[0][1] == "UklGRg==" + + +def test_final_reply_includes_speech_attachment(monkeypatch): + agent = FakeAgent() + sent = [] + + async def fake_synthesize(context, text): + return "/a0/usr/uploads/reply.wav", None + + async def fake_send_reply(context, response_text, attachments, keyboard): + sent.append( + { + "text": response_text, + "attachments": attachments, + "keyboard": keyboard, + } + ) + + monkeypatch.setattr(_55_telegram_reply, "_extract_last_response", lambda context: "Final reply.") + monkeypatch.setattr(speech, "synthesize_attachment", fake_synthesize) + + extension = _55_telegram_reply.TelegramAutoReply(agent=agent) + extension._send_reply = fake_send_reply + + asyncio.run(extension.execute()) + + assert sent == [ + { + "text": "Final reply.", + "attachments": ["/a0/usr/uploads/reply.wav"], + "keyboard": None, + } + ] + assert CTX_TG_REPLY_TO not in agent.context.data From 013a374daa312d57e5d5b0341d008a1b40752f2b Mon Sep 17 00:00:00 2001 From: Anmol Malik Date: Thu, 28 May 2026 23:43:49 +0530 Subject: [PATCH 009/196] Revert "Add Telegram speech mode" This reverts commit c51c235faacbbed44994635d4743b97f7a143a17. --- helpers/integration_commands.py | 8 -- plugins/_telegram_integration/README.md | 3 - .../process_chain_end/_55_telegram_reply.py | 10 -- .../helpers/command_ui.py | 37 +----- .../helpers/constants.py | 2 - .../_telegram_integration/helpers/speech.py | 80 ------------- tests/test_telegram_speech_mode.py | 112 ------------------ 7 files changed, 4 insertions(+), 248 deletions(-) delete mode 100644 plugins/_telegram_integration/helpers/speech.py delete mode 100644 tests/test_telegram_speech_mode.py diff --git a/helpers/integration_commands.py b/helpers/integration_commands.py index b555d2af0..e4a09b32b 100644 --- a/helpers/integration_commands.py +++ b/helpers/integration_commands.py @@ -70,12 +70,6 @@ COMMAND_REGISTRY: tuple[IntegrationCommandDef, ...] = ( "Configuration", args_hint="[on|off]", ), - IntegrationCommandDef( - "speech", - "Enable or disable Telegram spoken replies.", - "Configuration", - args_hint="[on|off]", - ), IntegrationCommandDef( "project", "Show or switch the active project.", @@ -200,8 +194,6 @@ def try_handle_command(context: "AgentContext", text: str) -> str | None: return _handle_toggle(context, args, "telegram_stream_enabled", "Response streaming") if command == "/tools": return _handle_toggle(context, args, "telegram_tools_enabled", "Tool progress") - if command == "/speech": - return _handle_toggle(context, args, "telegram_speech_enabled", "Speech replies") if command == "/project": return _handle_project(context, args) if command == "/model": diff --git a/plugins/_telegram_integration/README.md b/plugins/_telegram_integration/README.md index c3c13aae2..a9793fa35 100644 --- a/plugins/_telegram_integration/README.md +++ b/plugins/_telegram_integration/README.md @@ -24,7 +24,6 @@ This plugin connects one or more Telegram bots to Agent Zero. Each bot runs inde - `/agent ` switches the active agent profile when the current run is idle. - `/model`, `/project`, and `/agent` show Telegram inline keyboard pickers when used without arguments. - `/stream` toggles live response streaming; `/tools` toggles tool progress messages. - - `/speech` toggles spoken replies. When enabled, final text replies also include Kokoro-generated Telegram audio when Kokoro TTS is available. - `/send` or `/queue send` flushes queued messages for the current chat. - `/steer ` sends an intervention to the active run. - **Group support** @@ -36,7 +35,6 @@ This plugin connects one or more Telegram bots to Agent Zero. Each bot runs inde - **Reply delivery** - Streams tool progress and response text through real Telegram messages updated with `editMessageText`. - Tool progress and the AI response are separate messages; only the AI response replies to the user message. - - Sends native Telegram media: photos, voice/audio files, videos, and document fallback. - `tool_execute_after` intercepts the `response` tool — sends `break_loop=false` updates as separate intermediate Telegram messages. - `process_chain_end` auto-sends the final response, with retry logic on failure. - **Formatting** @@ -65,7 +63,6 @@ This plugin connects one or more Telegram bots to Agent Zero. Each bot runs inde - `helpers/telegram_client.py` — Low-level Telegram API wrapper: send text/file/photo, Markdown→HTML converter, keyboard builder, message splitting. - `helpers/command_ui.py` — Telegram inline keyboard command pickers and callback handling. - `helpers/draft_stream.py` — Editable Telegram message streaming for tool progress and response previews. - - `helpers/speech.py` — Telegram speech-mode TTS synthesis and audio attachment generation. - **Extensions** - `extensions/python/job_loop/_10_telegram_bot.py` — Bot lifecycle manager, starts/stops bots on each tick. - `extensions/python/message_loop_start/_45_telegram_draft_start.py` — Initializes Telegram response streaming. diff --git a/plugins/_telegram_integration/extensions/python/process_chain_end/_55_telegram_reply.py b/plugins/_telegram_integration/extensions/python/process_chain_end/_55_telegram_reply.py index 582d444b2..508c3acf4 100644 --- a/plugins/_telegram_integration/extensions/python/process_chain_end/_55_telegram_reply.py +++ b/plugins/_telegram_integration/extensions/python/process_chain_end/_55_telegram_reply.py @@ -32,17 +32,7 @@ class TelegramAutoReply(Extension): try: if response_text: - from plugins._telegram_integration.helpers import speech - - speech_attachment, speech_warning = await speech.synthesize_attachment( - context, response_text - ) - if speech_attachment: - attachments = [*attachments, speech_attachment] await self._send_reply(context, response_text, attachments, keyboard) - warning = speech.consume_warning(context, speech_warning) - if warning: - await self._send_reply(context, warning, [], None) except Exception as e: PrintStyle.error(f"Telegram auto-reply error: {format_error(e)}") finally: diff --git a/plugins/_telegram_integration/helpers/command_ui.py b/plugins/_telegram_integration/helpers/command_ui.py index 6ceb3fe6e..a0f108d0f 100644 --- a/plugins/_telegram_integration/helpers/command_ui.py +++ b/plugins/_telegram_integration/helpers/command_ui.py @@ -13,7 +13,6 @@ from plugins._telegram_integration.helpers import telegram_client as tc from plugins._telegram_integration.helpers.constants import ( CTX_TG_STREAM_ENABLED, CTX_TG_TOOLS_ENABLED, - CTX_TG_SPEECH_ENABLED, CTX_TG_BOT, CTX_TG_CHAT_ID, CTX_TG_CHAT_TYPE, @@ -86,17 +85,6 @@ async def handle_command( args, ) return True - if command == "/speech": - await send_toggle_picker( - context, - token, - chat_id, - reply_to_message_id, - CTX_TG_SPEECH_ENABLED, - "Speech replies", - args, - ) - return True return False @@ -141,8 +129,9 @@ async def handle_callback( selected_context = await _select_session(context, _safe_int(value)) await edit_session_picker(selected_context or context, token, chat_id, message_id, 0, selected=bool(selected_context)) return True - if kind in {"stream", "tools", "speech"} and action in {"on", "off"}: - key, label = _toggle_key_label(kind) + if kind in {"stream", "tools"} and action in {"on", "off"}: + key = CTX_TG_STREAM_ENABLED if kind == "stream" else CTX_TG_TOOLS_ENABLED + label = "Response streaming" if kind == "stream" else "Tool progress" context.set_data(key, action == "on") save_tmp_chat(context) mark_dirty_for_context(context.id, reason=f"telegram.{kind}_toggle") @@ -345,7 +334,7 @@ def _agent_view( def _toggle_view(context: AgentContext, key: str, label: str) -> tuple[str, dict]: enabled = _toggle_enabled(context, key) - kind = _toggle_kind(key) + kind = "stream" if key == CTX_TG_STREAM_ENABLED else "tools" state = "enabled" if enabled else "disabled" text = f"{_html(label)}: {state}" rows = [[ @@ -553,27 +542,9 @@ def _paged_buttons( def _toggle_enabled(context: AgentContext, key: str) -> bool: value = context.get_data(key) - if key == CTX_TG_SPEECH_ENABLED: - return bool(value) return True if value is None else bool(value) -def _toggle_kind(key: str) -> str: - if key == CTX_TG_STREAM_ENABLED: - return "stream" - if key == CTX_TG_TOOLS_ENABLED: - return "tools" - return "speech" - - -def _toggle_key_label(kind: str) -> tuple[str, str]: - if kind == "stream": - return CTX_TG_STREAM_ENABLED, "Response streaming" - if kind == "tools": - return CTX_TG_TOOLS_ENABLED, "Tool progress" - return CTX_TG_SPEECH_ENABLED, "Speech replies" - - def _parse_toggle(args: str) -> bool | None: value = (args or "").strip().lower() if value in {"on", "enable", "enabled", "yes", "true", "1"}: diff --git a/plugins/_telegram_integration/helpers/constants.py b/plugins/_telegram_integration/helpers/constants.py index 9696f2661..190ee7fa4 100644 --- a/plugins/_telegram_integration/helpers/constants.py +++ b/plugins/_telegram_integration/helpers/constants.py @@ -13,7 +13,6 @@ CTX_TG_TYPING_STOP = "_telegram_typing_stop" CTX_TG_REPLY_TO = "_telegram_reply_to_message_id" CTX_TG_STREAM_ENABLED = "telegram_stream_enabled" CTX_TG_TOOLS_ENABLED = "telegram_tools_enabled" -CTX_TG_SPEECH_ENABLED = "telegram_speech_enabled" # Transient CTX_TG_ATTACHMENTS = "_telegram_response_attachments" @@ -26,4 +25,3 @@ CTX_TG_RESPONSE_LAST_UPDATE = "_telegram_response_last_update" CTX_TG_ERROR_SENT = "_telegram_error_sent" CTX_TG_HEARTBEAT_TASK = "_telegram_heartbeat_task" CTX_TG_HEARTBEAT_STOP = "_telegram_heartbeat_stop" -CTX_TG_SPEECH_WARNING_SENT = "_telegram_speech_warning_sent" diff --git a/plugins/_telegram_integration/helpers/speech.py b/plugins/_telegram_integration/helpers/speech.py deleted file mode 100644 index eb74a5e30..000000000 --- a/plugins/_telegram_integration/helpers/speech.py +++ /dev/null @@ -1,80 +0,0 @@ -from __future__ import annotations - -import re -import uuid - -from agent import AgentContext -from helpers import files -from plugins._telegram_integration.helpers.constants import ( - CTX_TG_SPEECH_ENABLED, - CTX_TG_SPEECH_WARNING_SENT, - DOWNLOAD_FOLDER, -) - - -MAX_SPEECH_CHARS = 1800 - - -def is_enabled(context: AgentContext) -> bool: - return bool(context.get_data(CTX_TG_SPEECH_ENABLED)) - - -async def synthesize_attachment(context: AgentContext, text: str) -> tuple[str | None, str | None]: - if not is_enabled(context): - return None, None - - speech_text = _speech_text(text) - if not speech_text: - return None, "Speech mode is enabled, but there was no readable text to speak." - - try: - from plugins._kokoro_tts.helpers import runtime - except Exception: - return None, _setup_message() - - if not runtime.is_globally_enabled(): - return None, _setup_message() - - try: - audio = await runtime.synthesize_sentences([speech_text]) - except Exception as exc: - return None, f"Speech mode is enabled, but text-to-speech failed: {exc}" - - if not audio: - return None, "Speech mode is enabled, but text-to-speech returned no audio." - - name = f"telegram_speech_{context.id}_{uuid.uuid4().hex[:10]}.wav" - relative_path = f"{DOWNLOAD_FOLDER}/{name}" - files.write_file_base64(relative_path, audio) - return files.get_abs_path_dockerized(relative_path), None - - -def consume_warning(context: AgentContext, warning: str | None) -> str | None: - if not warning: - context.data.pop(CTX_TG_SPEECH_WARNING_SENT, None) - return None - if context.data.get(CTX_TG_SPEECH_WARNING_SENT): - return None - context.data[CTX_TG_SPEECH_WARNING_SENT] = True - return warning - - -def _setup_message() -> str: - return ( - "Speech mode is enabled, but Kokoro TTS is not available. " - "Enable the Kokoro TTS plugin from Agent Zero settings, then try again." - ) - - -def _speech_text(text: str) -> str: - value = str(text or "") - value = re.sub(r"```.*?```", " ", value, flags=re.DOTALL) - value = re.sub(r"`([^`]+)`", r"\1", value) - value = re.sub(r"!\[([^\]]*)\]\([^)]+\)", r"\1", value) - value = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", value) - value = re.sub(r"<[^>]+>", " ", value) - value = re.sub(r"[*_#>\-]+", " ", value) - value = re.sub(r"\s+", " ", value).strip() - if len(value) > MAX_SPEECH_CHARS: - value = value[:MAX_SPEECH_CHARS].rstrip() - return value diff --git a/tests/test_telegram_speech_mode.py b/tests/test_telegram_speech_mode.py deleted file mode 100644 index 575cc2777..000000000 --- a/tests/test_telegram_speech_mode.py +++ /dev/null @@ -1,112 +0,0 @@ -import asyncio - -from plugins._telegram_integration.extensions.python.process_chain_end import ( - _55_telegram_reply, -) -from plugins._telegram_integration.helpers import command_ui, speech -from plugins._telegram_integration.helpers.constants import ( - CTX_TG_BOT, - CTX_TG_REPLY_TO, - CTX_TG_SPEECH_ENABLED, -) - - -class FakeContext: - id = "ctx-speech" - - def __init__(self): - self.data = {} - - def get_data(self, key): - return self.data.get(key) - - def set_data(self, key, value): - self.data[key] = value - - -class FakeAgent: - number = 0 - - def __init__(self): - self.context = FakeContext() - self.context.data[CTX_TG_BOT] = "main" - self.context.data[CTX_TG_REPLY_TO] = 456 - - -def test_speech_toggle_defaults_off(): - context = FakeContext() - - text, markup = command_ui._toggle_view(context, CTX_TG_SPEECH_ENABLED, "Speech replies") - - assert "Speech replies: disabled" == text - assert markup["inline_keyboard"][0][0]["callback_data"] == "tg:speech:on" - assert markup["inline_keyboard"][0][1]["callback_data"] == "tg:speech:off" - - -def test_speech_text_is_cleaned_for_tts(): - cleaned = speech._speech_text( - "**Hello** [`Agent Zero`](https://example.com)\n\n```python\nprint('skip')\n```" - ) - - assert cleaned == "Hello Agent Zero" - - -def test_speech_synthesis_writes_audio_attachment(monkeypatch): - context = FakeContext() - context.data[CTX_TG_SPEECH_ENABLED] = True - writes = [] - - class FakeRuntime: - @staticmethod - def is_globally_enabled(): - return True - - @staticmethod - async def synthesize_sentences(sentences): - assert sentences == ["Hello from Agent Zero."] - return "UklGRg==" - - monkeypatch.setattr(speech.files, "write_file_base64", lambda path, audio: writes.append((path, audio))) - monkeypatch.setattr(speech.files, "get_abs_path_dockerized", lambda path: f"/a0/{path}") - monkeypatch.setitem(__import__("sys").modules, "plugins._kokoro_tts.helpers.runtime", FakeRuntime) - - path, warning = asyncio.run(speech.synthesize_attachment(context, "Hello from Agent Zero.")) - - assert warning is None - assert path.startswith("/a0/usr/uploads/telegram_speech_ctx-speech_") - assert path.endswith(".wav") - assert writes[0][1] == "UklGRg==" - - -def test_final_reply_includes_speech_attachment(monkeypatch): - agent = FakeAgent() - sent = [] - - async def fake_synthesize(context, text): - return "/a0/usr/uploads/reply.wav", None - - async def fake_send_reply(context, response_text, attachments, keyboard): - sent.append( - { - "text": response_text, - "attachments": attachments, - "keyboard": keyboard, - } - ) - - monkeypatch.setattr(_55_telegram_reply, "_extract_last_response", lambda context: "Final reply.") - monkeypatch.setattr(speech, "synthesize_attachment", fake_synthesize) - - extension = _55_telegram_reply.TelegramAutoReply(agent=agent) - extension._send_reply = fake_send_reply - - asyncio.run(extension.execute()) - - assert sent == [ - { - "text": "Final reply.", - "attachments": ["/a0/usr/uploads/reply.wav"], - "keyboard": None, - } - ] - assert CTX_TG_REPLY_TO not in agent.context.data From 94065dbe8602d00218cc5839334dc8ce69bf8bf5 Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Wed, 3 Jun 2026 01:55:41 +0200 Subject: [PATCH 010/196] Fix document query index reuse Reuse the DocumentQueryStore per chat context so repeated document_query calls can see an existing vector DB instead of re-parsing and re-embedding the same document every time. Add a regression test that proves a second store lookup in the same context reuses the indexed document while a separate context stays isolated. --- .../_document_query/helpers/document_query.py | 21 ++++- tests/test_document_query_plugin.py | 90 ++++++++++++++++++- 2 files changed, 105 insertions(+), 6 deletions(-) diff --git a/plugins/_document_query/helpers/document_query.py b/plugins/_document_query/helpers/document_query.py index 4a7fa897a..05dd112e4 100644 --- a/plugins/_document_query/helpers/document_query.py +++ b/plugins/_document_query/helpers/document_query.py @@ -72,15 +72,30 @@ def _load_config(agent: Agent) -> dict: class DocumentQueryStore: """FAISS Store for document query results.""" + CONTEXT_DATA_KEY = "_document_query_store" DEFAULT_CHUNK_SIZE = 1000 DEFAULT_CHUNK_OVERLAP = 100 DEFAULT_MAX_INDEX_CHUNKS = 1200 + _GET_LOCK = threading.RLock() - @staticmethod - def get(agent: Agent): + @classmethod + def get(cls, agent: Agent): if not agent or not agent.config: raise ValueError("Agent and agent config must be provided") - return DocumentQueryStore(agent) + + context = getattr(agent, "context", None) + if context is None: + return cls(agent) + + with cls._GET_LOCK: + store = context.get_data(cls.CONTEXT_DATA_KEY, recursive=False) + if not isinstance(store, cls): + store = cls(agent) + context.set_data(cls.CONTEXT_DATA_KEY, store, recursive=False) + else: + store.agent = agent + store.config = _load_config(agent) + return store def __init__(self, agent: Agent): self.agent = agent diff --git a/tests/test_document_query_plugin.py b/tests/test_document_query_plugin.py index e3ece0ebd..1009edc45 100644 --- a/tests/test_document_query_plugin.py +++ b/tests/test_document_query_plugin.py @@ -1,13 +1,19 @@ from __future__ import annotations import asyncio +import sys from pathlib import Path import pytest from PIL import Image +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + from plugins._document_query import hooks as document_query_hooks from plugins._document_query.helpers.fetch import FetchedDocument, fetch_public_resource +import plugins._document_query.helpers.document_query as document_query_module from plugins._document_query.helpers.document_query import ( DocumentQueryHelper, DocumentQueryStore, @@ -19,9 +25,6 @@ from plugins._document_query.helpers.parsers.liteparse import LiteParseParser from plugins._document_query.helpers.parsers.text import TextParser -ROOT = Path(__file__).resolve().parents[1] - - def run_async(coro): with asyncio.Runner() as runner: return runner.run(coro) @@ -52,6 +55,52 @@ class CountingAsyncParser(BaseParser): return document.uri +class _StoreContext: + def __init__(self, context_id: str): + self.id = context_id + self.data = {} + + def get_data(self, key: str, recursive: bool = True): + return self.data.get(key) + + def set_data(self, key: str, value, recursive: bool = True): + self.data[key] = value + + +class _StoreAgent: + def __init__(self, context_id: str): + self.config = object() + self.context = _StoreContext(context_id) + + +class _FakeVectorDB: + def __init__(self): + self.docs = [] + + async def insert_documents(self, docs): + ids = [] + for doc in docs: + doc_id = f"doc-{len(self.docs)}" + doc.metadata["id"] = doc_id + ids.append(doc_id) + self.docs.append(doc) + return ids + + async def search_by_metadata(self, filter: str, limit: int = 0): + document_uri = filter.split("'", 2)[1] + docs = [ + doc + for doc in self.docs + if doc.metadata.get("document_uri") == document_uri + ] + return docs[:limit] if limit > 0 else docs + + async def delete_documents_by_ids(self, ids: list[str]): + removed = [doc for doc in self.docs if doc.metadata.get("id") in ids] + self.docs = [doc for doc in self.docs if doc.metadata.get("id") not in ids] + return removed + + def test_fetch_file_detects_mimetype_and_reads_once(tmp_path): document = tmp_path / "notes.txt" document.write_text("hello\nworld\n", encoding="utf-8") @@ -196,6 +245,41 @@ def test_document_query_allows_uncapped_index_chunks(): assert len(chunks) > 10 +def test_document_query_store_reuses_vector_db_per_context(monkeypatch): + monkeypatch.setattr( + document_query_module, + "_load_config", + lambda _agent: { + "chunk_size": 100, + "chunk_overlap": 10, + "max_index_chunks": 20, + }, + ) + monkeypatch.setattr( + DocumentQueryStore, + "init_vector_db", + lambda _self: _FakeVectorDB(), + ) + + agent = _StoreAgent("ctx-one") + store = DocumentQueryStore.get(agent) + + success, ids = run_async( + store.add_document("alpha beta gamma " * 20, "/tmp/book.txt") + ) + second_store = DocumentQueryStore.get(agent) + + assert success is True + assert ids + assert second_store is store + assert second_store.vector_db is store.vector_db + assert run_async(second_store.document_exists("/tmp/book.txt")) is True + + isolated_store = DocumentQueryStore.get(_StoreAgent("ctx-two")) + assert isolated_store is not store + assert run_async(isolated_store.document_exists("/tmp/book.txt")) is False + + def test_document_query_thumbnail_matches_plugin_hub_limits(): thumbnail = ROOT / "plugins" / "_document_query" / "webui" / "thumbnail.jpg" From 84ea6c27a122479a748d57fe46cba67223a5d8b4 Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Wed, 3 Jun 2026 03:18:01 +0200 Subject: [PATCH 011/196] Support folder attachments in chat composer Flatten dropped or selected folders into ordinary file attachments while preserving basename display and existing multi-file upload behavior. Make the composer attachment menu actions open their hidden file inputs directly, and label them as Attach files and Attach folder. --- .../chat/attachments/attachmentsStore.js | 83 +++++++++++++++++-- .../chat/attachments/dragDropOverlay.html | 2 +- .../components/chat/input/bottom-actions.html | 52 ++++++++---- 3 files changed, 113 insertions(+), 24 deletions(-) diff --git a/webui/components/chat/attachments/attachmentsStore.js b/webui/components/chat/attachments/attachmentsStore.js index e702c2afb..82e117b1d 100644 --- a/webui/components/chat/attachments/attachmentsStore.js +++ b/webui/components/chat/attachments/attachmentsStore.js @@ -112,12 +112,19 @@ const model = { // Handle drop document.addEventListener( "drop", - (e) => { - console.log("Drop detected with files:", e.dataTransfer.files.length); + async (e) => { + const dataTransfer = e.dataTransfer; + console.log("Drop detected with files:", dataTransfer?.files?.length || 0); dragCounter = 0; this.hideDragDropOverlay(); - const files = e.dataTransfer.files; + let files = []; + try { + files = await this.getDroppedFiles(dataTransfer); + } catch (error) { + console.error("Failed to read dropped files:", error); + files = Array.from(dataTransfer?.files || []); + } this.handleFiles(files); }, false @@ -209,10 +216,76 @@ const model = { event.target.value = ""; // clear uploader selection to fix issue where same file is ignored the second time }, + async getDroppedFiles(dataTransfer) { + const items = Array.from(dataTransfer?.items || []); + const entries = items + .map((item) => + typeof item.webkitGetAsEntry === "function" + ? item.webkitGetAsEntry() + : null + ) + .filter(Boolean); + + if (!entries.length) { + return Array.from(dataTransfer?.files || []); + } + + const nested = await Promise.all( + entries.map((entry) => this.readEntryFiles(entry)) + ); + const files = nested.flat(); + return files.length ? files : Array.from(dataTransfer?.files || []); + }, + + async readEntryFiles(entry) { + if (entry.isFile) { + return await new Promise((resolve, reject) => { + entry.file( + (file) => resolve([file]), + (error) => reject(error) + ); + }); + } + + if (!entry.isDirectory) return []; + + const reader = entry.createReader(); + const childEntries = await this.readAllDirectoryEntries(reader); + const nested = await Promise.all( + childEntries.map((child) => this.readEntryFiles(child)) + ); + return nested.flat(); + }, + + async readAllDirectoryEntries(reader) { + const entries = []; + + return await new Promise((resolve, reject) => { + const readBatch = () => { + reader.readEntries( + (batch) => { + if (!batch.length) { + resolve(entries); + return; + } + entries.push(...batch); + readBatch(); + }, + (error) => reject(error) + ); + }; + + readBatch(); + }); + }, + // File handling logic (moved from index.js) handleFiles(files) { - console.log("handleFiles called with", files.length, "files"); - Array.from(files).forEach((file) => { + const fileList = Array.from(files || []); + console.log("handleFiles called with", fileList.length, "files"); + fileList.forEach((file) => { + if (!file?.name) return; + console.log("Processing file:", file.name, file.type); const ext = file.name.split(".").pop().toLowerCase(); const isImage = ["jpg", "jpeg", "png", "bmp", "gif", "webp", "svg"].includes( diff --git a/webui/components/chat/attachments/dragDropOverlay.html b/webui/components/chat/attachments/dragDropOverlay.html index 7259d9c61..cc3126e0d 100644 --- a/webui/components/chat/attachments/dragDropOverlay.html +++ b/webui/components/chat/attachments/dragDropOverlay.html @@ -17,7 +17,7 @@ x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-300" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" class="dragdrop-overlay"> Drop files -
Drop files to attach them to your message
+
Drop files or folders to attach them to your message
diff --git a/webui/components/chat/input/bottom-actions.html b/webui/components/chat/input/bottom-actions.html index c1ce1e0bf..43aaedec1 100644 --- a/webui/components/chat/input/bottom-actions.html +++ b/webui/components/chat/input/bottom-actions.html @@ -11,20 +11,42 @@ -
+

Available models

-

Available models from selected provider

+

-
+ +
+
+ + +
+ + + +
+ +
+ Enter this code + + +
+ +
+ + + +
+ +
+ Sign-in started + +
+ +

+
+
- -
@@ -375,8 +349,7 @@ color: var(--color-text); } - .oauth-provider-list, - .oauth-provider-detail { + .oauth-provider-list { display: grid; gap: 12px; padding: 0; @@ -477,11 +450,11 @@ padding: 4px 0 0; } - .oauth-provider-detail-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; + .oauth-provider-row-detail { + display: grid; + grid-column: 1 / -1; + min-width: 0; + padding-top: 2px; } .oauth-detail-grid { @@ -1154,6 +1127,7 @@ @media (max-width: 720px) { .oauth-device, + .oauth-provider-row-detail, .oauth-provider-usage, .oauth-status-row, .oauth-model-grid, @@ -1162,15 +1136,10 @@ .oauth-details div, .oauth-manual-callback, .oauth-auth-attempt, - .oauth-provider-row, - .oauth-provider-detail-head { + .oauth-provider-row { grid-template-columns: 1fr; } - .oauth-provider-detail-head { - display: grid; - } - .oauth-section-head { flex-direction: column; } diff --git a/plugins/_oauth/webui/oauth-config-store.js b/plugins/_oauth/webui/oauth-config-store.js index 5e2b45695..9b5279d54 100644 --- a/plugins/_oauth/webui/oauth-config-store.js +++ b/plugins/_oauth/webui/oauth-config-store.js @@ -299,6 +299,34 @@ export const store = createStore("oauthConfig", { return status.account_label || status.email || "Connected"; }, + providerDevice(providerId) { + return this.devices[String(providerId || "")] || null; + }, + + providerShowSetupFields(providerId) { + if (this.providerConnected(providerId)) return false; + if (this.providerDevice(providerId)) return false; + return this.selectedProviderId === providerId; + }, + + providerShowNote(providerId) { + if (this.providerConnected(providerId)) return false; + const status = this.providerStatus(providerId); + return this.selectedProviderId === providerId && Boolean(status.warning || status.note); + }, + + providerDetailOpen(providerId) { + if (!this.isOauthProvider(providerId) || this.providerConnected(providerId)) return false; + if (this.providerDevice(providerId)) return true; + const status = this.providerStatus(providerId); + return this.selectedProviderId === providerId && Boolean( + status.supports_enterprise_domain + || status.supports_oauth_client_config + || status.warning + || status.note + ); + }, + providerReadinessLabel(providerId) { const status = this.providerStatus(providerId); if (this.loadingStatus) return "Checking"; diff --git a/tests/test_oauth_static.py b/tests/test_oauth_static.py index 0cdc2d394..fe9eb0b7d 100644 --- a/tests/test_oauth_static.py +++ b/tests/test_oauth_static.py @@ -52,9 +52,14 @@ def test_oauth_settings_exposes_provider_specific_controls_and_generic_copy(): assert "supports_quota_project" in config_html + store_js assert "OAuth client ID" in config_html assert "quota_project_id" in config_html + store_js - assert "submitManualCallback($store.oauthConfig.selectedProviderId)" in config_html - assert "cancelConnect($store.oauthConfig.selectedProviderId)" in config_html + assert "providerDetailOpen(card.provider_id)" in config_html + store_js + assert "providerDevice(card.provider_id)?.user_code" in config_html + assert "submitManualCallback(card.provider_id)" in config_html + assert "cancelConnect(card.provider_id)" in config_html + assert "selectedProvider()" not in config_html assert "oauth-auth-attempt" in config_html + assert "oauth-provider-row-detail" in config_html + assert "oauth-provider-detail" not in config_html assert "oauth-detail-metrics" not in config_html assert "Codex/ChatGPT Account" not in config_html + store_js assert "activeModelsDescription()" in config_html + store_js @@ -88,7 +93,8 @@ def test_oauth_available_models_list_sits_above_advanced_without_borders(): assert config_html.index("

Providers

") < config_html.index("Advanced") assert config_html.index("Available models") < config_html.index("Advanced") assert ".oauth-models-panel {\n display: grid;\n gap: 10px;\n padding: 0;\n border: 0;\n }" in config_html - assert ".oauth-provider-list,\n .oauth-provider-detail {\n display: grid;\n gap: 12px;\n padding: 0;\n border: 0;" in config_html + assert ".oauth-provider-list {\n display: grid;\n gap: 12px;\n padding: 0;\n border: 0;" in config_html + assert ".oauth-provider-row-detail {\n display: grid;\n grid-column: 1 / -1;" in config_html assert ".oauth-advanced {\n border: 0;\n border-radius: 0;\n padding: 0;\n }" in config_html model_chip_rule = config_html.split(".oauth-models span {", 1)[1].split("}", 1)[0] assert "border:" not in model_chip_rule From 85e28d0799b3aa4c9573b22606dce7a44a4b0a3d Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:52:58 +0200 Subject: [PATCH 019/196] Honor GitHub device-flow polling intervals Update the OAuth settings poller to wait for the provider interval, carry slow_down interval updates forward, and respect provider expiration times so GitHub Copilot device-code auth can complete after browser authorization. Dispatch the legacy device-login endpoint by provider_id and add regressions plus DOX guidance for provider-aware device polling. --- plugins/_oauth/AGENTS.md | 1 + plugins/_oauth/api/start_device_login.py | 26 ++++++++++- plugins/_oauth/webui/oauth-config-store.js | 42 ++++++++++++++--- tests/test_oauth_providers.py | 52 +++++++++++++++++++++- tests/test_oauth_static.py | 15 +++++++ 5 files changed, 127 insertions(+), 9 deletions(-) diff --git a/plugins/_oauth/AGENTS.md b/plugins/_oauth/AGENTS.md index 02f807262..1bbf87f9e 100644 --- a/plugins/_oauth/AGENTS.md +++ b/plugins/_oauth/AGENTS.md @@ -26,6 +26,7 @@ - Provider cards and model slot actions must be driven by backend provider status. Do not reintroduce hardcoded frontend provider lists or fallback provider catalogs. - OAuth account surfaces in settings, discovery, and onboarding must use the provider registry/status summary rather than Codex-only frontend state. - OAuth settings pending-auth controls such as device codes, manual callback input, and provider setup fields must render inline under the relevant provider row, not as a detached section below all providers. +- OAuth device-code polling must honor provider `interval`, `expires_at`, and `slow_down` updates; do not poll immediately or keep a stale fixed interval after a provider asks the client to slow down. - OAuth settings model slots must keep provider choice editable per slot, list only connected OAuth account providers, and persist the selected provider IDs into `chat_model.provider` and `utility_model.provider`. - `helpers/providers/registry.py` is the source of truth for connectable OAuth providers. - OAuth provider config must not expose the dummy `oauth` API key in `conf/model_providers.yaml`; the dummy key is a runtime-only shim supplied by the `get_api_key` extension after the account provider reports connected. diff --git a/plugins/_oauth/api/start_device_login.py b/plugins/_oauth/api/start_device_login.py index 04c9e4ab9..c93fb199b 100644 --- a/plugins/_oauth/api/start_device_login.py +++ b/plugins/_oauth/api/start_device_login.py @@ -6,4 +6,28 @@ from plugins._oauth.helpers.providers import CODEX_PROVIDER_ID, get_provider class StartDeviceLogin(ApiHandler): async def process(self, input: dict, request: Request) -> dict: - return get_provider(CODEX_PROVIDER_ID).start_login(input, request).to_dict() + raw_provider_id = _provider_id(input) + try: + return get_provider(raw_provider_id).start_login(input, request).to_dict() + except Exception as exc: + return { + "ok": False, + "provider_id": _provider_id_label(raw_provider_id), + "error": str(exc), + } + + +def _provider_id(input: dict) -> object: + if "provider_id" not in input or input.get("provider_id") is None: + return CODEX_PROVIDER_ID + value = input.get("provider_id") + if isinstance(value, str) and not value.strip(): + return CODEX_PROVIDER_ID + return value + + +def _provider_id_label(value: object) -> str: + if value is None: + return CODEX_PROVIDER_ID + text = str(value).strip() + return text or CODEX_PROVIDER_ID diff --git a/plugins/_oauth/webui/oauth-config-store.js b/plugins/_oauth/webui/oauth-config-store.js index 9b5279d54..6e2b2201d 100644 --- a/plugins/_oauth/webui/oauth-config-store.js +++ b/plugins/_oauth/webui/oauth-config-store.js @@ -758,9 +758,23 @@ export const store = createStore("oauthConfig", { startPolling(providerId = CODEX_PROVIDER) { this.stopPolling(providerId); this.pollStartedAt = { ...this.pollStartedAt, [providerId]: Date.now() }; + const clearTimer = () => { + if (this.pollTimers[providerId]) window.clearTimeout(this.pollTimers[providerId]); + const timers = { ...this.pollTimers }; + delete timers[providerId]; + this.pollTimers = timers; + if (providerId === CODEX_PROVIDER) this.pollTimer = null; + }; + const schedule = (delayMs) => { + clearTimer(); + this.pollTimers = { ...this.pollTimers, [providerId]: window.setTimeout(tick, delayMs) }; + if (providerId === CODEX_PROVIDER) this.pollTimer = this.pollTimers[providerId]; + }; const tick = async () => { + clearTimer(); const device = this.devices[providerId]; if (!device?.attempt_id) return; + let nextDelay = Math.max(1500, Number(device.interval || 5) * 1000); try { const response = await callJsonApi(POLL_LOGIN_API, { provider_id: providerId, @@ -782,6 +796,16 @@ export const store = createStore("oauthConfig", { void toastFrontendSuccess(`${this.providerLabel(providerId)} connected.`, "OAuth Connections"); return; } + if (response.interval || response.expires_at) { + const updatedDevice = { + ...device, + interval: response.interval || device.interval, + expires_at: response.expires_at || device.expires_at, + }; + this.devices = { ...this.devices, [providerId]: updatedDevice }; + if (providerId === CODEX_PROVIDER) this.device = updatedDevice; + nextDelay = Math.max(1500, Number(updatedDevice.interval || 5) * 1000); + } } catch (error) { if (this.connectingProvider === providerId) this.connectingProvider = ""; this.connecting = Boolean(this.connectingProvider); @@ -789,17 +813,21 @@ export const store = createStore("oauthConfig", { void toastFrontendError(messageOf(error), "OAuth Connections"); return; } - if (Date.now() - Number(this.pollStartedAt[providerId] || 0) > MAX_POLL_MS) { + const expiresAt = Number(this.devices[providerId]?.expires_at || 0); + const timedOut = expiresAt > 0 + ? Date.now() / 1000 > expiresAt + : Date.now() - Number(this.pollStartedAt[providerId] || 0) > MAX_POLL_MS; + if (timedOut) { if (this.connectingProvider === providerId) this.connectingProvider = ""; this.connecting = Boolean(this.connectingProvider); this.clearProviderDevice(providerId); this.stopPolling(providerId); + return; } + schedule(nextDelay); }; - const delay = Math.max(1500, Number(this.devices[providerId]?.interval || 5) * 1000); - this.pollTimers = { ...this.pollTimers, [providerId]: window.setInterval(tick, delay) }; - if (providerId === CODEX_PROVIDER) this.pollTimer = this.pollTimers[providerId]; - void tick(); + const initialDelay = Math.max(1500, Number(this.devices[providerId]?.interval || 5) * 1000); + schedule(initialDelay); }, pollProvider(providerId = CODEX_PROVIDER) { @@ -834,7 +862,7 @@ export const store = createStore("oauthConfig", { stopPolling(providerId = "") { if (providerId) { - if (this.pollTimers[providerId]) window.clearInterval(this.pollTimers[providerId]); + if (this.pollTimers[providerId]) window.clearTimeout(this.pollTimers[providerId]); const timers = { ...this.pollTimers }; delete timers[providerId]; this.pollTimers = timers; @@ -846,7 +874,7 @@ export const store = createStore("oauthConfig", { } for (const timer of Object.values(this.pollTimers || {})) { - if (timer) window.clearInterval(timer); + if (timer) window.clearTimeout(timer); } this.pollTimers = {}; this.pollStartedAt = {}; diff --git a/tests/test_oauth_providers.py b/tests/test_oauth_providers.py index baf1da369..155c2bcba 100644 --- a/tests/test_oauth_providers.py +++ b/tests/test_oauth_providers.py @@ -540,7 +540,7 @@ def test_manual_callback_dispatches_to_xai_provider_without_active_attempt(): assert "no active xai grok sign-in attempt" in response["error"].lower() -def test_start_device_login_wrapper_calls_codex_provider(monkeypatch): +def test_start_device_login_wrapper_defaults_to_codex_provider(monkeypatch): calls = [] class FakeProvider: @@ -576,6 +576,56 @@ def test_start_device_login_wrapper_calls_codex_provider(monkeypatch): assert response["attempt_id"] == "attempt-1" +def test_start_device_login_with_provider_id_calls_github_provider(monkeypatch): + calls = [] + + class FakeProvider: + def start_login(self, input, request): + calls.append((input, request)) + return LoginStartResult( + ok=True, + provider_id=GITHUB_COPILOT_PROVIDER_ID, + flow="device_code", + attempt_id="github-attempt-1", + verification_url="https://github.com/login/device", + user_code="1234-5678", + ) + + monkeypatch.setattr( + start_device_login_api, + "get_provider", + lambda provider_id: calls.append(("provider_id", provider_id)) or FakeProvider(), + ) + + request = FakeRequest() + payload = {"provider_id": GITHUB_COPILOT_PROVIDER_ID, "enterprise_domain": ""} + response = asyncio.run( + start_device_login_api.StartDeviceLogin(None, None).process(payload, request) + ) + + assert calls[0] == ("provider_id", GITHUB_COPILOT_PROVIDER_ID) + assert calls[1] == (payload, request) + assert response["ok"] is True + assert response["provider_id"] == GITHUB_COPILOT_PROVIDER_ID + assert response["flow"] == "device_code" + assert response["attempt_id"] == "github-attempt-1" + assert response["verification_url"] == "https://github.com/login/device" + assert response["user_code"] == "1234-5678" + + +def test_start_device_login_unknown_provider_returns_structured_error(): + response = asyncio.run( + start_device_login_api.StartDeviceLogin(None, None).process( + {"provider_id": "missing"}, + FakeRequest(), + ) + ) + + assert response["ok"] is False + assert response["provider_id"] == "missing" + assert "Unknown OAuth provider" in response["error"] + + def test_poll_device_login_wrapper_calls_codex_provider(monkeypatch): calls = [] diff --git a/tests/test_oauth_static.py b/tests/test_oauth_static.py index fe9eb0b7d..155681e68 100644 --- a/tests/test_oauth_static.py +++ b/tests/test_oauth_static.py @@ -126,6 +126,21 @@ def test_browser_callback_completion_is_observed_from_modal(): assert "this.providerConnected(providerId)" in store_js +def test_device_polling_honors_provider_interval_updates(): + store_js = (PROJECT_ROOT / "plugins/_oauth/webui/oauth-config-store.js").read_text(encoding="utf-8") + + start_polling = store_js.split("startPolling(providerId = CODEX_PROVIDER)", 1)[1].split( + "pollProvider(providerId = CODEX_PROVIDER)", + 1, + )[0] + assert "window.setTimeout(tick, delayMs)" in start_polling + assert "window.setInterval(tick" not in start_polling + assert "void tick();" not in start_polling + assert "interval: response.interval || device.interval" in start_polling + assert "expires_at: response.expires_at || device.expires_at" in start_polling + assert "Date.now() / 1000 > expiresAt" in start_polling + + def test_usage_plan_catalog_stays_backend_only_on_oauth_settings_page(): config_html = (PROJECT_ROOT / "plugins/_oauth/webui/config.html").read_text(encoding="utf-8") store_js = (PROJECT_ROOT / "plugins/_oauth/webui/oauth-config-store.js").read_text(encoding="utf-8") From ca4efe6e6ac27905482f2995264c0bd5c4305832 Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:52:49 +0200 Subject: [PATCH 020/196] Fix Tailscale Remote Control CSRF origins Normalize active Remote Control URLs to same-origin values before adding them to CSRF allowlists, so Tailscale Funnel URLs with paths or trailing slashes can bootstrap tokens correctly. Allow WebSocket origin validation to trust only the currently active Remote Control origin, including Docker split-process tunnel service URLs, while preserving rejection for unrelated external origins. Add focused regression coverage for active Tailscale-style origins, tunnel-service origin lookup, and negative cross-origin cases; keep run_ui decorator re-exports compatible with existing CSRF tests. --- api/csrf_token.py | 14 ++- helpers/tunnel_origins.py | 107 +++++++++++++++++++++ helpers/ws.py | 8 +- run_ui.py | 1 + tests/test_csrf_tunnel_origins.py | 153 ++++++++++++++++++++++++++++++ tests/test_ws_csrf.py | 46 +++++++++ 6 files changed, 320 insertions(+), 9 deletions(-) create mode 100644 helpers/tunnel_origins.py create mode 100644 tests/test_csrf_tunnel_origins.py diff --git a/api/csrf_token.py b/api/csrf_token.py index 5f4ba13d5..6cbd355c0 100644 --- a/api/csrf_token.py +++ b/api/csrf_token.py @@ -1,5 +1,4 @@ import secrets -from urllib.parse import urlparse from helpers.api import ( ApiHandler, Input, @@ -9,6 +8,7 @@ from helpers.api import ( session, ) from helpers import runtime, dotenv, login +from helpers.tunnel_origins import origin_from_url import fnmatch ALLOWED_ORIGINS_KEY = "ALLOWED_ORIGINS" @@ -82,11 +82,7 @@ class GetCsrfToken(ApiHandler): ) if not r: return None - # parse and normalize - p = urlparse(r) - if not p.scheme or not p.hostname: - return None - return f"{p.scheme}://{p.hostname}" + (f":{p.port}" if p.port else "") + return origin_from_url(r) async def get_allowed_origins(self) -> list[str]: # get the allowed origins from the environment @@ -107,8 +103,10 @@ class GetCsrfToken(ApiHandler): from api.tunnel_proxy import process as tunnel_api_process tunnel = await tunnel_api_process({"action": "get"}) - if tunnel and isinstance(tunnel, dict) and tunnel["success"]: - allowed_origins.append(tunnel["tunnel_url"]) + if tunnel and isinstance(tunnel, dict) and tunnel.get("success"): + tunnel_origin = origin_from_url(tunnel.get("tunnel_url")) + if tunnel_origin: + allowed_origins.append(tunnel_origin) except Exception: pass diff --git a/helpers/tunnel_origins.py b/helpers/tunnel_origins.py new file mode 100644 index 000000000..c24d8e997 --- /dev/null +++ b/helpers/tunnel_origins.py @@ -0,0 +1,107 @@ +import json +import urllib.request +from urllib.parse import urlparse + + +_DEFAULT_PORTS = { + "http": 80, + "https": 443, + "ws": 80, + "wss": 443, +} + + +def origin_from_url(value): + """Normalize a URL or Origin header to scheme://host[:port].""" + if not isinstance(value, str) or not value.strip(): + return None + parsed = urlparse(value.strip()) + if not parsed.scheme or not parsed.hostname: + return None + + scheme = parsed.scheme.lower() + host = parsed.hostname.lower() + try: + port = parsed.port + except ValueError: + return None + + origin = f"{scheme}://{host}" + if port and port != _DEFAULT_PORTS.get(scheme): + origin += f":{port}" + return origin + + +def origin_key(value): + """Return a comparable same-origin tuple including default ports.""" + origin = origin_from_url(value) + if not origin: + return None + parsed = urlparse(origin) + try: + port = parsed.port or _DEFAULT_PORTS.get(parsed.scheme) + except ValueError: + return None + if not parsed.scheme or not parsed.hostname or port is None: + return None + return parsed.scheme, parsed.hostname.lower(), int(port) + + +def get_active_tunnel_origins(): + """Return normalized origins for currently active Remote Control URLs.""" + origins = [] + + try: + from helpers.tunnel_manager import TunnelManager + + tunnel_url = TunnelManager.get_instance().get_tunnel_url() + _append_origin(origins, tunnel_url) + except Exception: + pass + + try: + _append_origin(origins, _get_tunnel_service_url()) + except Exception: + pass + + return origins + + +def _append_origin(origins, url): + origin = origin_from_url(url) + if origin and origin not in origins: + origins.append(origin) + + +def _get_tunnel_service_url(): + try: + from helpers import dotenv, runtime + + should_query_service = bool( + runtime.is_dockerized() + or runtime.get_arg("tunnel_api_port") + or dotenv.get_dotenv_value("TUNNEL_API_PORT") + ) + if not should_query_service: + return None + + port = runtime.get_tunnel_api_port() + except Exception: + return None + + body = json.dumps({"action": "get"}).encode("utf-8") + request = urllib.request.Request( + f"http://localhost:{port}/", + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=0.35) as response: + payload = json.loads(response.read().decode("utf-8", errors="replace")) + except Exception: + return None + + if isinstance(payload, dict) and payload.get("success"): + return payload.get("tunnel_url") + return None diff --git a/helpers/ws.py b/helpers/ws.py index 1150aa8dc..5ca1cc79c 100644 --- a/helpers/ws.py +++ b/helpers/ws.py @@ -13,6 +13,7 @@ from flask import Flask, session, request from helpers import files, cache from helpers.print_style import PrintStyle from helpers.errors import format_error +from helpers.tunnel_origins import get_active_tunnel_origins, origin_key if TYPE_CHECKING: from helpers.ws_manager import WsManager @@ -148,6 +149,11 @@ def validate_ws_origin(environ: dict[str, Any]) -> tuple[bool, str | None]: if origin_host == host and origin_port == port: return True, None + request_origin_key = (origin_parsed.scheme, origin_host, int(origin_port)) + for active_origin in get_active_tunnel_origins(): + if origin_key(active_origin) == request_origin_key: + return True, None + if origin_host not in {host for host, _ in candidates}: return False, "origin_host_mismatch" return False, "origin_port_mismatch" @@ -648,4 +654,4 @@ def _error_response(code: str, message: str, "ok": False, "error": {"code": code, "error": message}, }], - } \ No newline at end of file + } diff --git a/run_ui.py b/run_ui.py index 4edf94b8b..8346d1b47 100644 --- a/run_ui.py +++ b/run_ui.py @@ -1,5 +1,6 @@ import initialize from helpers import dotenv, extension, runtime +from helpers.api import csrf_protect, requires_auth from helpers.print_style import PrintStyle from helpers.server_startup import run_uvicorn_with_retries from helpers.ui_server import UiServerRuntime, configure_process_environment diff --git a/tests/test_csrf_tunnel_origins.py b/tests/test_csrf_tunnel_origins.py new file mode 100644 index 000000000..b2c0af92d --- /dev/null +++ b/tests/test_csrf_tunnel_origins.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import json +from types import SimpleNamespace + +import pytest +from flask import Flask + + +@pytest.mark.asyncio +async def test_csrf_token_allows_normalized_active_tailscale_origin(monkeypatch): + import api.csrf_token as csrf_module + import api.tunnel_proxy as tunnel_proxy + + handler = csrf_module.GetCsrfToken(Flask("test_csrf_tunnel_origins"), None) + request = SimpleNamespace( + headers={"Origin": "https://agent-zero.tailabc.ts.net"}, + environ={}, + referrer=None, + ) + + monkeypatch.setattr(csrf_module.login, "is_login_required", lambda: False) + monkeypatch.setattr( + csrf_module.dotenv, + "get_dotenv_value", + lambda key: "http://localhost:32080" + if key == csrf_module.ALLOWED_ORIGINS_KEY + else "", + ) + + async def fake_tunnel_process(input_data): + return { + "success": True, + "tunnel_url": "https://agent-zero.tailabc.ts.net/funnel-ready/", + "is_running": True, + } + + monkeypatch.setattr(tunnel_proxy, "process", fake_tunnel_process) + + origin_check = await handler.check_allowed_origin(request) + + assert origin_check["ok"] is True + assert "https://agent-zero.tailabc.ts.net" in origin_check["allowed_origins"] + + +@pytest.mark.asyncio +async def test_csrf_token_rejects_unrelated_origin_with_active_tunnel(monkeypatch): + import api.csrf_token as csrf_module + import api.tunnel_proxy as tunnel_proxy + + handler = csrf_module.GetCsrfToken(Flask("test_csrf_tunnel_origins"), None) + request = SimpleNamespace( + headers={"Origin": "https://evil.example"}, + environ={}, + referrer=None, + ) + + monkeypatch.setattr(csrf_module.login, "is_login_required", lambda: False) + monkeypatch.setattr( + csrf_module.dotenv, + "get_dotenv_value", + lambda key: "http://localhost:32080" + if key == csrf_module.ALLOWED_ORIGINS_KEY + else "", + ) + + async def fake_tunnel_process(input_data): + return { + "success": True, + "tunnel_url": "https://agent-zero.tailabc.ts.net/funnel-ready/", + "is_running": True, + } + + monkeypatch.setattr(tunnel_proxy, "process", fake_tunnel_process) + + origin_check = await handler.check_allowed_origin(request) + + assert origin_check["ok"] is False + + +def test_active_tunnel_origins_include_docker_tunnel_service_url(monkeypatch): + import helpers.tunnel_origins as tunnel_origins + + monkeypatch.setattr( + tunnel_origins, + "_get_tunnel_service_url", + lambda: "https://agent-zero.tailabc.ts.net/funnel-ready/", + ) + + assert ( + "https://agent-zero.tailabc.ts.net" + in tunnel_origins.get_active_tunnel_origins() + ) + + +def test_tunnel_service_url_uses_short_local_get_request(monkeypatch): + from helpers import dotenv, runtime + import helpers.tunnel_origins as tunnel_origins + + captured = {} + + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return None + + def read(self): + return json.dumps({ + "success": True, + "tunnel_url": "https://agent-zero.tailabc.ts.net/funnel-ready/", + }).encode("utf-8") + + def fake_urlopen(request, timeout): + captured["url"] = request.full_url + captured["body"] = request.data + captured["method"] = request.get_method() + captured["timeout"] = timeout + return FakeResponse() + + monkeypatch.setattr( + runtime, + "is_dockerized", + lambda: True, + ) + monkeypatch.setattr( + runtime, + "get_arg", + lambda name: None, + ) + monkeypatch.setattr( + runtime, + "get_tunnel_api_port", + lambda: 55520, + ) + monkeypatch.setattr( + dotenv, + "get_dotenv_value", + lambda key: "", + ) + monkeypatch.setattr(tunnel_origins.urllib.request, "urlopen", fake_urlopen) + + assert ( + tunnel_origins._get_tunnel_service_url() + == "https://agent-zero.tailabc.ts.net/funnel-ready/" + ) + assert captured == { + "url": "http://localhost:55520/", + "body": b'{"action": "get"}', + "method": "POST", + "timeout": 0.35, + } diff --git a/tests/test_ws_csrf.py b/tests/test_ws_csrf.py index 48e7e8230..a5fb7ba70 100644 --- a/tests/test_ws_csrf.py +++ b/tests/test_ws_csrf.py @@ -49,3 +49,49 @@ def test_validate_ws_origin_rejects_cross_origin(): ) assert ok is False assert reason == "origin_host_mismatch" + + +def test_validate_ws_origin_allows_active_tunnel_origin_with_local_upstream_host( + monkeypatch, +): + import helpers.ws as ws + + monkeypatch.setattr( + ws, + "get_active_tunnel_origins", + lambda: ["https://agent-zero.tailabc.ts.net"], + raising=False, + ) + + ok, reason = validate_ws_origin( + { + "HTTP_ORIGIN": "https://agent-zero.tailabc.ts.net", + "HTTP_HOST": "127.0.0.1:80", + } + ) + + assert ok is True + assert reason is None + + +def test_validate_ws_origin_rejects_unrelated_origin_with_active_tunnel( + monkeypatch, +): + import helpers.ws as ws + + monkeypatch.setattr( + ws, + "get_active_tunnel_origins", + lambda: ["https://agent-zero.tailabc.ts.net"], + raising=False, + ) + + ok, reason = validate_ws_origin( + { + "HTTP_ORIGIN": "https://evil.example", + "HTTP_HOST": "127.0.0.1:80", + } + ) + + assert ok is False + assert reason == "origin_host_mismatch" From 16f724226a10f94e60f8446ed34b6de8598cd522 Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:16:39 +0200 Subject: [PATCH 021/196] Fix Editor manual open behavior Open the Editor file browser from the active project context before falling back to the configured workdir. Keep manual Editor launches on the empty start page instead of auto-creating blank Markdown files, while preserving explicit Markdown creation from the empty state. Add static regression coverage for both behaviors. --- plugins/_editor/webui/editor-store.js | 25 +++++++++---------------- tests/test_office_canvas_setup.py | 21 +++++++++++++++++++-- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/plugins/_editor/webui/editor-store.js b/plugins/_editor/webui/editor-store.js index e0e3c3682..c4293320a 100644 --- a/plugins/_editor/webui/editor-store.js +++ b/plugins/_editor/webui/editor-store.js @@ -225,7 +225,6 @@ const model = { _previewEnhanceTimer: null, _staticHighlightPromise: null, _pendingPreviewFragment: "", - _initialCreatePromise: null, async init() { if (this._initialized) return; @@ -244,7 +243,6 @@ const model = { this._mode = options?.mode === "canvas" ? "canvas" : "modal"; if (this._mode === "modal") { this.setupMarkdownModal(element); - await this.ensureInitialMarkdownFile(); } this.scheduleSourceEditorInit(); }, @@ -261,7 +259,6 @@ const model = { }); return; } - await this.ensureInitialMarkdownFile(); }, beforeHostHidden() { @@ -848,24 +845,20 @@ const model = { }); }, - async ensureInitialMarkdownFile() { - if (this.session || this.visibleTabs().length > 0 || this.loading) return null; - if (!this._root || this._initialCreatePromise) return this._initialCreatePromise; - this._initialCreatePromise = this.create("document", "md").finally(() => { - this._initialCreatePromise = null; - }); - return await this._initialCreatePromise; - }, - async openFileBrowser() { let workdirPath = "/a0/usr/workdir"; try { - const response = await callJsonApi("settings_get", null); - workdirPath = response?.settings?.workdir_path || workdirPath; + const home = await callEditor("home"); + if (home?.path) { + workdirPath = home.path; + } else { + const response = await callJsonApi("settings_get", null); + workdirPath = response?.settings?.workdir_path || workdirPath; + } } catch { try { - const home = await callEditor("home"); - workdirPath = home?.path || workdirPath; + const response = await callJsonApi("settings_get", null); + workdirPath = response?.settings?.workdir_path || workdirPath; } catch { // The file browser can still open with the static fallback. } diff --git a/tests/test_office_canvas_setup.py b/tests/test_office_canvas_setup.py index 28935ba67..b4c398a80 100644 --- a/tests/test_office_canvas_setup.py +++ b/tests/test_office_canvas_setup.py @@ -140,10 +140,13 @@ def test_right_canvas_uses_desktop_surface_id_and_migrates_legacy_office_state() assert "editor-preview-title" in editor_web_panel assert "editor-preview-page-editor" in editor_web_panel assert "editor-table-wrap" in editor_web_panel + assert "editor-empty" in editor_web_panel + assert "runNewMenuAction('open')" in editor_web_panel + assert "runNewMenuAction('markdown')" in editor_web_panel assert "closeAllFiles" in editor_store assert "confirmPendingClose" in editor_store - assert "ensureInitialMarkdownFile" in editor_store - assert "await this.ensureInitialMarkdownFile();" in editor_store + assert "ensureInitialMarkdownFile" not in editor_store + assert "_initialCreatePromise" not in editor_store assert "startPreviewEdit" in editor_store assert "applyPreviewEdit" in editor_store assert "previewEditDirty" in editor_store @@ -502,6 +505,20 @@ def test_editor_plugin_owns_markdown_sessions_and_active_context_extras(): assert "syncTextEditorResultsIntoOpenEditor" in editor_result_sync +def test_editor_open_file_browser_prefers_context_home_before_workdir_fallback(): + editor_store = read("plugins", "_editor", "webui", "editor-store.js") + start = editor_store.index("async openFileBrowser()") + end = editor_store.index("\n async openPath", start) + open_file_browser = editor_store[start:end] + + home_lookup = open_file_browser.index('const home = await callEditor("home");') + settings_fallback = open_file_browser.index('const response = await callJsonApi("settings_get", null);') + + assert home_lookup < settings_fallback + assert "workdirPath = home.path;" in open_file_browser + assert "workdirPath = response?.settings?.workdir_path || workdirPath;" in open_file_browser + + def test_office_and_desktop_skills_are_rehomed_and_renamed(): office_skills = PROJECT_ROOT / "plugins" / "_office" / "skills" desktop_skills = PROJECT_ROOT / "plugins" / "_desktop" / "skills" From 8c6157301926495ed4319776ef03a28505269016 Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:21:23 +0200 Subject: [PATCH 022/196] Polish Editor toolbar actions Move the Editor preview/source toggle into the left-side toolbar cluster so mode switching sits with editing controls. Add a dedicated right-side Save button and remove Save from the overflow menu, leaving the menu for rename and close actions. Cover the toolbar placement with a static regression test. --- plugins/_editor/webui/editor-panel.html | 26 ++++++++++++++++--------- tests/test_office_canvas_setup.py | 25 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/plugins/_editor/webui/editor-panel.html b/plugins/_editor/webui/editor-panel.html index 318aed2e5..310a8f7a3 100644 --- a/plugins/_editor/webui/editor-panel.html +++ b/plugins/_editor/webui/editor-panel.html @@ -79,6 +79,16 @@