feat: add Telegram integration plugin - core backend
keyboardstaff committed
Mar 20, 2026 at 10:12 UTC
83ffa27d139c005cf82b9c8c5cbdd60380b8b898
16 files changed
+1087
plugins/_telegram_integration/api/test_connection.py
new
+35
@@ -0,0 +1,35 @@
1
+from helpers.api import ApiHandler, Request
2
+from helpers.errors import format_error
3
+
4
+
5
+class TestConnection(ApiHandler):
6
+
7
+ async def process(self, input: dict, request: Request) -> dict:
8
+ bot_cfg = input.get("bot", {})
9
+ token = bot_cfg.get("token", "")
10
+ results: list[dict] = []
11
+
12
+ if not token:
13
+ results.append({
14
+ "test": "Token",
15
+ "ok": False,
16
+ "message": "No bot token provided",
17
+ })
18
+ return {"success": False, "results": results}
19
+
20
+ try:
21
+ from plugins._telegram_integration.helpers.bot_manager import test_token
22
+ ok, message = await test_token(token)
23
+ results.append({
24
+ "test": "Bot Token",
25
+ "ok": ok,
26
+ "message": message,
27
+ })
28
+ except Exception as e:
29
+ results.append({
30
+ "test": "Bot Token",
31
+ "ok": False,
32
+ "message": format_error(e),
33
+ })
34
+
35
+ return {"success": all(r["ok"] for r in results), "results": results}
plugins/_telegram_integration/default_config.yaml
new
+15
@@ -0,0 +1,15 @@
1
+bots: []
2
+# Example bot:
3
+# - name: my_bot
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
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)
11
+# user_projects: {} # Map Telegram user_id to project name, e.g. { "123456": "my_project" }
12
+# default_project: "" # Fallback project if user not in user_projects
13
+# attachment_max_age_hours: 0 # Hours to keep downloaded attachments. 0 = keep forever.
14
+# agent_instructions: "" # Extra instructions for the agent in Telegram chats
15
+
plugins/_telegram_integration/helpers/__init__.py
plugins/_telegram_integration/helpers/bot_manager.py
new
+221
@@ -0,0 +1,221 @@
1
+import asyncio
2
+from dataclasses import dataclass, field
3
+from typing import Callable, Awaitable
4
+
5
+from aiogram import Bot, Dispatcher, Router, F
6
+from aiogram.client.default import DefaultBotProperties
7
+from aiogram.enums import ParseMode, ChatType
8
+from aiogram.exceptions import TelegramBadRequest
9
+from aiogram.filters import Command, CommandStart
10
+from aiogram.types import Message, CallbackQuery, Update
11
+from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
12
+
13
+from helpers.errors import format_error
14
+from helpers.print_style import PrintStyle
15
+
16
+# Data models
17
+
18
+@dataclass
19
+class BotInstance:
20
+ name: str
21
+ bot: Bot
22
+ dispatcher: Dispatcher
23
+ router: Router
24
+ task: asyncio.Task | None = None # polling task
25
+ webhook_app: object | None = None # aiohttp app for webhook mode
26
+ bot_info: object | None = None # cached result of bot.get_me()
27
+
28
+# Bot registry (singleton, persists across module reloads)
29
+
30
+_bots: dict[str, BotInstance] = {}
31
+
32
+
33
+def get_bot(name: str) -> BotInstance | None:
34
+ return _bots.get(name)
35
+
36
+
37
+def get_all_bots() -> dict[str, BotInstance]:
38
+ return _bots
39
+
40
+# Bot creation
41
+
42
+def create_bot(
43
+ name: str,
44
+ token: str,
45
+ on_message: Callable[..., Awaitable],
46
+ on_command_start: Callable[..., Awaitable],
47
+ on_command_clear: Callable[..., Awaitable],
48
+ on_callback_query: Callable[..., Awaitable] | None = None,
49
+ group_mode: str = "mention",
50
+) -> BotInstance:
51
+ bot = Bot(token=token, default=DefaultBotProperties(parse_mode=ParseMode.MARKDOWN))
52
+ dp = Dispatcher()
53
+ router = Router()
54
+
55
+ # Register command handlers
56
+ router.message.register(on_command_start, CommandStart())
57
+ router.message.register(on_command_clear, Command("clear"))
58
+
59
+ if on_callback_query:
60
+ router.callback_query.register(on_callback_query)
61
+
62
+ # Register message handler with group filtering
63
+ if group_mode == "off":
64
+ # Private chats only
65
+ router.message.register(
66
+ on_message, F.chat.type == ChatType.PRIVATE,
67
+ )
68
+ elif group_mode == "mention":
69
+ # Private chats: all messages; Groups: only when mentioned/replied
70
+ router.message.register(
71
+ on_message, F.chat.type == ChatType.PRIVATE,
72
+ )
73
+ router.message.register(
74
+ _make_group_mention_filter(on_message, bot),
75
+ )
76
+ else:
77
+ # All messages in all chats
78
+ router.message.register(on_message)
79
+
80
+ dp.include_router(router)
81
+ instance = BotInstance(name=name, bot=bot, dispatcher=dp, router=router)
82
+ _bots[name] = instance
83
+ return instance
84
+
85
+
86
+async def cache_bot_info(instance: BotInstance):
87
+ """Fetch and cache bot info. Call after create_bot."""
88
+ if not instance.bot_info:
89
+ instance.bot_info = await instance.bot.get_me()
90
+ return instance.bot_info
91
+
92
+
93
+def _make_group_mention_filter(handler: Callable, bot: Bot):
94
+ """Create a group message handler that only responds to mentions and replies."""
95
+ async def _group_handler(message: Message):
96
+ if message.chat.type == ChatType.PRIVATE:
97
+ return
98
+ # Use cached bot_info from the instance
99
+ inst = get_bot(handler.__name__.split('_')[-1]) if hasattr(handler, '__name__') else None
100
+ bot_info = None
101
+ for b in _bots.values():
102
+ if b.bot is bot:
103
+ bot_info = b.bot_info
104
+ break
105
+ if not bot_info:
106
+ bot_info = await bot.get_me()
107
+ bot_username = bot_info.username or ""
108
+
109
+ # Check for reply to bot
110
+ if message.reply_to_message and message.reply_to_message.from_user:
111
+ if message.reply_to_message.from_user.id == bot_info.id:
112
+ await handler(message)
113
+ return
114
+
115
+ # Check for @mention in text
116
+ if message.text and f"@{bot_username}" in message.text:
117
+ await handler(message)
118
+ return
119
+
120
+ # Check entities for mention
121
+ if message.entities:
122
+ for entity in message.entities:
123
+ if entity.type == "mention":
124
+ mention_text = message.text[entity.offset:entity.offset + entity.length]
125
+ if mention_text.lower() == f"@{bot_username.lower()}":
126
+ await handler(message)
127
+ return
128
+
129
+ _group_handler.__name__ = f"_group_handler_{id(handler)}"
130
+ return _group_handler
131
+
132
+# Polling
133
+
134
+async def start_polling(instance: BotInstance) -> asyncio.Task:
135
+ async def _poll():
136
+ try:
137
+ PrintStyle.info(f"Telegram ({instance.name}): starting polling")
138
+ await instance.dispatcher.start_polling(
139
+ instance.bot,
140
+ handle_signals=False,
141
+ )
142
+ except asyncio.CancelledError:
143
+ PrintStyle.info(f"Telegram ({instance.name}): polling cancelled")
144
+ except Exception as e:
145
+ PrintStyle.error(f"Telegram ({instance.name}): polling error: {format_error(e)}")
146
+
147
+ task = asyncio.create_task(_poll())
148
+ instance.task = task
149
+ return task
150
+
151
+
152
+async def stop_polling(instance: BotInstance):
153
+ if instance.task and not instance.task.done():
154
+ await instance.dispatcher.stop_polling()
155
+ instance.task.cancel()
156
+ try:
157
+ await instance.task
158
+ except asyncio.CancelledError:
159
+ pass
160
+ instance.task = None
161
+
162
+# Webhook
163
+
164
+async def setup_webhook(instance: BotInstance, url: str, secret: str = ""):
165
+ """Set Telegram webhook and return aiohttp app for local serving."""
166
+ from aiohttp import web
167
+
168
+ await instance.bot.set_webhook(
169
+ url=url,
170
+ secret_token=secret or None,
171
+ )
172
+
173
+ app = web.Application()
174
+ handler = SimpleRequestHandler(
175
+ dispatcher=instance.dispatcher,
176
+ bot=instance.bot,
177
+ secret_token=secret or None,
178
+ )
179
+ handler.register(app, path=f"/telegram/{instance.name}")
180
+ instance.webhook_app = app
181
+ PrintStyle.info(f"Telegram ({instance.name}): webhook set to {url}")
182
+ return app
183
+
184
+
185
+async def remove_webhook(instance: BotInstance):
186
+ try:
187
+ await instance.bot.delete_webhook()
188
+ except Exception as e:
189
+ PrintStyle.error(f"Telegram ({instance.name}): remove webhook error: {format_error(e)}")
190
+
191
+# Cleanup
192
+
193
+async def stop_bot(name: str):
194
+ instance = _bots.pop(name, None)
195
+ if not instance:
196
+ return
197
+ if instance.task and not instance.task.done():
198
+ await stop_polling(instance)
199
+ else:
200
+ await remove_webhook(instance)
201
+ try:
202
+ await instance.bot.session.close()
203
+ except Exception:
204
+ pass
205
+ PrintStyle.info(f"Telegram ({name}): stopped")
206
+
207
+
208
+async def stop_all_bots():
209
+ for name in list(_bots.keys()):
210
+ await stop_bot(name)
211
+
212
+# Test connection
213
+
214
+async def test_token(token: str) -> tuple[bool, str]:
215
+ try:
216
+ bot = Bot(token=token)
217
+ info = await bot.get_me()
218
+ await bot.session.close()
219
+ return True, f"Connected as @{info.username} ({info.first_name})"
220
+ except Exception as e:
221
+ return False, format_error(e)
plugins/_telegram_integration/helpers/handler.py
new
+517
@@ -0,0 +1,517 @@
1
+import json
2
+import os
3
+import threading
4
+import time
5
+import uuid
6
+
7
+from aiogram.types import Message as TgMessage, CallbackQuery
8
+
9
+from agent import Agent, AgentContext, AgentContextType, UserMessage
10
+from helpers import guids, plugins, files
11
+from helpers import message_queue as mq
12
+from helpers.notification import NotificationManager, NotificationType, NotificationPriority
13
+from helpers.persist_chat import save_tmp_chat
14
+from helpers.print_style import PrintStyle
15
+from helpers.errors import format_error
16
+from initialize import initialize_agent
17
+
18
+from plugins._telegram_integration.helpers import telegram_client as tc
19
+from plugins._telegram_integration.helpers.bot_manager import get_bot
20
+
21
+
22
+PLUGIN_NAME = "_telegram_integration"
23
+DOWNLOAD_FOLDER = "usr/telegram/attachments"
24
+STATE_FILE = "usr/telegram/state.json"
25
+
26
+# Context data keys
27
+CTX_TG_BOT = "telegram_bot"
28
+CTX_TG_CHAT_ID = "telegram_chat_id"
29
+CTX_TG_USER_ID = "telegram_user_id"
30
+CTX_TG_USERNAME = "telegram_username"
31
+CTX_TG_LAST_MSG_ID = "telegram_last_msg_id"
32
+
33
+# Transient
34
+CTX_TG_ATTACHMENTS = "_telegram_response_attachments"
35
+CTX_TG_KEYBOARD = "_telegram_response_keyboard"
36
+
37
+# Chat mapping: (bot_name, tg_user_id) → AgentContext ID
38
+
39
+_chat_map_lock = threading.Lock()
40
+
41
+
42
+def _load_state() -> dict:
43
+ path = files.get_abs_path(STATE_FILE)
44
+ if os.path.isfile(path):
45
+ try:
46
+ return json.loads(files.read_file(path))
47
+ except Exception:
48
+ return {}
49
+ return {}
50
+
51
+
52
+def _save_state(state: dict):
53
+ path = files.get_abs_path(STATE_FILE)
54
+ files.make_dirs(path)
55
+ files.write_file(path, json.dumps(state))
56
+
57
+
58
+def _map_key(bot_name: str, user_id: int) -> str:
59
+ return f"{bot_name}:{user_id}"
60
+
61
+
62
+def cleanup_old_attachments():
63
+ """Remove downloaded attachment files older than per-bot max age. 0 = keep forever."""
64
+ config = plugins.get_plugin_config(PLUGIN_NAME) or {}
65
+ bots_cfg = config.get("bots") or []
66
+ total_removed = 0
67
+ for bot_cfg in bots_cfg:
68
+ bot_name = bot_cfg.get("name", "")
69
+ if not bot_name:
70
+ continue
71
+ max_age_hours = bot_cfg.get("attachment_max_age_hours", 0)
72
+ if not max_age_hours or max_age_hours <= 0:
73
+ continue
74
+ folder = os.path.join(files.get_abs_path(DOWNLOAD_FOLDER), bot_name)
75
+ if not os.path.isdir(folder):
76
+ continue
77
+ cutoff = time.time() - max_age_hours * 3600
78
+ for name in os.listdir(folder):
79
+ path = os.path.join(folder, name)
80
+ try:
81
+ if os.path.isfile(path) and os.path.getmtime(path) < cutoff:
82
+ os.remove(path)
83
+ total_removed += 1
84
+ except OSError:
85
+ pass
86
+ if total_removed:
87
+ PrintStyle.info(f"Telegram: cleaned up {total_removed} old attachment(s)")
88
+
89
+# Access control
90
+
91
+def _is_allowed(bot_cfg: dict, user_id: int, username: str | None) -> bool:
92
+ allowed = bot_cfg.get("allowed_users") or []
93
+ if not allowed:
94
+ return True # empty = allow all
95
+ for entry in allowed:
96
+ entry_str = str(entry).strip()
97
+ if entry_str.startswith("@"):
98
+ if username and f"@{username}" == entry_str:
99
+ return True
100
+ else:
101
+ try:
102
+ if int(entry_str) == user_id:
103
+ return True
104
+ except ValueError:
105
+ if username and entry_str.lower() == username.lower():
106
+ return True
107
+ return False
108
+
109
+
110
+def _get_project(bot_cfg: dict, user_id: int) -> str:
111
+ user_projects = bot_cfg.get("user_projects") or {}
112
+ project = user_projects.get(str(user_id), "")
113
+ if not project:
114
+ project = bot_cfg.get("default_project", "")
115
+ return project
116
+
117
+# Message handlers (registered with aiogram by bot_manager)
118
+
119
+async def handle_start(message: TgMessage, bot_name: str, bot_cfg: dict):
120
+ """Handle /start command."""
121
+ user = message.from_user
122
+ if not user:
123
+ return
124
+
125
+ if not _is_allowed(bot_cfg, user.id, user.username):
126
+ await message.reply("⛔ You are not authorized to use this bot.")
127
+ return
128
+
129
+ instance = get_bot(bot_name)
130
+ if not instance:
131
+ return
132
+
133
+ await message.reply(
134
+ f"👋 Hello {user.first_name}! I'm connected to Agent Zero.\n\n"
135
+ "Send me a message and I'll process it.\n"
136
+ "Use /clear to reset the conversation."
137
+ )
138
+
139
+ # Ensure a chat context exists
140
+ await _get_or_create_context(bot_name, bot_cfg, message)
141
+
142
+
143
+async def handle_clear(message: TgMessage, bot_name: str, bot_cfg: dict):
144
+ """Handle /clear command — reset user's chat context."""
145
+ user = message.from_user
146
+ if not user:
147
+ return
148
+
149
+ if not _is_allowed(bot_cfg, user.id, user.username):
150
+ return
151
+
152
+ key = _map_key(bot_name, user.id)
153
+
154
+ with _chat_map_lock:
155
+ state = _load_state()
156
+ ctx_id = state.get("chats", {}).get(key)
157
+ if ctx_id:
158
+ ctx = AgentContext.get(ctx_id)
159
+ if ctx:
160
+ ctx.reset()
161
+ PrintStyle.info(f"Telegram ({bot_name}): cleared chat for user {user.id}")
162
+
163
+ instance = get_bot(bot_name)
164
+ if instance:
165
+ await tc.send_text(
166
+ instance.bot, message.chat.id,
167
+ "🗑 Chat cleared. Send a new message to start fresh.",
168
+ parse_mode=None,
169
+ )
170
+
171
+ # Send notification
172
+ username_str = f"@{user.username}" if user.username else str(user.id)
173
+ NotificationManager.send_notification(
174
+ type=NotificationType.INFO,
175
+ priority=NotificationPriority.NORMAL,
176
+ title="Telegram: chat cleared",
177
+ message=f"{username_str} cleared their chat via /clear",
178
+ display_time=5,
179
+ group="telegram",
180
+ )
181
+
182
+
183
+async def handle_message(message: TgMessage, bot_name: str, bot_cfg: dict):
184
+ """Handle incoming user message."""
185
+ user = message.from_user
186
+ if not user:
187
+ return
188
+
189
+ if not _is_allowed(bot_cfg, user.id, user.username):
190
+ return
191
+
192
+ instance = get_bot(bot_name)
193
+ if not instance:
194
+ return
195
+
196
+ # Send typing indicator
197
+ await tc.send_typing(instance.bot, message.chat.id)
198
+
199
+ # Get or create agent context
200
+ context = await _get_or_create_context(bot_name, bot_cfg, message)
201
+ if not context:
202
+ await tc.send_text(
203
+ instance.bot, message.chat.id,
204
+ "❌ Failed to create chat session.",
205
+ parse_mode=None,
206
+ )
207
+ return
208
+
209
+ # Build user message text
210
+ text = _extract_message_content(message)
211
+ attachments = await _download_attachments(instance.bot, message, bot_name=bot_name)
212
+
213
+ # Store last incoming message ID for reply threading
214
+ context.data[CTX_TG_LAST_MSG_ID] = message.message_id
215
+
216
+ # Build user message with prompt
217
+ agent = context.agent0
218
+ user_msg = agent.read_prompt(
219
+ "fw.telegram.user_message.md",
220
+ sender=_format_user(user),
221
+ body=text,
222
+ )
223
+
224
+ instructions = bot_cfg.get("agent_instructions", "")
225
+ if instructions:
226
+ user_msg += agent.read_prompt(
227
+ "fw.telegram.user_message_instructions.md",
228
+ instructions=instructions,
229
+ )
230
+
231
+ system_ctx = agent.read_prompt("fw.telegram.system_context.md")
232
+
233
+ mq.log_user_message(context, user_msg, attachments, source=" (telegram)")
234
+ context.communicate(UserMessage(
235
+ message=user_msg,
236
+ system_message=[system_ctx],
237
+ attachments=attachments,
238
+ ))
239
+
240
+ save_tmp_chat(context)
241
+
242
+ # Send notification
243
+ username_str = f"@{user.username}" if user.username else str(user.id)
244
+ preview = (text[:80] + "...") if len(text) > 80 else text
245
+ NotificationManager.send_notification(
246
+ type=NotificationType.INFO,
247
+ priority=NotificationPriority.HIGH,
248
+ title="Telegram: new message",
249
+ message=f"From {username_str}: {preview}",
250
+ display_time=10,
251
+ group="telegram",
252
+ )
253
+
254
+
255
+async def handle_callback_query(query: CallbackQuery, bot_name: str, bot_cfg: dict):
256
+ """Handle inline keyboard button press."""
257
+ user = query.from_user
258
+ if not user or not query.message:
259
+ return
260
+
261
+ if not _is_allowed(bot_cfg, user.id, user.username):
262
+ await query.answer("Not authorized.")
263
+ return
264
+
265
+ await query.answer()
266
+
267
+ # Treat callback data as a user message
268
+ text = query.data or ""
269
+ if not text:
270
+ return
271
+
272
+ context = await _get_or_create_context_from_user(
273
+ bot_name, bot_cfg, user.id, user.username, query.message.chat.id,
274
+ )
275
+ if not context:
276
+ return
277
+
278
+ agent = context.agent0
279
+ user_msg = agent.read_prompt(
280
+ "fw.telegram.user_message.md",
281
+ sender=_format_user(user),
282
+ body=f"[Button pressed: {text}]",
283
+ )
284
+
285
+ mq.log_user_message(context, user_msg, [], source=" (telegram)")
286
+ context.communicate(UserMessage(message=user_msg))
287
+ save_tmp_chat(context)
288
+
289
+# Context management
290
+
291
+async def _get_or_create_context(
292
+ bot_name: str,
293
+ bot_cfg: dict,
294
+ message: TgMessage,
295
+) -> AgentContext | None:
296
+ user = message.from_user
297
+ if not user:
298
+ return None
299
+ return await _get_or_create_context_from_user(
300
+ bot_name, bot_cfg, user.id, user.username, message.chat.id,
301
+ )
302
+
303
+
304
+async def _get_or_create_context_from_user(
305
+ bot_name: str,
306
+ bot_cfg: dict,
307
+ user_id: int,
308
+ username: str | None,
309
+ chat_id: int,
310
+) -> AgentContext | None:
311
+ key = _map_key(bot_name, user_id)
312
+
313
+ with _chat_map_lock:
314
+ state = _load_state()
315
+ chats = state.setdefault("chats", {})
316
+ ctx_id = chats.get(key)
317
+
318
+ # Check if existing context is still alive
319
+ if ctx_id:
320
+ ctx = AgentContext.get(ctx_id)
321
+ if ctx:
322
+ return ctx
323
+ # Context was garbage collected, remove stale mapping
324
+ chats.pop(key, None)
325
+
326
+ # Create new context
327
+ try:
328
+ config = initialize_agent()
329
+ display_name = f"@{username}" if username else str(user_id)
330
+ ctx = AgentContext(config, name=f"Telegram: {display_name}")
331
+
332
+ ctx.data[CTX_TG_BOT] = bot_name
333
+ ctx.data[CTX_TG_CHAT_ID] = chat_id
334
+ ctx.data[CTX_TG_USER_ID] = user_id
335
+ ctx.data[CTX_TG_USERNAME] = username or ""
336
+
337
+ project = _get_project(bot_cfg, user_id)
338
+ if project:
339
+ from helpers import projects
340
+ projects.activate_project(ctx.id, project)
341
+
342
+ chats[key] = ctx.id
343
+ _save_state(state)
344
+
345
+ PrintStyle.success(
346
+ f"Telegram ({bot_name}): new chat {ctx.id} for user {display_name}"
347
+ )
348
+ return ctx
349
+
350
+ except Exception as e:
351
+ PrintStyle.error(f"Telegram: failed to create context: {format_error(e)}")
352
+ return None
353
+
354
+# Message content extraction
355
+
356
+def _extract_message_content(message: TgMessage) -> str:
357
+ parts = []
358
+
359
+ if message.text:
360
+ parts.append(message.text)
361
+ elif message.caption:
362
+ parts.append(message.caption)
363
+
364
+ if message.location:
365
+ parts.append(f"[Location: {message.location.latitude}, {message.location.longitude}]")
366
+
367
+ if message.contact:
368
+ parts.append(
369
+ f"[Contact: {message.contact.first_name} "
370
+ f"{message.contact.last_name or ''} "
371
+ f"phone={message.contact.phone_number}]"
372
+ )
373
+
374
+ if message.sticker:
375
+ emoji = message.sticker.emoji or ""
376
+ parts.append(f"[Sticker: {emoji}]")
377
+
378
+ if message.voice:
379
+ parts.append("[Voice message — see attachment]")
380
+
381
+ if message.video_note:
382
+ parts.append("[Video note — see attachment]")
383
+
384
+ return "\n".join(parts) if parts else "[No text content]"
385
+
386
+
387
+async def _download_attachments(bot, message: TgMessage, bot_name: str = "") -> list[str]:
388
+ """Download photos, documents, audio, voice, video from message."""
389
+ paths: list[str] = []
390
+ sub = bot_name if bot_name else "_default"
391
+ download_base = os.path.join(files.get_abs_path(DOWNLOAD_FOLDER), sub)
392
+ os.makedirs(download_base, exist_ok=True)
393
+
394
+ async def _dl(file_id: str, filename: str) -> str | None:
395
+ dest = os.path.join(download_base, f"{uuid.uuid4().hex[:8]}_{filename}")
396
+ return await tc.download_file(bot, file_id, dest)
397
+
398
+ # Photo: get largest resolution
399
+ if message.photo:
400
+ photo = message.photo[-1]
401
+ path = await _dl(photo.file_id, f"photo_{photo.file_unique_id}.jpg")
402
+ if path:
403
+ paths.append(path)
404
+
405
+ # Document
406
+ if message.document:
407
+ fname = message.document.file_name or f"file_{message.document.file_unique_id}"
408
+ path = await _dl(message.document.file_id, fname)
409
+ if path:
410
+ paths.append(path)
411
+
412
+ # Audio
413
+ if message.audio:
414
+ fname = message.audio.file_name or f"audio_{message.audio.file_unique_id}.mp3"
415
+ path = await _dl(message.audio.file_id, fname)
416
+ if path:
417
+ paths.append(path)
418
+
419
+ # Voice
420
+ if message.voice:
421
+ path = await _dl(message.voice.file_id, f"voice_{message.voice.file_unique_id}.ogg")
422
+ if path:
423
+ paths.append(path)
424
+
425
+ # Video
426
+ if message.video:
427
+ fname = message.video.file_name or f"video_{message.video.file_unique_id}.mp4"
428
+ path = await _dl(message.video.file_id, fname)
429
+ if path:
430
+ paths.append(path)
431
+
432
+ # Video note
433
+ if message.video_note:
434
+ path = await _dl(
435
+ message.video_note.file_id,
436
+ f"videonote_{message.video_note.file_unique_id}.mp4",
437
+ )
438
+ if path:
439
+ paths.append(path)
440
+
441
+ return paths
442
+
443
+# Reply sending (called from process_chain_end extension)
444
+
445
+async def send_telegram_reply(
446
+ context: AgentContext,
447
+ response_text: str,
448
+ attachments: list[str] | None = None,
449
+ keyboard: list[list[dict]] | None = None,
450
+) -> str | None:
451
+ """Send reply to Telegram user. Returns error string or None on success."""
452
+ from aiogram import Bot
453
+ from aiogram.client.default import DefaultBotProperties
454
+ from aiogram.enums import ParseMode
455
+
456
+ bot_name = context.data.get(CTX_TG_BOT)
457
+ if not bot_name:
458
+ return "No Telegram bot configured on context"
459
+
460
+ instance = get_bot(bot_name)
461
+ if not instance:
462
+ return f"Bot '{bot_name}' not running"
463
+
464
+ chat_id = context.data.get(CTX_TG_CHAT_ID)
465
+ if not chat_id:
466
+ return "No chat_id on context"
467
+
468
+ # Create a temporary Bot bound to the current event loop to avoid
469
+ # cross-event-loop issues with the shared instance's aiohttp session.
470
+ reply_bot = Bot(
471
+ token=instance.bot.token,
472
+ default=DefaultBotProperties(parse_mode=ParseMode.MARKDOWN),
473
+ )
474
+ try:
475
+ # Send attachments first
476
+ if attachments:
477
+ for path in attachments:
478
+ if tc.is_image_file(path):
479
+ await tc.send_photo(reply_bot, chat_id, path)
480
+ else:
481
+ await tc.send_file(reply_bot, chat_id, path)
482
+
483
+ # Send text (with or without keyboard)
484
+ if response_text:
485
+ if keyboard:
486
+ await tc.send_text_with_keyboard(
487
+ reply_bot, chat_id, response_text, keyboard,
488
+ )
489
+ else:
490
+ await tc.send_text(reply_bot, chat_id, response_text)
491
+
492
+ return None
493
+
494
+ except Exception as e:
495
+ error = format_error(e)
496
+ PrintStyle.error(f"Telegram reply failed: {error}")
497
+ return error
498
+ finally:
499
+ await reply_bot.session.close()
500
+
501
+# Helpers
502
+
503
+def _format_user(user) -> str:
504
+ name = user.first_name or ""
505
+ if user.last_name:
506
+ name += f" {user.last_name}"
507
+ if user.username:
508
+ name += f" (@{user.username})"
509
+ return name.strip() or str(user.id)
510
+
511
+
512
+def find_context_for_bot_chat(bot_name: str, chat_id: int) -> AgentContext | None:
513
+ """Find active context matching a Telegram bot + chat_id."""
514
+ for ctx in AgentContext.all():
515
+ if ctx.data.get(CTX_TG_BOT) == bot_name and ctx.data.get(CTX_TG_CHAT_ID) == chat_id:
516
+ return ctx
517
+ return None
plugins/_telegram_integration/helpers/telegram_client.py
new
+226
@@ -0,0 +1,226 @@
1
+import asyncio
2
+import os
3
+from typing import BinaryIO
4
+
5
+from aiogram import Bot
6
+from aiogram.exceptions import TelegramBadRequest
7
+from aiogram.types import (
8
+ BufferedInputFile,
9
+ FSInputFile,
10
+ InlineKeyboardButton,
11
+ InlineKeyboardMarkup,
12
+)
13
+
14
+from helpers.errors import format_error
15
+from helpers.print_style import PrintStyle
16
+
17
+# Text messages
18
+
19
+MAX_MESSAGE_LENGTH: int = 4096 # Telegram message length limit
20
+
21
+
22
+async def send_text(
23
+ bot: Bot,
24
+ chat_id: int,
25
+ text: str,
26
+ reply_to_message_id: int | None = None,
27
+ parse_mode: str | None = None,
28
+) -> int | None:
29
+ """Send text message, splitting if too long. Returns last message_id or None on error."""
30
+ try:
31
+ chunks = _split_text(text, MAX_MESSAGE_LENGTH)
32
+ last_msg_id = None
33
+ for chunk in chunks:
34
+ try:
35
+ msg = await bot.send_message(
36
+ chat_id=chat_id,
37
+ text=chunk,
38
+ reply_to_message_id=reply_to_message_id,
39
+ parse_mode=parse_mode,
40
+ )
41
+ last_msg_id = msg.message_id
42
+ except TelegramBadRequest:
43
+ # Retry without markdown if parse fails
44
+ msg = await bot.send_message(
45
+ chat_id=chat_id,
46
+ text=chunk,
47
+ reply_to_message_id=reply_to_message_id,
48
+ parse_mode=None,
49
+ )
50
+ last_msg_id = msg.message_id
51
+ return last_msg_id
52
+ except Exception as e:
53
+ PrintStyle.error(f"Telegram send_text failed: {format_error(e)}")
54
+ return None
55
+
56
+# Files and images
57
+
58
+async def send_file(
59
+ bot: Bot,
60
+ chat_id: int,
61
+ file_path: str,
62
+ caption: str = "",
63
+ reply_to_message_id: int | None = None,
64
+) -> int | None:
65
+ """Send a file from local path. Returns message_id or None on error."""
66
+ try:
67
+ if not os.path.isfile(file_path):
68
+ PrintStyle.error(f"Telegram: file not found: {file_path}")
69
+ return None
70
+ input_file = FSInputFile(file_path)
71
+ msg = await bot.send_document(
72
+ chat_id=chat_id,
73
+ document=input_file,
74
+ caption=caption[:1024] if caption else None,
75
+ reply_to_message_id=reply_to_message_id,
76
+ )
77
+ return msg.message_id
78
+ except Exception as e:
79
+ PrintStyle.error(f"Telegram send_file failed: {format_error(e)}")
80
+ return None
81
+
82
+
83
+async def send_photo(
84
+ bot: Bot,
85
+ chat_id: int,
86
+ photo_path: str,
87
+ caption: str = "",
88
+ reply_to_message_id: int | None = None,
89
+) -> int | None:
90
+ """Send a photo from local path. Returns message_id or None on error."""
91
+ try:
92
+ if not os.path.isfile(photo_path):
93
+ PrintStyle.error(f"Telegram: photo not found: {photo_path}")
94
+ return None
95
+ input_file = FSInputFile(photo_path)
96
+ msg = await bot.send_photo(
97
+ chat_id=chat_id,
98
+ photo=input_file,
99
+ caption=caption[:1024] if caption else None,
100
+ reply_to_message_id=reply_to_message_id,
101
+ )
102
+ return msg.message_id
103
+ except Exception as e:
104
+ PrintStyle.error(f"Telegram send_photo failed: {format_error(e)}")
105
+ return None
106
+
107
+
108
+async def send_file_bytes(
109
+ bot: Bot,
110
+ chat_id: int,
111
+ content: bytes,
112
+ filename: str,
113
+ caption: str = "",
114
+) -> int | None:
115
+ """Send file from bytes content."""
116
+ try:
117
+ input_file = BufferedInputFile(content, filename=filename)
118
+ msg = await bot.send_document(
119
+ chat_id=chat_id,
120
+ document=input_file,
121
+ caption=caption[:1024] if caption else None,
122
+ )
123
+ return msg.message_id
124
+ except Exception as e:
125
+ PrintStyle.error(f"Telegram send_file_bytes failed: {format_error(e)}")
126
+ return None
127
+
128
+# Inline keyboards
129
+
130
+def build_inline_keyboard(
131
+ buttons: list[list[dict]],
132
+) -> InlineKeyboardMarkup:
133
+ """Build inline keyboard from a list of rows.
134
+ Each row is a list of dicts with keys: text, callback_data or url.
135
+ """
136
+ rows = []
137
+ for row in buttons:
138
+ row_buttons = []
139
+ for btn in row:
140
+ if "url" in btn:
141
+ row_buttons.append(InlineKeyboardButton(
142
+ text=btn["text"], url=btn["url"],
143
+ ))
144
+ else:
145
+ row_buttons.append(InlineKeyboardButton(
146
+ text=btn["text"],
147
+ callback_data=btn.get("callback_data", btn["text"]),
148
+ ))
149
+ rows.append(row_buttons)
150
+ return InlineKeyboardMarkup(inline_keyboard=rows)
151
+
152
+
153
+async def send_text_with_keyboard(
154
+ bot: Bot,
155
+ chat_id: int,
156
+ text: str,
157
+ buttons: list[list[dict]],
158
+ reply_to_message_id: int | None = None,
159
+) -> int | None:
160
+ """Send text with inline keyboard buttons."""
161
+ try:
162
+ keyboard = build_inline_keyboard(buttons)
163
+ msg = await bot.send_message(
164
+ chat_id=chat_id,
165
+ text=text,
166
+ reply_markup=keyboard,
167
+ reply_to_message_id=reply_to_message_id,
168
+ )
169
+ return msg.message_id
170
+ except Exception as e:
171
+ PrintStyle.error(f"Telegram send_text_with_keyboard failed: {format_error(e)}")
172
+ return None
173
+
174
+# Typing indicator
175
+
176
+async def send_typing(bot: Bot, chat_id: int):
177
+ """Send 'typing...' action to chat."""
178
+ try:
179
+ await bot.send_chat_action(chat_id=chat_id, action="typing")
180
+ except Exception:
181
+ pass
182
+
183
+# File download
184
+
185
+async def download_file(
186
+ bot: Bot,
187
+ file_id: str,
188
+ destination: str,
189
+) -> str | None:
190
+ """Download a file by file_id to destination path. Returns path or None on error."""
191
+ try:
192
+ file = await bot.get_file(file_id)
193
+ if not file.file_path:
194
+ return None
195
+ os.makedirs(os.path.dirname(destination), exist_ok=True)
196
+ await bot.download_file(file.file_path, destination)
197
+ return destination
198
+ except Exception as e:
199
+ PrintStyle.error(f"Telegram download failed: {format_error(e)}")
200
+ return None
201
+
202
+# Helpers
203
+
204
+def _split_text(text: str, max_len: int) -> list[str]:
205
+ if len(text) <= max_len:
206
+ return [text]
207
+ chunks = []
208
+ while text:
209
+ if len(text) <= max_len:
210
+ chunks.append(text)
211
+ break
212
+ # Try to split at newline
213
+ split_at = text.rfind("\n", 0, max_len)
214
+ if split_at < max_len // 2:
215
+ split_at = max_len
216
+ chunks.append(text[:split_at])
217
+ text = text[split_at:].lstrip("\n")
218
+ return chunks
219
+
220
+
221
+_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
222
+
223
+
224
+def is_image_file(path: str) -> bool:
225
+ _, ext = os.path.splitext(path.lower())
226
+ return ext in _IMAGE_EXTENSIONS
plugins/_telegram_integration/plugin.yaml
new
+8
@@ -0,0 +1,8 @@
1
+name: _telegram_integration
2
+title: Telegram Integration
3
+description: Communicate with Agent Zero via Telegram. Supports polling and webhook modes with per-user chat sessions.
4
+version: 1.0.0
5
+settings_sections:
6
+ - external
7
+per_project_config: false
8
+per_agent_config: false
plugins/_telegram_integration/prompts/fw.telegram.cleared.md
new
+3
@@ -0,0 +1,3 @@
1
+chat cleared by user via /clear command
2
+previous context has been reset
3
+greet user and wait for new input
\ No newline at end of file
plugins/_telegram_integration/prompts/fw.telegram.send_failed.md
new
+4
@@ -0,0 +1,4 @@
1
+telegram send failed (attempt {{attempt}}/{{max_retries}}): {{error}}
2
+previous response was NOT delivered to user
3
+retry without problematic content or inform user via alternative means
4
+if attachment caused the issue, retry without it
\ No newline at end of file
plugins/_telegram_integration/prompts/fw.telegram.system_context.md
new
+4
@@ -0,0 +1,4 @@
1
+telegram session user communicates via Telegram
2
+response tool sends the message to the user dont use python code
3
+break_loop true > stop working and wait for user reply
4
+break_loop false > only for mid-task progress updates then keep working
\ No newline at end of file
plugins/_telegram_integration/prompts/fw.telegram.system_context_reply.md
new
+46
@@ -0,0 +1,46 @@
1
+# Telegram session behavior
2
+user communicates via Telegram messenger
3
+response tool = send message to user on Telegram
4
+dont use code to send messages
5
+break_loop true > stop working and wait for user reply
6
+break_loop false > only for mid-task progress updates then keep working
7
+include file paths in attachments array to send files/images
8
+for multiple files zip first then attach single archive
9
+optionally set keyboard array for inline buttons
10
+
11
+usage:
12
+
13
+~~~json
14
+{
15
+ ...
16
+ "tool_name": "response",
17
+ "tool_args": {
18
+ "text": "working on it...",
19
+ "break_loop": false
20
+ }
21
+}
22
+~~~
23
+
24
+~~~json
25
+{
26
+ ...
27
+ "tool_name": "response",
28
+ "tool_args": {
29
+ "text": "Here is the result",
30
+ "attachments": ["/path/to/file.zip"],
31
+ "break_loop": true
32
+ }
33
+}
34
+~~~
35
+
36
+~~~json
37
+{
38
+ ...
39
+ "tool_name": "response",
40
+ "tool_args": {
41
+ "text": "Choose an option:",
42
+ "keyboard": [[{"text": "Option A", "callback_data": "a"}, {"text": "Option B", "callback_data": "b"}]],
43
+ "break_loop": true
44
+ }
45
+}
46
+~~~
\ No newline at end of file
plugins/_telegram_integration/prompts/fw.telegram.update_error.md
new
+1
@@ -0,0 +1 @@
1
+telegram update failed: {{error}}
\ No newline at end of file
plugins/_telegram_integration/prompts/fw.telegram.update_ok.md
new
+1
@@ -0,0 +1 @@
1
+telegram update sent, continue working
\ No newline at end of file
plugins/_telegram_integration/prompts/fw.telegram.user_message.md
new
+3
@@ -0,0 +1,3 @@
1
+[Telegram message from {{sender}}]
2
+
3
+{{body}}
\ No newline at end of file
plugins/_telegram_integration/prompts/fw.telegram.user_message_instructions.md
new
+2
@@ -0,0 +1,2 @@
1
+
2
+[Handler instructions: {{instructions}}]
\ No newline at end of file
requirements.txt
+1
@@ -48,6 +48,7 @@ html2text>=2024.2.26
48
beautifulsoup4>=4.12.3
49
boto3>=1.35.0
50
exchangelib>=5.4.3
51
+aiogram>=3.15.0
52
pywinpty==3.0.2; sys_platform == "win32"
53
python-socketio>=5.14.2
54
uvicorn>=0.38.0