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:
Apple
2025-11-27 00:19:40 -08:00
parent 5bed515852
commit 3de3c8cb36
6371 changed files with 1317450 additions and 932 deletions

View File

@@ -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)

View File

@@ -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):
"""

View File

@@ -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]

View File

@@ -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")

View File

@@ -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()

View File

@@ -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}")