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
+