refactor(telegram): restructure handler.py and integrate model configuration

keyboardstaff committed Mar 21, 2026 at 23:54 UTC 5f996eded56e87d5b217d64e10f14eea57d8039d
2 files changed +80 -99
plugins/_telegram_integration/extensions/python/job_loop/_10_telegram_bot.py
-1
@@ -1,4 +1,3 @@
1 -import asyncio
1 from functools import partial
2 from typing import Any
3
plugins/_telegram_integration/helpers/handler.py
+80 -98
@@ -3,6 +3,7 @@ import os
3 import threading
4 import time
5 import uuid
6 +from contextlib import asynccontextmanager, suppress
7
8 from aiogram import Bot
9 from aiogram.client.default import DefaultBotProperties
@@ -219,14 +220,8 @@ async def handle_message(message: TgMessage, bot_name: str, bot_cfg: dict):
220 text = _extract_message_content(message)
221
222 # Use temp bot for downloads (cross-event-loop safe)
222 - dl_bot = Bot(token=instance.bot.token)
223 - try:
223 + async with _temp_bot(instance.bot.token) as dl_bot:
224 attachments = await _download_attachments(dl_bot, message, bot_name=bot_name)
225 - finally:
226 - try:
227 - await dl_bot.session.close()
228 - except Exception:
229 - pass
225
226 # Build user message with prompt
227 agent = context.agent0
@@ -353,6 +348,9 @@ async def _get_or_create_context_from_user(
348 if project:
349 projects.activate_project(ctx.id, project)
350
351 + # Inherit model override from an existing context in the same project
352 + _inherit_model_override(ctx)
353 +
354 chats[key] = ctx.id
355 _save_state(state)
356
@@ -376,24 +374,20 @@ def _extract_message_content(message: TgMessage) -> str:
374 parts.append(message.caption)
375
376 if message.location:
379 - parts.append(f"[Location: {message.location.latitude}, {message.location.longitude}]")
377 + loc = message.location
378 + parts.append(f"[Location: {loc.latitude}, {loc.longitude}]")
379
380 if message.contact:
382 - parts.append(
383 - f"[Contact: {message.contact.first_name} "
384 - f"{message.contact.last_name or ''} "
385 - f"phone={message.contact.phone_number}]"
386 - )
381 + c = message.contact
382 + parts.append(f"[Contact: {c.first_name} {c.last_name or ''} phone={c.phone_number}]")
383
384 if message.sticker:
389 - emoji = message.sticker.emoji or ""
390 - parts.append(f"[Sticker: {emoji}]")
385 + parts.append(f"[Sticker: {message.sticker.emoji or ''}]")
386
392 - if message.voice:
393 - parts.append("[Voice message — see attachment]")
394 -
395 - if message.video_note:
396 - parts.append("[Video note — see attachment]")
387 + # Simple attachment indicators
388 + for attr, label in [("voice", "Voice message"), ("video_note", "Video note")]:
389 + if getattr(message, attr, None):
390 + parts.append(f"[{label} — see attachment]")
391
392 return "\n".join(parts) if parts else "[No text content]"
393
@@ -416,39 +410,20 @@ async def _download_attachments(bot, message: TgMessage, bot_name: str = "") ->
410 if path:
411 paths.append(path)
412
419 - # Document
420 - if message.document:
421 - fname = message.document.file_name or f"file_{message.document.file_unique_id}"
422 - path = await _dl(message.document.file_id, fname)
423 - if path:
424 - paths.append(path)
425 -
426 - # Audio
427 - if message.audio:
428 - fname = message.audio.file_name or f"audio_{message.audio.file_unique_id}.mp3"
429 - path = await _dl(message.audio.file_id, fname)
430 - if path:
431 - paths.append(path)
432 -
433 - # Voice
434 - if message.voice:
435 - path = await _dl(message.voice.file_id, f"voice_{message.voice.file_unique_id}.ogg")
436 - if path:
437 - paths.append(path)
438 -
439 - # Video
440 - if message.video:
441 - fname = message.video.file_name or f"video_{message.video.file_unique_id}.mp4"
442 - path = await _dl(message.video.file_id, fname)
443 - if path:
444 - paths.append(path)
445 -
446 - # Video note
447 - if message.video_note:
448 - path = await _dl(
449 - message.video_note.file_id,
450 - f"videonote_{message.video_note.file_unique_id}.mp4",
451 - )
413 + # Other attachment types: (attr, default_prefix, default_ext)
414 + _types = [
415 + ("document", "file", None),
416 + ("audio", "audio", ".mp3"),
417 + ("voice", "voice", ".ogg"),
418 + ("video", "video", ".mp4"),
419 + ("video_note", "videonote", ".mp4"),
420 + ]
421 + for attr, prefix, ext in _types:
422 + obj = getattr(message, attr, None)
423 + if not obj:
424 + continue
425 + fname = getattr(obj, "file_name", None) or f"{prefix}_{obj.file_unique_id}{ext or ''}"
426 + path = await _dl(obj.file_id, fname)
427 if path:
428 paths.append(path)
429
@@ -480,28 +455,21 @@ async def send_telegram_reply(
455 if typing_stop:
456 typing_stop.set()
457
483 - reply_bot = Bot(
484 - token=instance.bot.token,
485 - default=DefaultBotProperties(parse_mode=ParseMode.HTML),
486 - )
458 try:
488 - # Send attachments first
489 - if attachments:
490 - for path in attachments:
491 - if tc.is_image_file(path):
492 - await tc.send_photo(reply_bot, chat_id, path)
459 + async with _temp_bot(instance.bot.token, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) as reply_bot:
460 + if attachments:
461 + for path in attachments:
462 + if tc.is_image_file(path):
463 + await tc.send_photo(reply_bot, chat_id, path)
464 + else:
465 + await tc.send_file(reply_bot, chat_id, path)
466 +
467 + if response_text:
468 + html_text = tc.md_to_telegram_html(response_text)
469 + if keyboard:
470 + await tc.send_text_with_keyboard(reply_bot, chat_id, html_text, keyboard)
471 else:
494 - await tc.send_file(reply_bot, chat_id, path)
495 -
496 - # Send text (with or without keyboard), convert Markdown → HTML
497 - if response_text:
498 - html_text = tc.md_to_telegram_html(response_text)
499 - if keyboard:
500 - await tc.send_text_with_keyboard(
501 - reply_bot, chat_id, html_text, keyboard,
502 - )
503 - else:
504 - await tc.send_text(reply_bot, chat_id, html_text)
472 + await tc.send_text(reply_bot, chat_id, html_text)
473
474 return None
475
@@ -509,24 +477,24 @@ async def send_telegram_reply(
477 error = format_error(e)
478 PrintStyle.error(f"Telegram reply failed: {error}")
479 return error
512 - finally:
513 - try:
514 - await reply_bot.session.close()
515 - except Exception:
516 - pass
480
481 # Helpers
482
520 -async def _send_with_temp_bot(token: str, chat_id: int, text: str, parse_mode: str | None = None):
521 - """Send text using a temporary Bot to avoid cross-event-loop session issues."""
522 - bot = Bot(token=token)
483 +@asynccontextmanager
484 +async def _temp_bot(token: str, **kwargs):
485 + """Create a temporary Bot, yield it, and ensure the session is closed."""
486 + bot = Bot(token=token, **kwargs)
487 try:
524 - await tc.send_text(bot, chat_id, text, parse_mode=parse_mode)
488 + yield bot
489 finally:
526 - try:
490 + with suppress(Exception):
491 await bot.session.close()
528 - except Exception:
529 - pass
492 +
493 +
494 +async def _send_with_temp_bot(token: str, chat_id: int, text: str, parse_mode: str | None = None):
495 + """Send text using a temporary Bot to avoid cross-event-loop session issues."""
496 + async with _temp_bot(token) as bot:
497 + await tc.send_text(bot, chat_id, text, parse_mode=parse_mode)
498
499
500 def _start_typing(token: str, chat_id: int) -> threading.Event:
@@ -537,27 +505,20 @@ def _start_typing(token: str, chat_id: int) -> threading.Event:
505 import asyncio
506
507 async def _loop():
540 - bot = Bot(token=token)
541 - try:
508 + async with _temp_bot(token) as bot:
509 while not stop.is_set():
510 await tc.send_typing(bot, chat_id)
544 - # Sleep 4s total, checking stop every 0.5s
511 for _ in range(8):
512 if stop.is_set():
513 return
514 await asyncio.sleep(0.5)
549 - except Exception:
550 - pass
551 - finally:
552 - try:
553 - await bot.session.close()
554 - except Exception:
555 - pass
515
557 - asyncio.run(_loop())
516 + try:
517 + asyncio.run(_loop())
518 + except Exception:
519 + pass
520
559 - t = threading.Thread(target=_run, daemon=True)
560 - t.start()
521 + threading.Thread(target=_run, daemon=True).start()
522 return stop
523
524
@@ -569,3 +530,24 @@ def _format_user(user) -> str:
530 name += f" (@{user.username})"
531 return name.strip() or str(user.id)
532
533 +
534 +def _inherit_model_override(ctx: AgentContext):
535 + """Copy chat_model_override from the most recent sibling context in the same project."""
536 + project = ctx.get_data("project")
537 + if not project:
538 + return
539 + try:
540 + from plugins._model_config.helpers.model_config import is_chat_override_allowed
541 + if not is_chat_override_allowed(ctx.agent0):
542 + return
543 + except Exception:
544 + return
545 + source = max(
546 + (c for c in AgentContext.all()
547 + if c.id != ctx.id and c.get_data("project") == project and c.get_data("chat_model_override")),
548 + key=lambda c: c.last_message,
549 + default=None,
550 + )
551 + if source:
552 + ctx.set_data("chat_model_override", source.get_data("chat_model_override"))
553 +