Skip to content
372 changes: 357 additions & 15 deletions helpers/integration_commands.py

Large diffs are not rendered by default.

26 changes: 21 additions & 5 deletions plugins/_telegram_integration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,26 @@ 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 <name>` switches the active project for the current chat.
- `/config <preset>` switches the active model preset for the current chat.
- `/send` or `/queue send` flushes the queued messages for the current chat.
- `/model <preset>` switches the active model preset for the current chat (`/config` remains an alias).
- `/agent <profile>` 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 <message>` 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.
- **Message handling**
- 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**
- `tool_execute_after` intercepts the `response` tool — sends inline progress for `break_loop=false`.
- 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 `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).
Expand All @@ -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 <message>` 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**
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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):
Comment thread
3clyp50 marked this conversation as resolved.
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, heartbeat
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()
await heartbeat.stop(context)
draft_stream.clear(context)
context.data.pop(CTX_TG_REPLY_TO, None)
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,13 @@ async def execute(self, **kwargs: Any) -> None:
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,
Expand Down Expand Up @@ -73,7 +73,6 @@ async def execute(self, **kwargs: Any) -> None:
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)
Expand All @@ -83,14 +82,14 @@ async def execute(self, **kwargs: Any) -> None:
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,
group_mode=bot_cfg.get("group_mode", "mention"),
)

await cache_bot_info(instance)
await register_bot_commands(instance)

mode = bot_cfg.get("mode", "polling")
if mode == "webhook":
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
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):
Comment thread
3clyp50 marked this conversation as resolved.
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, heartbeat

await draft_stream.start(context)
await heartbeat.start(context)
Original file line number Diff line number Diff line change
Expand Up @@ -26,21 +26,24 @@ async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
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:
# Cancel typing and clean up reply_to after final send
typing_stop = context.data.pop(CTX_TG_TYPING_STOP, None)
if typing_stop:
typing_stop.set()
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)

async def _send_reply(
Expand Down
Original file line number Diff line number Diff line change
@@ -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(),
Comment thread
3clyp50 marked this conversation as resolved.
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))
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -18,6 +19,7 @@ async def execute(
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, {})
Expand All @@ -29,3 +31,18 @@ async def execute(
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, integration="telegram"):
definition = integration_commands.resolve_command(name, integration="telegram")
if definition:
args = f" {definition.args_hint}" if definition.args_hint else ""
lines.append(f"- /{definition.name}{args}: {definition.description}")
return "\n".join(lines)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -43,6 +47,7 @@ async def execute(
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
Expand All @@ -51,7 +56,12 @@ async def _send_inline(self, context, tool, response: Response):
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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {})
22 changes: 18 additions & 4 deletions plugins/_telegram_integration/helpers/bot_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
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
Expand Down Expand Up @@ -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,
Expand All @@ -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(integration="telegram")),
)

if on_callback_query:
Expand Down Expand Up @@ -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:
Expand Down
Loading