feat/fix(telegram): Frontend Input Persistence Fix & Webhook Backend Refactor

- Replaced :value reactive binding on Allowed Users and User→Project Mapping inputs with x-effect and focus guard, ensuring Alpine.js rerenders don't clear user-typed values while editing. - Enhanced setUserProjects() to preserve incomplete entries (e.g., 123=), preventing accidental data loss. - Refactored Default Project <select> persistence: replaced x-model with a x-effect + @change one-way binding, fixing value resets before option loading completes. - Removed standalone aiohttp HTTP server for webhook mode; switched to Agent Zero's built-in API handler at /api/plugins/_telegram_integration/webhook. - Added a new unauthenticated POST handler (api/webhook.py) that receives Telegram updates and feeds them to aiogram's Dispatcher. - Webhook URLs are auto-constructed from the base URL and API path—no additional port or reverse proxy configuration required. - Added webhook_secret verification in the API handler for improved security. - bot.delete_webhook() is now called before starting polling mode to prevent TelegramConflictError races. - Improved bot lifecycle: detects active webhook mode via webhook_active flag to avoid restart loops on each job_loop tick. - Removed unused aiohttp/SimpleRequestHandler code.

keyboardstaff committed Mar 21, 2026 at 00:46 UTC fc787ea2c488f1fd5e38bacf93fecbc353f3b860
6 files changed +94 -32
plugins/_telegram_integration/api/webhook.py new
+47
@@ -0,0 +1,47 @@
1 +from helpers.api import ApiHandler, Request, Response
2 +from helpers.print_style import PrintStyle
3 +
4 +
5 +class TelegramWebhook(ApiHandler):
6 + """Receives Telegram webhook updates. No auth/CSRF — Telegram cannot send session cookies."""
7 +
8 + @classmethod
9 + def requires_auth(cls) -> bool:
10 + return False
11 +
12 + @classmethod
13 + def requires_csrf(cls) -> bool:
14 + return False
15 +
16 + @classmethod
17 + def get_methods(cls) -> list[str]:
18 + return ["POST"]
19 +
20 + async def process(self, input: dict, request: Request) -> dict | Response:
21 + from aiogram.types import Update
22 +
23 + from plugins._telegram_integration.helpers.bot_manager import get_bot, get_all_bots
24 +
25 + # Identify which bot this update is for
26 + bot_name = request.args.get("bot", "")
27 + if not bot_name:
28 + return Response("Missing ?bot= parameter", 400)
29 +
30 + instance = get_bot(bot_name)
31 + if not instance:
32 + return Response(f"Bot not found: {bot_name}", 404)
33 +
34 + # Verify webhook secret if configured
35 + secret_header = request.headers.get("X-Telegram-Bot-Api-Secret-Token", "")
36 + if instance.webhook_secret and secret_header != instance.webhook_secret:
37 + return Response("Invalid secret token", 403)
38 +
39 + # Parse and feed the update to aiogram
40 + try:
41 + update = Update.model_validate(input, context={"bot": instance.bot})
42 + await instance.dispatcher.feed_update(instance.bot, update)
43 + except Exception as e:
44 + PrintStyle.error(f"Telegram webhook ({bot_name}): {e}")
45 + return Response("Internal error", 500)
46 +
47 + return {"ok": True}
plugins/_telegram_integration/default_config.yaml
+1 -1
@@ -4,7 +4,7 @@ bots: []
4 # enabled: true
5 # token: ""
6 # mode: polling # polling or webhook
7 -# webhook_url: "" # required if mode=webhook, e.g. https://yourdomain.com/telegram/my_bot
7 +# webhook_url: "" # required if mode=webhook, your A0 base URL e.g. https://yourdomain.com (webhook path appended automatically)
8 # webhook_secret: "" # optional shared secret for webhook verification
9 # allowed_users: [] # Telegram user IDs or @usernames. Empty = allow all.
10 # group_mode: mention # mention (respond when @mentioned or replied to) | all (respond to every message) | off (ignore groups)
plugins/_telegram_integration/extensions/python/job_loop/_10_telegram_bot.py
+18 -6
@@ -52,8 +52,11 @@ class TelegramBotManager(Extension):
52 continue
53 if name in running:
54 inst = running[name]
55 - if inst.task and not inst.task.done():
56 - continue # already running
55 + # Already running: polling (task alive) or webhook (active)
56 + if (inst.task and not inst.task.done()) or inst.webhook_active:
57 + continue
58 + # Instance exists but is dead — stop and recreate
59 + await stop_bot(name)
60
61 try:
62 # Create handler closures that capture bot_name and config
@@ -97,21 +100,30 @@ class TelegramBotManager(Extension):
100
101 # Wrapper functions for aiogram handlers
102
103 +def _get_current_bot_cfg(bot_name: str) -> dict:
104 + """Fetch the latest bot config by name, so handlers always use fresh settings."""
105 + config = plugins.get_plugin_config(PLUGIN_NAME) or {}
106 + for b in config.get("bots", []):
107 + if b.get("name") == bot_name:
108 + return b
109 + return {}
110 +
111 +
112 async def _wrap_start(message, bot_name: str, bot_cfg: dict):
113 from plugins._telegram_integration.helpers.handler import handle_start
102 - await handle_start(message, bot_name, bot_cfg)
114 + await handle_start(message, bot_name, _get_current_bot_cfg(bot_name) or bot_cfg)
115
116
117 async def _wrap_clear(message, bot_name: str, bot_cfg: dict):
118 from plugins._telegram_integration.helpers.handler import handle_clear
107 - await handle_clear(message, bot_name, bot_cfg)
119 + await handle_clear(message, bot_name, _get_current_bot_cfg(bot_name) or bot_cfg)
120
121
122 async def _wrap_message(message, bot_name: str, bot_cfg: dict):
123 from plugins._telegram_integration.helpers.handler import handle_message
112 - await handle_message(message, bot_name, bot_cfg)
124 + await handle_message(message, bot_name, _get_current_bot_cfg(bot_name) or bot_cfg)
125
126
127 async def _wrap_callback(query, bot_name: str, bot_cfg: dict):
128 from plugins._telegram_integration.helpers.handler import handle_callback_query
117 - await handle_callback_query(query, bot_name, bot_cfg)
129 + await handle_callback_query(query, bot_name, _get_current_bot_cfg(bot_name) or bot_cfg)
plugins/_telegram_integration/helpers/bot_manager.py
+17 -16
@@ -7,7 +7,6 @@ from aiogram.client.default import DefaultBotProperties
7 from aiogram.enums import ParseMode, ChatType
8 from aiogram.filters import Command, CommandStart
9 from aiogram.types import Message, CallbackQuery
10 -from aiogram.webhook.aiohttp_server import SimpleRequestHandler
10
11 from helpers.errors import format_error
12 from helpers.print_style import PrintStyle
@@ -21,7 +20,8 @@ class BotInstance:
20 dispatcher: Dispatcher
21 router: Router
22 task: asyncio.Task | None = None # polling task
24 - webhook_app: object | None = None # aiohttp app for webhook mode
23 + webhook_active: bool = False # True when webhook mode is registered
24 + webhook_secret: str = "" # secret for webhook verification
25 bot_info: object | None = None # cached result of bot.get_me()
26
27 # Bot registry (singleton, persists across module reloads)
@@ -130,6 +130,12 @@ def _make_group_mention_filter(handler: Callable, bot: Bot):
130 # Polling
131
132 async def start_polling(instance: BotInstance) -> asyncio.Task:
133 + # Ensure any leftover webhook is removed before polling
134 + try:
135 + await instance.bot.delete_webhook()
136 + except Exception:
137 + pass
138 +
139 async def _poll():
140 try:
141 PrintStyle.info(f"Telegram ({instance.name}): starting polling")
@@ -159,25 +165,18 @@ async def stop_polling(instance: BotInstance):
165
166 # Webhook
167
162 -async def setup_webhook(instance: BotInstance, url: str, secret: str = ""):
163 - """Set Telegram webhook and return aiohttp app for local serving."""
164 - from aiohttp import web
168 +async def setup_webhook(instance: BotInstance, webhook_url: str, secret: str = ""):
169 + """Register webhook with Telegram. Updates are received via the API handler."""
170 + full_url = f"{webhook_url.rstrip('/')}/api/plugins/_telegram_integration/webhook?bot={instance.name}"
171
172 await instance.bot.set_webhook(
167 - url=url,
173 + url=full_url,
174 secret_token=secret or None,
175 )
176
171 - app = web.Application()
172 - handler = SimpleRequestHandler(
173 - dispatcher=instance.dispatcher,
174 - bot=instance.bot,
175 - secret_token=secret or None,
176 - )
177 - handler.register(app, path=f"/telegram/{instance.name}")
178 - instance.webhook_app = app
179 - PrintStyle.info(f"Telegram ({instance.name}): webhook set to {url}")
180 - return app
177 + instance.webhook_active = True
178 + instance.webhook_secret = secret
179 + PrintStyle.info(f"Telegram ({instance.name}): webhook active via {webhook_url.rstrip('/')}")
180
181
182 async def remove_webhook(instance: BotInstance):
@@ -185,6 +184,8 @@ async def remove_webhook(instance: BotInstance):
184 await instance.bot.delete_webhook()
185 except Exception as e:
186 PrintStyle.error(f"Telegram ({instance.name}): remove webhook error: {format_error(e)}")
187 + instance.webhook_active = False
188 + instance.webhook_secret = ""
189
190 # Cleanup
191
plugins/_telegram_integration/webui/config.html
+8 -7
@@ -83,10 +83,10 @@
83 <div class="field" x-show="bot.mode === 'webhook'">
84 <div class="field-label">
85 <div class="field-title">Webhook URL</div>
86 - <div class="field-description">Public HTTPS URL for Telegram to send updates to</div>
86 + <div class="field-description">Your Agent Zero base URL (e.g. https://yourdomain.com). The webhook path is appended automatically</div>
87 </div>
88 <div class="field-control">
89 - <input type="text" x-model="bot.webhook_url" placeholder="https://yourdomain.com/telegram/my_bot" />
89 + <input type="text" x-model="bot.webhook_url" placeholder="https://yourdomain.com" />
90 </div>
91 </div>
92
@@ -107,8 +107,8 @@
107 </div>
108 <div class="field-control">
109 <input type="text"
110 - :value="$store.telegramConfig.whitelistText(bot)"
111 - @input="$store.telegramConfig.setWhitelist(bot, $event.target.value)"
110 + x-effect="if (document.activeElement !== $el) $el.value = $store.telegramConfig.whitelistText(bot)"
111 + @change="$store.telegramConfig.setWhitelist(bot, $event.target.value)"
112 placeholder="123456789, @username" />
113 </div>
114 </div>
@@ -134,8 +134,8 @@
134 </div>
135 <div class="field-control">
136 <input type="text"
137 - :value="$store.telegramConfig.userProjectsText(bot)"
138 - @input="$store.telegramConfig.setUserProjects(bot, $event.target.value)"
137 + x-effect="if (document.activeElement !== $el) $el.value = $store.telegramConfig.userProjectsText(bot)"
138 + @change="$store.telegramConfig.setUserProjects(bot, $event.target.value)"
139 placeholder="123456=my_project, 789012=other_project" />
140 </div>
141 </div>
@@ -146,7 +146,8 @@
146 <div class="field-description">Fallback project if user not in the mapping above</div>
147 </div>
148 <div class="field-control">
149 - <select x-model="bot.default_project">
149 + <select x-effect="void $store.telegramConfig.projects; $nextTick(() => $el.value = bot.default_project)"
150 + @change="bot.default_project = $event.target.value">
151 <option value="">No project</option>
152 <template x-for="proj in $store.telegramConfig.projects" :key="proj.name">
153 <option :value="proj.name" x-text="proj.title || proj.name"></option>
plugins/_telegram_integration/webui/telegram-config-store.js
+3 -2
@@ -81,8 +81,9 @@ export const store = createStore("telegramConfig", {
81 .map((s) => s.trim())
82 .filter((s) => s)
83 .forEach((item) => {
84 - const [k, v] = item.split("=").map((p) => p.trim());
85 - if (k && v) obj[k] = v;
84 + const parts = item.split("=").map((p) => p.trim());
85 + const k = parts[0];
86 + if (k) obj[k] = parts[1] || "";
87 });
88 bot.user_projects = obj;
89 },