feat: Add presence heartbeat for Matrix online status
- matrix-gateway: POST /internal/matrix/presence/online endpoint - usePresenceHeartbeat hook with activity tracking - Auto away after 5 min inactivity - Offline on page close/visibility change - Integrated in MatrixChatRoom component
This commit is contained in:
@@ -36,6 +36,14 @@ class BotsRegistry:
|
||||
for bot_config in bot_configs:
|
||||
self.register_from_config(bot_config)
|
||||
|
||||
def unregister(self, agent_id: str) -> Optional[str]:
|
||||
"""Видалити бота за agent_id та повернути bot_token"""
|
||||
bot_token = self._agent_to_token.pop(agent_id, None)
|
||||
if bot_token:
|
||||
self._token_to_agent.pop(bot_token, None)
|
||||
logger.info(f"Unregistered bot: agent_id={agent_id}, token={bot_token[:8]}...")
|
||||
return bot_token
|
||||
|
||||
def get_token_by_agent(self, agent_id: str) -> Optional[str]:
|
||||
return self._agent_to_token.get(agent_id)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .config import settings, load_bots_config
|
||||
from .models import BotRegistration, TelegramSendCommand
|
||||
@@ -15,6 +16,21 @@ logging.basicConfig(level=logging.INFO if settings.DEBUG else logging.WARNING)
|
||||
|
||||
app = FastAPI(title="telegram-gateway", version="0.1.0")
|
||||
|
||||
ALLOWED_ORIGINS = [
|
||||
"http://localhost:8899",
|
||||
"http://127.0.0.1:8899",
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=ALLOWED_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def on_startup():
|
||||
@@ -102,6 +118,22 @@ async def register_bot(reg: BotRegistration):
|
||||
return {"status": "registered", "agent_id": reg.agent_id}
|
||||
|
||||
|
||||
@app.delete("/bots/{agent_id}")
|
||||
async def unregister_bot(agent_id: str):
|
||||
"""Відключити бота та зупинити polling"""
|
||||
bot_token = bots_registry.unregister(agent_id)
|
||||
if not bot_token:
|
||||
raise HTTPException(status_code=404, detail="Bot not registered")
|
||||
|
||||
await telegram_listener.remove_bot(bot_token)
|
||||
await nats_client.publish_json(
|
||||
subject="bot.unregistered",
|
||||
data={"agent_id": agent_id, "bot_token": bot_token[:8] + "..."},
|
||||
)
|
||||
|
||||
return {"status": "unregistered", "agent_id": agent_id}
|
||||
|
||||
|
||||
@app.get("/bots/list")
|
||||
async def list_bots():
|
||||
"""Повернути список зареєстрованих ботів"""
|
||||
@@ -109,6 +141,13 @@ async def list_bots():
|
||||
return {"bots": agents, "count": len(agents)}
|
||||
|
||||
|
||||
@app.get("/bots/status/{agent_id}")
|
||||
async def bot_status(agent_id: str):
|
||||
"""Повернути статус конкретного бота"""
|
||||
status = telegram_listener.get_status(agent_id)
|
||||
return status
|
||||
|
||||
|
||||
@app.post("/send")
|
||||
async def send_message(cmd: TelegramSendCommand):
|
||||
"""
|
||||
|
||||
@@ -9,6 +9,7 @@ class TelegramUpdateEvent(BaseModel):
|
||||
chat_id: int
|
||||
user_id: int
|
||||
text: Optional[str] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
raw_update: Dict[str, Any]
|
||||
|
||||
|
||||
|
||||
@@ -65,14 +65,16 @@ class RouterHandler:
|
||||
async def _handle_telegram_event(self, event: TelegramUpdateEvent):
|
||||
"""Обробити подію Telegram та викликати Router"""
|
||||
try:
|
||||
metadata = event.metadata or {}
|
||||
|
||||
# Обробка фото (Vision Encoder)
|
||||
if event.metadata and "photo" in event.metadata:
|
||||
await self._handle_photo(event)
|
||||
if "photo" in metadata:
|
||||
await self._handle_photo(event, metadata)
|
||||
return
|
||||
|
||||
# Обробка документів (Parser Service)
|
||||
if event.metadata and "document" in event.metadata:
|
||||
await self._handle_document(event)
|
||||
if "document" in metadata:
|
||||
await self._handle_document(event, metadata)
|
||||
return
|
||||
|
||||
# Звичайні текстові повідомлення
|
||||
@@ -160,7 +162,8 @@ class RouterHandler:
|
||||
logger.info(f"📤 Sending response: agent={event.agent_id}, chat={event.chat_id}, len={len(answer)}")
|
||||
|
||||
# Перевірити чи треба відповідати голосом (якщо користувач надіслав voice)
|
||||
should_reply_voice = event.raw_update.get("voice") or event.raw_update.get("audio") or event.raw_update.get("video_note")
|
||||
raw_update = event.raw_update or {}
|
||||
should_reply_voice = raw_update.get("voice") or raw_update.get("audio") or raw_update.get("video_note")
|
||||
|
||||
if should_reply_voice:
|
||||
# Синтезувати голос
|
||||
@@ -203,17 +206,16 @@ class RouterHandler:
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error handling Telegram event: {e}", exc_info=True)
|
||||
|
||||
async def _handle_photo(self, event: TelegramUpdateEvent):
|
||||
async def _handle_photo(self, event: TelegramUpdateEvent, metadata: Dict[str, Any]):
|
||||
"""Обробити фото через Swapper vision-8b модель"""
|
||||
try:
|
||||
photo_info = event.metadata.get("photo", {})
|
||||
photo_info = metadata.get("photo", {})
|
||||
file_url = photo_info.get("file_url", "")
|
||||
caption = event.text or ""
|
||||
|
||||
logger.info(f"🖼️ Processing photo: agent={event.agent_id}, url={file_url[:50]}...")
|
||||
|
||||
# Відправити до Router з specialist_vision_8b через Swapper
|
||||
router_url = os.getenv("ROUTER_URL", "http://router:9102")
|
||||
router_request = {
|
||||
"message": f"Опиши це зображення детально: {file_url}",
|
||||
"mode": "chat",
|
||||
@@ -231,7 +233,7 @@ class RouterHandler:
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=90.0) as client:
|
||||
response = await client.post(f"{router_url}/route", json=router_request)
|
||||
response = await client.post(f"{self._router_url}/route", json=router_request)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
@@ -274,10 +276,10 @@ class RouterHandler:
|
||||
except:
|
||||
pass
|
||||
|
||||
async def _handle_document(self, event: TelegramUpdateEvent):
|
||||
async def _handle_document(self, event: TelegramUpdateEvent, metadata: Dict[str, Any]):
|
||||
"""Обробити PDF через Parser Service"""
|
||||
try:
|
||||
doc_info = event.metadata.get("document", {})
|
||||
doc_info = metadata.get("document", {})
|
||||
file_url = doc_info.get("file_url", "")
|
||||
file_name = doc_info.get("file_name", "document.pdf")
|
||||
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Dict
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from aiogram import Bot, Dispatcher, F
|
||||
from aiogram.client.session.aiohttp import AiohttpSession
|
||||
from aiogram.client.telegram import TelegramAPIServer
|
||||
from aiogram.types import Message, Update
|
||||
from aiogram.types import Message
|
||||
|
||||
from .config import settings
|
||||
from .models import TelegramUpdateEvent
|
||||
from .nats_client import nats_client
|
||||
from .bots_registry import bots_registry
|
||||
from .voice_handler import handle_voice_message, handle_document_message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -36,6 +37,7 @@ class TelegramListener:
|
||||
bot = await self._create_bot(bot_token)
|
||||
dp = Dispatcher()
|
||||
|
||||
# TEXT
|
||||
@dp.message(F.text)
|
||||
async def on_message(message: Message) -> None:
|
||||
agent_id = bots_registry.get_agent_by_token(bot_token)
|
||||
@@ -43,9 +45,13 @@ class TelegramListener:
|
||||
logger.warning("⚠️ No agent_id for bot_token=%s...", bot_token[:16])
|
||||
return
|
||||
|
||||
logger.info("📨 Received message: agent=%s, chat=%s, user=%s, len=%d",
|
||||
agent_id, message.chat.id, message.from_user.id if message.from_user else 0,
|
||||
len(message.text or ""))
|
||||
logger.info(
|
||||
"📨 Received TEXT: agent=%s, chat=%s, user=%s, len=%d",
|
||||
agent_id,
|
||||
message.chat.id,
|
||||
message.from_user.id if message.from_user else 0,
|
||||
len(message.text or ""),
|
||||
)
|
||||
|
||||
event = TelegramUpdateEvent(
|
||||
agent_id=agent_id,
|
||||
@@ -53,17 +59,145 @@ class TelegramListener:
|
||||
chat_id=message.chat.id,
|
||||
user_id=message.from_user.id if message.from_user else 0,
|
||||
text=message.text,
|
||||
raw_update=message.model_dump()
|
||||
raw_update=message.model_dump(),
|
||||
)
|
||||
|
||||
logger.info("📤 Publishing to NATS: subject=agent.telegram.update, agent=%s", agent_id)
|
||||
|
||||
await nats_client.publish_json(
|
||||
subject="agent.telegram.update",
|
||||
data=event.model_dump()
|
||||
data=event.model_dump(),
|
||||
)
|
||||
logger.debug("✅ Published to NATS: agent=%s, chat_id=%s", agent_id, message.chat.id)
|
||||
logger.debug("✅ Published TEXT to NATS: agent=%s, chat_id=%s", agent_id, message.chat.id)
|
||||
|
||||
# VOICE / AUDIO / VIDEO NOTE
|
||||
@dp.message(F.voice | F.audio | F.video_note)
|
||||
async def on_voice(message: Message) -> None:
|
||||
agent_id = bots_registry.get_agent_by_token(bot_token)
|
||||
if not agent_id:
|
||||
logger.warning("⚠️ No agent_id for bot_token=%s...", bot_token[:16])
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"🎤 Received VOICE: agent=%s, chat=%s, user=%s",
|
||||
agent_id,
|
||||
message.chat.id,
|
||||
message.from_user.id if message.from_user else 0,
|
||||
)
|
||||
|
||||
await message.answer("🎤 Обробляю голосове повідомлення...")
|
||||
transcribed_text = await handle_voice_message(message, bot)
|
||||
|
||||
if not transcribed_text:
|
||||
await message.answer("❌ Не вдалося розпізнати голос. Спробуйте ще раз.")
|
||||
return
|
||||
|
||||
event = TelegramUpdateEvent(
|
||||
agent_id=agent_id,
|
||||
bot_id=f"bot:{bot_token[:8]}",
|
||||
chat_id=message.chat.id,
|
||||
user_id=message.from_user.id if message.from_user else 0,
|
||||
text=transcribed_text,
|
||||
raw_update=message.model_dump(),
|
||||
)
|
||||
|
||||
await nats_client.publish_json(
|
||||
subject="agent.telegram.update",
|
||||
data=event.model_dump(),
|
||||
)
|
||||
logger.info("✅ Published STT text to NATS: agent=%s", agent_id)
|
||||
|
||||
# DOCUMENTS (PDF)
|
||||
@dp.message(F.document)
|
||||
async def on_document(message: Message) -> None:
|
||||
agent_id = bots_registry.get_agent_by_token(bot_token)
|
||||
if not agent_id:
|
||||
logger.warning("⚠️ No agent_id for bot_token=%s...", bot_token[:16])
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"📄 Received DOCUMENT: agent=%s, chat=%s, user=%s, file=%s",
|
||||
agent_id,
|
||||
message.chat.id,
|
||||
message.from_user.id if message.from_user else 0,
|
||||
message.document.file_name if message.document else "unknown",
|
||||
)
|
||||
|
||||
doc_info = await handle_document_message(message, bot)
|
||||
if not doc_info:
|
||||
logger.info("⏭️ Not a PDF or processing skipped")
|
||||
return
|
||||
|
||||
await message.answer(f"📄 Обробляю документ: {doc_info.get('file_name')}...")
|
||||
|
||||
event = TelegramUpdateEvent(
|
||||
agent_id=agent_id,
|
||||
bot_id=f"bot:{bot_token[:8]}",
|
||||
chat_id=message.chat.id,
|
||||
user_id=message.from_user.id if message.from_user else 0,
|
||||
text=message.caption or f"[DOCUMENT] {doc_info.get('file_name')}",
|
||||
raw_update=message.model_dump(),
|
||||
metadata={"document": doc_info},
|
||||
)
|
||||
|
||||
await nats_client.publish_json(
|
||||
subject="agent.telegram.update",
|
||||
data=event.model_dump(),
|
||||
)
|
||||
logger.info("✅ Published DOCUMENT to NATS: agent=%s", agent_id)
|
||||
|
||||
# PHOTOS
|
||||
@dp.message(F.photo)
|
||||
async def on_photo(message: Message) -> None:
|
||||
agent_id = bots_registry.get_agent_by_token(bot_token)
|
||||
if not agent_id:
|
||||
logger.warning("⚠️ No agent_id for bot_token=%s...", bot_token[:16])
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"🖼️ Received PHOTO: agent=%s, chat=%s, user=%s",
|
||||
agent_id,
|
||||
message.chat.id,
|
||||
message.from_user.id if message.from_user else 0,
|
||||
)
|
||||
|
||||
photo = message.photo[-1] if message.photo else None
|
||||
if not photo:
|
||||
logger.warning("⏭️ Photo payload missing")
|
||||
return
|
||||
|
||||
await message.answer("🖼️ Обробляю зображення...")
|
||||
|
||||
try:
|
||||
file = await bot.get_file(photo.file_id)
|
||||
file_path = file.file_path
|
||||
file_url = f"https://api.telegram.org/file/bot{bot.token}/{file_path}"
|
||||
|
||||
event = TelegramUpdateEvent(
|
||||
agent_id=agent_id,
|
||||
bot_id=f"bot:{bot_token[:8]}",
|
||||
chat_id=message.chat.id,
|
||||
user_id=message.from_user.id if message.from_user else 0,
|
||||
text=message.caption or "[IMAGE]",
|
||||
raw_update=message.model_dump(),
|
||||
metadata={
|
||||
"photo": {
|
||||
"file_url": file_url,
|
||||
"file_id": photo.file_id,
|
||||
"file_size": photo.file_size,
|
||||
"width": photo.width,
|
||||
"height": photo.height,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await nats_client.publish_json(
|
||||
subject="agent.telegram.update",
|
||||
data=event.model_dump(),
|
||||
)
|
||||
logger.info("✅ Published PHOTO to NATS: agent=%s", agent_id)
|
||||
except Exception as e:
|
||||
logger.error("❌ Error processing photo: %s", e, exc_info=True)
|
||||
await message.answer("❌ Помилка обробки зображення. Спробуйте ще раз.")
|
||||
|
||||
# Запускаємо polling у фоні
|
||||
async def _polling():
|
||||
try:
|
||||
logger.info("🔁 Start polling for bot %s...", bot_token[:16])
|
||||
@@ -75,46 +209,111 @@ class TelegramListener:
|
||||
raise
|
||||
|
||||
task = asyncio.create_task(_polling())
|
||||
|
||||
self._bots[bot_token] = bot
|
||||
self._dispatchers[bot_token] = dp
|
||||
self._tasks[bot_token] = task
|
||||
|
||||
|
||||
logger.info("✅ Bot registered and polling started: %s...", bot_token[:16])
|
||||
|
||||
async def send_message(self, agent_id: str, chat_id: int, text: str, reply_to_message_id: int | None = None):
|
||||
# Логування перед відправкою
|
||||
async def send_message(
|
||||
self,
|
||||
agent_id: str,
|
||||
chat_id: int,
|
||||
text: str,
|
||||
reply_to_message_id: Optional[int] = None,
|
||||
):
|
||||
logger.info(
|
||||
f"Sending message: agent_id={agent_id}, chat_id={chat_id}, "
|
||||
f"text_length={len(text)}, reply_to={reply_to_message_id}"
|
||||
"Sending message: agent_id=%s, chat_id=%s, text_length=%d, reply_to=%s",
|
||||
agent_id,
|
||||
chat_id,
|
||||
len(text),
|
||||
reply_to_message_id,
|
||||
)
|
||||
|
||||
bot_token = bots_registry.get_token_by_agent(agent_id)
|
||||
if not bot_token:
|
||||
logger.error(f"No bot token for agent_id={agent_id}")
|
||||
logger.error("No bot token for agent_id=%s", agent_id)
|
||||
raise RuntimeError(f"No bot token for agent_id={agent_id}")
|
||||
|
||||
bot = self._bots.get(bot_token)
|
||||
if not bot:
|
||||
# Якщо бот ще не запущений (наприклад, перший виклик через /send)
|
||||
logger.info(f"Bot not started yet, initializing: agent_id={agent_id}")
|
||||
await self.add_bot(bot_token)
|
||||
bot = self._bots[bot_token]
|
||||
bot = await self._ensure_bot(bot_token)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
reply_to_message_id=reply_to_message_id
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
)
|
||||
logger.info(f"Message sent successfully: agent_id={agent_id}, chat_id={chat_id}")
|
||||
logger.info("✅ Message sent: agent_id=%s, chat_id=%s", agent_id, chat_id)
|
||||
|
||||
async def send_voice(
|
||||
self,
|
||||
agent_id: str,
|
||||
chat_id: int,
|
||||
audio_bytes: bytes,
|
||||
reply_to_message_id: Optional[int] = None,
|
||||
):
|
||||
if not audio_bytes:
|
||||
logger.warning("Empty audio_bytes for agent_id=%s, skip", agent_id)
|
||||
return
|
||||
|
||||
bot_token = bots_registry.get_token_by_agent(agent_id)
|
||||
if not bot_token:
|
||||
logger.error("No bot token for agent_id=%s", agent_id)
|
||||
raise RuntimeError(f"No bot token for agent_id={agent_id}")
|
||||
|
||||
bot = await self._ensure_bot(bot_token)
|
||||
|
||||
from aiogram.types import BufferedInputFile
|
||||
|
||||
audio_file = BufferedInputFile(audio_bytes, filename="voice.mp3")
|
||||
await bot.send_voice(
|
||||
chat_id=chat_id,
|
||||
voice=audio_file,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
)
|
||||
logger.info("✅ Voice message sent: agent_id=%s, chat_id=%s", agent_id, chat_id)
|
||||
|
||||
async def remove_bot(self, bot_token: str) -> None:
|
||||
"""Зупинити polling та видалити бота"""
|
||||
task = self._tasks.pop(bot_token, None)
|
||||
if task:
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
bot = self._bots.pop(bot_token, None)
|
||||
if bot:
|
||||
await bot.session.close()
|
||||
|
||||
self._dispatchers.pop(bot_token, None)
|
||||
logger.info("🗑️ Bot stopped and removed: %s...", bot_token[:16])
|
||||
|
||||
async def _ensure_bot(self, bot_token: str) -> Bot:
|
||||
bot = self._bots.get(bot_token)
|
||||
if bot:
|
||||
return bot
|
||||
await self.add_bot(bot_token)
|
||||
return self._bots[bot_token]
|
||||
|
||||
def get_status(self, agent_id: str) -> Dict[str, any]:
|
||||
"""Повернути статус бота (для UI)"""
|
||||
bot_token = bots_registry.get_token_by_agent(agent_id)
|
||||
if not bot_token:
|
||||
return {"agent_id": agent_id, "registered": False}
|
||||
|
||||
task = self._tasks.get(bot_token)
|
||||
status = {
|
||||
"agent_id": agent_id,
|
||||
"registered": True,
|
||||
"token_prefix": bot_token[:8],
|
||||
"polling": bool(task) and not task.done(),
|
||||
"task_cancelled": bool(task) and task.cancelled(),
|
||||
}
|
||||
return status
|
||||
|
||||
async def shutdown(self):
|
||||
# Завершити polling задачі
|
||||
for task in self._tasks.values():
|
||||
task.cancel()
|
||||
await asyncio.gather(*self._tasks.values(), return_exceptions=True)
|
||||
|
||||
# Закрити бот-сесії
|
||||
for bot in self._bots.values():
|
||||
await bot.session.close()
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ Handles STT (Speech-to-Text) and document processing
|
||||
"""
|
||||
import logging
|
||||
import httpx
|
||||
from aiogram import Bot
|
||||
from aiogram.types import Message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -12,7 +13,7 @@ STT_SERVICE_URL = "http://dagi-stt:9000/stt"
|
||||
PARSER_SERVICE_URL = "http://dagi-parser:9400"
|
||||
|
||||
|
||||
async def handle_voice_message(message: Message, bot_token: str) -> str:
|
||||
async def handle_voice_message(message: Message, bot: Bot) -> str:
|
||||
"""
|
||||
Process voice/audio message through STT
|
||||
|
||||
@@ -43,13 +44,11 @@ async def handle_voice_message(message: Message, bot_token: str) -> str:
|
||||
|
||||
try:
|
||||
# Get file path from Telegram
|
||||
from aiogram import Bot
|
||||
bot = Bot(token=bot_token)
|
||||
file = await bot.get_file(file_id)
|
||||
file_path = file.file_path
|
||||
|
||||
# Download file URL (через офіційний Telegram API для файлів)
|
||||
file_url = f"https://api.telegram.org/file/bot{bot_token}/{file_path}"
|
||||
file_url = f"https://api.telegram.org/file/bot{bot.token}/{file_path}"
|
||||
|
||||
logger.info(f"📥 Downloading audio: {file_url}")
|
||||
|
||||
@@ -83,7 +82,7 @@ async def handle_voice_message(message: Message, bot_token: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
async def handle_document_message(message: Message, bot_token: str) -> dict:
|
||||
async def handle_document_message(message: Message, bot: Bot) -> dict:
|
||||
"""
|
||||
Process document (PDF) message through Parser
|
||||
|
||||
@@ -113,13 +112,11 @@ async def handle_document_message(message: Message, bot_token: str) -> dict:
|
||||
|
||||
try:
|
||||
# Get file path from Telegram
|
||||
from aiogram import Bot
|
||||
bot = Bot(token=bot_token)
|
||||
file = await bot.get_file(file_id)
|
||||
file_path = file.file_path
|
||||
|
||||
# Download file URL (через офіційний Telegram API для файлів)
|
||||
file_url = f"https://api.telegram.org/file/bot{bot_token}/{file_path}"
|
||||
file_url = f"https://api.telegram.org/file/bot{bot.token}/{file_path}"
|
||||
|
||||
logger.info(f"📥 PDF URL: {file_url}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user