snapshot: NODE1 production state 2026-02-09
Complete snapshot of /opt/microdao-daarion/ from NODE1 (144.76.224.179).
This represents the actual running production code that has diverged
significantly from the previous main branch.
Key changes from old main:
- Gateway (http_api.py): expanded from ~40KB to 164KB with full agent support
- Router: new /v1/agents/{id}/infer endpoint with vision + DeepSeek routing
- Behavior Policy: SOWA v2.2 (3-level: FULL/ACK/SILENT)
- Agent Registry: config/agent_registry.yml as single source of truth
- 13 agents configured (was 3)
- Memory service integration
- CrewAI teams and roles
Excluded from snapshot: venv/, .env, data/, backups, .tgz archives
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
42
telegram-infrastructure/.gitignore
vendored
42
telegram-infrastructure/.gitignore
vendored
@@ -1,42 +0,0 @@
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
# Data directory
|
||||
data/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
telegram-gateway/bots.yaml
|
||||
@@ -1,412 +0,0 @@
|
||||
# 🔧 Завдання для Cursor: Виправити telegram-gateway (DAARWIZZ і Helion не відповідають)
|
||||
|
||||
## 🎯 Мета
|
||||
Зробити так, щоб обидва боти (DAARWIZZ і Helion) автоматично ініціалізувались при старті та відповідали на повідомлення через polling.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Проблеми зараз
|
||||
|
||||
1. ❌ Боти не ініціалізуються автоматично при старті
|
||||
2. ❌ `bots.yaml` не монтується в контейнер
|
||||
3. ❌ Polling не запускається для ботів з конфігурації
|
||||
4. ❌ Логування недостатнє для діагностики
|
||||
|
||||
---
|
||||
|
||||
## ✅ Що потрібно зробити
|
||||
|
||||
### 1. Виправити `docker-compose.yml`
|
||||
|
||||
**Файл:** `/Users/apple/github-projects/microdao-daarion/telegram-infrastructure/docker-compose.yml`
|
||||
|
||||
**Додати volume для bots.yaml:**
|
||||
|
||||
```yaml
|
||||
telegram-gateway:
|
||||
build: ./telegram-gateway
|
||||
container_name: telegram-gateway
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- TELEGRAM_API_BASE=http://telegram-bot-api:8081
|
||||
- NATS_URL=nats://nats:4222
|
||||
- ROUTER_BASE_URL=http://router:9102
|
||||
- DEBUG=true
|
||||
depends_on:
|
||||
- telegram-bot-api
|
||||
- nats
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000"
|
||||
volumes:
|
||||
- ./telegram-gateway/bots.yaml:/app/bots.yaml:ro # ← ДОДАТИ ЦЕЙ РЯДОК
|
||||
networks:
|
||||
- telegram-net
|
||||
- dagi-network
|
||||
```
|
||||
|
||||
**Чому:** Контейнер має читати `bots.yaml` при старті, але файл не копіюється в образ і не монтується як volume.
|
||||
|
||||
---
|
||||
|
||||
### 2. Виправити `app/main.py` — автоматична ініціалізація ботів
|
||||
|
||||
**Файл:** `/Users/apple/github-projects/microdao-daarion/telegram-infrastructure/telegram-gateway/app/main.py`
|
||||
|
||||
**Поточний код `on_startup()`:**
|
||||
```python
|
||||
@app.on_event("startup")
|
||||
async def on_startup():
|
||||
# Підключаємося до NATS
|
||||
await nats_client.connect()
|
||||
logger.info("Connected to NATS at %s", settings.NATS_URL)
|
||||
|
||||
# На цьому етапі список ботів пустий; їх додаватимуть через /bots/register.
|
||||
# За потреби сюди можна додати завантаження конфігів з БД.
|
||||
```
|
||||
|
||||
**Новий код `on_startup()` (ЗАМІНИТИ):**
|
||||
```python
|
||||
@app.on_event("startup")
|
||||
async def on_startup():
|
||||
# 1. Підключаємося до NATS
|
||||
await nats_client.connect()
|
||||
logger.info("✅ Connected to NATS at %s", settings.NATS_URL)
|
||||
|
||||
# 2. Завантажити конфігурацію ботів з bots.yaml або env
|
||||
from .config import load_bots_config
|
||||
|
||||
try:
|
||||
bot_configs = load_bots_config()
|
||||
logger.info("📋 Loaded %d bot(s) from config", len(bot_configs))
|
||||
except Exception as e:
|
||||
logger.warning("⚠️ Failed to load bots config: %s", e)
|
||||
bot_configs = []
|
||||
|
||||
# 3. Зареєструвати всі боти в реєстрі
|
||||
if bot_configs:
|
||||
bots_registry.register_from_config(bot_configs)
|
||||
logger.info("📝 Registered %d bot(s) in registry", len(bot_configs))
|
||||
|
||||
# 4. Запустити polling для кожного бота
|
||||
for bot_config in bot_configs:
|
||||
agent_id = bot_config.agent_id
|
||||
bot_token = bot_config.bot_token
|
||||
|
||||
# Запускаємо polling в фоновій задачі
|
||||
asyncio.create_task(telegram_listener.add_bot(bot_token))
|
||||
logger.info("🚀 Started polling for agent=%s (token=%s...)", agent_id, bot_token[:16])
|
||||
|
||||
if not bot_configs:
|
||||
logger.warning("⚠️ No bots configured. Use /bots/register to add bots manually.")
|
||||
```
|
||||
|
||||
**Чому:** Зараз боти не запускаються автоматично — треба викликати `/bots/register` вручну. Цей код автоматично завантажує конфіг і запускає polling при старті.
|
||||
|
||||
---
|
||||
|
||||
### 3. Перевірити `app/config.py` — функція `load_bots_config()`
|
||||
|
||||
**Файл:** `/Users/apple/github-projects/microdao-daarion/telegram-infrastructure/telegram-gateway/app/config.py`
|
||||
|
||||
**Переконайся, що функція існує і виглядає приблизно так:**
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
import yaml
|
||||
import os
|
||||
from pydantic import BaseModel
|
||||
|
||||
class BotConfig(BaseModel):
|
||||
agent_id: str
|
||||
bot_token: str
|
||||
|
||||
def load_bots_config() -> List[BotConfig]:
|
||||
"""
|
||||
Завантажити конфігурацію ботів з bots.yaml або env variables.
|
||||
|
||||
Пріоритет:
|
||||
1. bots.yaml (якщо існує)
|
||||
2. Environment variables: BOT_<AGENT_ID>_TOKEN
|
||||
"""
|
||||
bots = []
|
||||
|
||||
# Спробувати завантажити з bots.yaml
|
||||
config_path = Path("/app/bots.yaml") # Шлях в контейнері
|
||||
if config_path.exists():
|
||||
try:
|
||||
with open(config_path, "r") as f:
|
||||
data = yaml.safe_load(f)
|
||||
if data and "bots" in data:
|
||||
for bot_data in data["bots"]:
|
||||
bots.append(BotConfig(**bot_data))
|
||||
except Exception as e:
|
||||
# Fallback до env variables
|
||||
pass
|
||||
|
||||
# Fallback: завантажити з env variables
|
||||
if not bots:
|
||||
for key, value in os.environ.items():
|
||||
if key.startswith("BOT_") and key.endswith("_TOKEN"):
|
||||
agent_id = key[4:-6].lower() # BOT_DAARWIZZ_TOKEN -> daarwizz
|
||||
bots.append(BotConfig(agent_id=agent_id, bot_token=value))
|
||||
|
||||
return bots
|
||||
```
|
||||
|
||||
**Якщо функція відсутня або неповна — додай/виправ її.**
|
||||
|
||||
---
|
||||
|
||||
### 4. Перевірити `app/bots_registry.py` — метод `register_from_config()`
|
||||
|
||||
**Файл:** `/Users/apple/github-projects/microdao-daarion/telegram-infrastructure/telegram-gateway/app/bots_registry.py`
|
||||
|
||||
**Переконайся, що метод існує:**
|
||||
|
||||
```python
|
||||
from typing import List
|
||||
from .config import BotConfig
|
||||
|
||||
class BotsRegistry:
|
||||
# ... інші методи ...
|
||||
|
||||
def register_from_config(self, configs: List[BotConfig]) -> None:
|
||||
"""Масова реєстрація ботів з конфігурації"""
|
||||
for config in configs:
|
||||
self.register(config)
|
||||
# або напряму:
|
||||
# self._agent_to_token[config.agent_id] = config.bot_token
|
||||
# self._token_to_agent[config.bot_token] = config.agent_id
|
||||
```
|
||||
|
||||
**Якщо метод відсутній — додай його.**
|
||||
|
||||
---
|
||||
|
||||
### 5. Додати детальне логування в `app/telegram_listener.py`
|
||||
|
||||
**Файл:** `/Users/apple/github-projects/microdao-daarion/telegram-infrastructure/telegram-gateway/app/telegram_listener.py`
|
||||
|
||||
**Оновити метод `add_bot()` для кращого логування:**
|
||||
|
||||
```python
|
||||
async def add_bot(self, bot_token: str) -> None:
|
||||
if bot_token in self._bots:
|
||||
logger.info("🔄 Bot already registered: %s...", bot_token[:16])
|
||||
return
|
||||
|
||||
logger.info("🤖 Creating bot: %s...", bot_token[:16])
|
||||
bot = await self._create_bot(bot_token)
|
||||
dp = Dispatcher()
|
||||
|
||||
@dp.message(F.text)
|
||||
async def on_message(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 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 ""))
|
||||
|
||||
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.text,
|
||||
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()
|
||||
)
|
||||
|
||||
# Запускаємо polling у фоні
|
||||
async def _polling():
|
||||
try:
|
||||
logger.info("🔁 Start polling for bot %s...", bot_token[:16])
|
||||
await dp.start_polling(bot)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("🛑 Polling cancelled for bot %s...", bot_token[:16])
|
||||
except Exception as e:
|
||||
logger.exception("💥 Polling error for bot %s...: %s", bot_token[:16], e)
|
||||
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])
|
||||
```
|
||||
|
||||
**Чому:** Детальніше логування допоможе побачити, що відбувається при отриманні повідомлень.
|
||||
|
||||
---
|
||||
|
||||
### 6. Створити `bots.yaml` локально (якщо ще не створений)
|
||||
|
||||
**Файл:** `/Users/apple/github-projects/microdao-daarion/telegram-infrastructure/telegram-gateway/bots.yaml`
|
||||
|
||||
**Вміст:**
|
||||
```yaml
|
||||
bots:
|
||||
- agent_id: daarwizz
|
||||
bot_token: 8323412397:AAFxaru-hHRl08A3T6TC02uHLvO5wAB0m3M
|
||||
- agent_id: helion
|
||||
bot_token: 8112062582:AAGI7tPFo4gvZ6bfbkFu9miq5GdAH2_LvcM
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Як перевірити після змін
|
||||
|
||||
### 1. Деплой на сервер (виконати з Mac)
|
||||
|
||||
```bash
|
||||
cd /Users/apple/github-projects/microdao-daarion/telegram-infrastructure
|
||||
|
||||
# Синхронізація коду
|
||||
rsync -avz --exclude='.git' --exclude='__pycache__' --exclude='*.pyc' --exclude='data/' ./ root@144.76.224.179:/opt/telegram-infrastructure/
|
||||
|
||||
# Перезапуск на сервері
|
||||
ssh root@144.76.224.179 "cd /opt/telegram-infrastructure && docker compose down telegram-gateway && docker compose up -d --build telegram-gateway"
|
||||
```
|
||||
|
||||
### 2. Перевірка логів (на сервері)
|
||||
|
||||
```bash
|
||||
ssh root@144.76.224.179
|
||||
docker logs -f telegram-gateway
|
||||
```
|
||||
|
||||
**Очікувані рядки в логах:**
|
||||
```
|
||||
✅ Connected to NATS at nats://nats:4222
|
||||
📋 Loaded 2 bot(s) from config
|
||||
📝 Registered 2 bot(s) in registry
|
||||
🚀 Started polling for agent=daarwizz (token=8323412397:AAFxa...)
|
||||
🚀 Started polling for agent=helion (token=8112062582:AAGI7...)
|
||||
🔁 Start polling for bot 8323412397:AAFxa...
|
||||
🔁 Start polling for bot 8112062582:AAGI7...
|
||||
```
|
||||
|
||||
### 3. Перевірка списку ботів
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8000/bots/list | jq .
|
||||
```
|
||||
|
||||
**Очікувана відповідь:**
|
||||
```json
|
||||
{
|
||||
"bots": ["daarwizz", "helion"],
|
||||
"count": 2
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Перевірка polling tasks
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8000/debug/bots | jq .
|
||||
```
|
||||
|
||||
**Очікувана відповідь:**
|
||||
```json
|
||||
{
|
||||
"registered_bots": 2,
|
||||
"bot_tokens": ["8323412397:AAFxa...", "8112062582:AAGI7..."],
|
||||
"registry_mappings": 2,
|
||||
"active_tasks": 2
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8000/debug/bots/tasks | jq .
|
||||
```
|
||||
|
||||
**Очікувана відповідь:**
|
||||
```json
|
||||
{
|
||||
"8323412397:AAFxa...": {
|
||||
"done": false,
|
||||
"cancelled": false
|
||||
},
|
||||
"8112062582:AAGI7...": {
|
||||
"done": false,
|
||||
"cancelled": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Надіслати тестове повідомлення
|
||||
|
||||
**В Telegram:**
|
||||
1. Надішліть "Привіт" в DAARWIZZ бот
|
||||
2. Надішліть "Привіт" в Helion бот
|
||||
|
||||
**В логах має з'явитись:**
|
||||
```
|
||||
📨 Received message: agent=daarwizz, chat=123456, user=789, len=6
|
||||
📤 Publishing to NATS: subject=agent.telegram.update, agent=daarwizz
|
||||
📨 Received message: agent=helion, chat=123456, user=789, len=6
|
||||
📤 Publishing to NATS: subject=agent.telegram.update, agent=helion
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Troubleshooting
|
||||
|
||||
### Якщо боти не ініціалізуються:
|
||||
```bash
|
||||
# Перевірити, чи bots.yaml є в контейнері
|
||||
docker exec telegram-gateway cat /app/bots.yaml
|
||||
|
||||
# Має показати вміст файлу. Якщо "No such file" — volume не працює
|
||||
```
|
||||
|
||||
### Якщо polling не запускається:
|
||||
```bash
|
||||
# Перевірити логи aiogram
|
||||
docker logs telegram-gateway 2>&1 | grep -i "polling\|error\|exception"
|
||||
```
|
||||
|
||||
### Якщо повідомлення не приходять:
|
||||
```bash
|
||||
# Перевірити, чи webhooks видалені
|
||||
curl -s "https://api.telegram.org/bot8323412397:AAFxaru-hHRl08A3T6TC02uHLvO5wAB0m3M/getWebhookInfo" | jq .result.url
|
||||
curl -s "https://api.telegram.org/bot8112062582:AAGI7tPFo4gvZ6bfbkFu9miq5GdAH2_LvcM/getWebhookInfo" | jq .result.url
|
||||
|
||||
# Обидва мають повертати: "" (порожній рядок = webhook видалений)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Checklist для Cursor
|
||||
|
||||
- [ ] Додати `volumes:` в `docker-compose.yml` для `telegram-gateway`
|
||||
- [ ] Оновити `on_startup()` в `app/main.py` з автоматичною ініціалізацією
|
||||
- [ ] Перевірити/додати `load_bots_config()` в `app/config.py`
|
||||
- [ ] Перевірити/додати `register_from_config()` в `app/bots_registry.py`
|
||||
- [ ] Додати детальне логування в `app/telegram_listener.py`
|
||||
- [ ] Створити `telegram-gateway/bots.yaml` з реальними токенами
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Очікуваний результат
|
||||
|
||||
Після виконання всіх кроків:
|
||||
- ✅ Обидва боти (DAARWIZZ і Helion) автоматично стартують при запуску контейнера
|
||||
- ✅ Polling працює для обох ботів
|
||||
- ✅ Повідомлення отримуються і публікуються в NATS
|
||||
- ✅ Детальні логи для діагностики
|
||||
- ✅ `/bots/list` показує обидва боти
|
||||
|
||||
**Після цього агенти мають відповідати на повідомлення!** 🎉
|
||||
@@ -1,556 +0,0 @@
|
||||
# CURSOR_INSTRUCTIONS.md
|
||||
|
||||
## telegram-gateway для DAARION / microdao
|
||||
|
||||
Мета: реалізувати сервіс `telegram-gateway`, який:
|
||||
|
||||
- працює через **Local Telegram Bot API** (`telegram-bot-api`), без SSL і без публічних вебхуків;
|
||||
- отримує апдейти від **декількох Telegram-ботів** через **long polling**;
|
||||
- публікує вхідні повідомлення в **NATS** як події `agent.telegram.update`;
|
||||
- приймає HTTP-запити від DAGI/microdao для надсилання повідомлень назад у Telegram (`agent.telegram.send`);
|
||||
- інтегрується з існуючим **Router** (HTTP API `http://router:9102`), але не залежить від нього жорстко.
|
||||
|
||||
Інфраструктура (вже піднята Warp-ом):
|
||||
|
||||
- `telegram-bot-api` (Local Bot API, `http://telegram-bot-api:8081`, всередині Docker-мережі).
|
||||
- `nats` (`nats://nats:4222`).
|
||||
- `telegram-gateway` (цей сервіс, FastAPI, порт `8000` всередині контейнера).
|
||||
|
||||
Все це описано в `docker-compose.yml` у каталозі `telegram-infrastructure/`.
|
||||
|
||||
---
|
||||
|
||||
## 0. Структура проєкту
|
||||
|
||||
Працюємо в каталозі:
|
||||
|
||||
`telegram-infrastructure/telegram-gateway/`
|
||||
|
||||
Очікувана структура:
|
||||
|
||||
```text
|
||||
telegram-infrastructure/
|
||||
docker-compose.yml
|
||||
.env
|
||||
|
||||
telegram-gateway/
|
||||
Dockerfile
|
||||
requirements.txt (або pyproject.toml — див. нижче)
|
||||
app/
|
||||
__init__.py
|
||||
main.py
|
||||
config.py
|
||||
nats_client.py
|
||||
telegram_listener.py
|
||||
models.py
|
||||
bots_registry.py
|
||||
```
|
||||
|
||||
**Завдання для Cursor:** створити/оновити ці файли згідно інструкції нижче.
|
||||
|
||||
---
|
||||
|
||||
## 1. Залежності (requirements.txt)
|
||||
|
||||
Онови `telegram-gateway/requirements.txt`, додавши:
|
||||
|
||||
```txt
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
aiogram==3.*
|
||||
nats-py
|
||||
pydantic-settings
|
||||
httpx
|
||||
```
|
||||
|
||||
Якщо вже існують інші залежності — збережи їх.
|
||||
|
||||
---
|
||||
|
||||
## 2. Конфігурація (app/config.py)
|
||||
|
||||
Створи файл `app/config.py`:
|
||||
|
||||
```python
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Local Telegram Bot API (Docker service)
|
||||
TELEGRAM_API_BASE: str = "http://telegram-bot-api:8081"
|
||||
|
||||
# NATS event bus
|
||||
NATS_URL: str = "nats://nats:4222"
|
||||
|
||||
# Опційно: URL Router-а DAGI/microdao
|
||||
ROUTER_BASE_URL: str = "http://router:9102"
|
||||
|
||||
# Через це поле можна включити debug-логування
|
||||
DEBUG: bool = False
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Моделі подій і DTO (app/models.py)
|
||||
|
||||
Створи `app/models.py` з Pydantic-схемами:
|
||||
|
||||
```python
|
||||
from typing import Optional, Any, Dict
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TelegramUpdateEvent(BaseModel):
|
||||
"""Подія 'agent.telegram.update' для NATS."""
|
||||
agent_id: str
|
||||
bot_id: str
|
||||
chat_id: int
|
||||
user_id: int
|
||||
text: Optional[str] = None
|
||||
raw_update: Dict[str, Any]
|
||||
|
||||
|
||||
class TelegramSendCommand(BaseModel):
|
||||
"""Команда від DAGI/microdao для надсилання повідомлення в Telegram."""
|
||||
agent_id: str
|
||||
chat_id: int
|
||||
text: str
|
||||
reply_to_message_id: Optional[int] = None
|
||||
|
||||
|
||||
class BotRegistration(BaseModel):
|
||||
"""HTTP payload для реєстрації нового бота/агента."""
|
||||
agent_id: str
|
||||
bot_token: str
|
||||
# опційно: allowed_chat_id, ім'я, тощо
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Клієнт NATS (app/nats_client.py)
|
||||
|
||||
Створи `app/nats_client.py` з асинхронним клієнтом:
|
||||
|
||||
```python
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
import nats
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
class NatsClient:
|
||||
def __init__(self, url: str):
|
||||
self._url = url
|
||||
self._nc: Optional[nats.NATS] = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
if self._nc is None or self._nc.is_closed:
|
||||
self._nc = await nats.connect(self._url)
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._nc and not self._nc.is_closed:
|
||||
await self._nc.drain()
|
||||
await self._nc.close()
|
||||
|
||||
async def publish_json(self, subject: str, data: dict) -> None:
|
||||
if self._nc is None or self._nc.is_closed:
|
||||
await self.connect()
|
||||
await self._nc.publish(subject, json.dumps(data).encode("utf-8"))
|
||||
|
||||
|
||||
nats_client = NatsClient(settings.NATS_URL)
|
||||
```
|
||||
|
||||
На цьому етапі **підписки** NATS не потрібні — тільки `publish`. Підписуватися на `agent.telegram.send` можна пізніше, якщо буде потрібно. Зараз відправлення в Telegram робимо через HTTP `/send`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Реєстр ботів (app/bots_registry.py)
|
||||
|
||||
Нам потрібна мапа `agent_id → bot_token` (і навпаки) для маршрутизації.
|
||||
|
||||
Створи `app/bots_registry.py`:
|
||||
|
||||
```python
|
||||
from typing import Dict, Optional
|
||||
|
||||
from .models import BotRegistration
|
||||
|
||||
|
||||
class BotsRegistry:
|
||||
"""
|
||||
Простий in-memory реєстр.
|
||||
TODO: замінити на персистентне сховище (PostgreSQL/microdao DB).
|
||||
"""
|
||||
def __init__(self) -> None:
|
||||
self._agent_to_token: Dict[str, str] = {}
|
||||
self._token_to_agent: Dict[str, str] = {}
|
||||
|
||||
def register(self, reg: BotRegistration) -> None:
|
||||
self._agent_to_token[reg.agent_id] = reg.bot_token
|
||||
self._token_to_agent[reg.bot_token] = reg.agent_id
|
||||
|
||||
def get_token_by_agent(self, agent_id: str) -> Optional[str]:
|
||||
return self._agent_to_token.get(agent_id)
|
||||
|
||||
def get_agent_by_token(self, bot_token: str) -> Optional[str]:
|
||||
return self._token_to_agent.get(bot_token)
|
||||
|
||||
|
||||
bots_registry = BotsRegistry()
|
||||
```
|
||||
|
||||
На MVP-дроті достатньо in-memory. Потім можна буде підключити БД microdao (таблиця `telegram_bots`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Telegram listener (app/telegram_listener.py)
|
||||
|
||||
Задача: запускати **long polling** для кількох ботів через Local Bot API і на кожне вхідне повідомлення публікувати `agent.telegram.update` у NATS.
|
||||
|
||||
Створи `app/telegram_listener.py`:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
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 .config import settings
|
||||
from .models import TelegramUpdateEvent
|
||||
from .nats_client import nats_client
|
||||
from .bots_registry import bots_registry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TelegramListener:
|
||||
def __init__(self) -> None:
|
||||
self._bots: Dict[str, Bot] = {} # bot_token -> Bot
|
||||
self._dispatchers: Dict[str, Dispatcher] = {}
|
||||
self._tasks: Dict[str, asyncio.Task] = {}
|
||||
self._server = TelegramAPIServer.from_base(settings.TELEGRAM_API_BASE)
|
||||
|
||||
async def _create_bot(self, bot_token: str) -> Bot:
|
||||
session = AiohttpSession(api=self._server)
|
||||
bot = Bot(token=bot_token, session=session)
|
||||
return bot
|
||||
|
||||
async def add_bot(self, bot_token: str) -> None:
|
||||
if bot_token in self._bots:
|
||||
return
|
||||
|
||||
bot = await self._create_bot(bot_token)
|
||||
dp = Dispatcher()
|
||||
|
||||
@dp.message(F.text)
|
||||
async def on_message(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)
|
||||
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=message.text,
|
||||
raw_update=message.model_dump()
|
||||
)
|
||||
await nats_client.publish_json(
|
||||
subject="agent.telegram.update",
|
||||
data=event.model_dump()
|
||||
)
|
||||
|
||||
# Запускаємо polling у фоні
|
||||
async def _polling():
|
||||
try:
|
||||
logger.info("Start polling for bot %s", bot_token)
|
||||
await dp.start_polling(bot)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Polling cancelled for bot %s", bot_token)
|
||||
except Exception as e:
|
||||
logger.exception("Polling error for bot %s: %s", bot_token, e)
|
||||
raise
|
||||
|
||||
task = asyncio.create_task(_polling())
|
||||
|
||||
self._bots[bot_token] = bot
|
||||
self._dispatchers[bot_token] = dp
|
||||
self._tasks[bot_token] = task
|
||||
|
||||
async def send_message(self, agent_id: str, chat_id: int, text: str, reply_to_message_id: int | None = None):
|
||||
bot_token = bots_registry.get_token_by_agent(agent_id)
|
||||
if not bot_token:
|
||||
raise RuntimeError(f"No bot token for agent_id={agent_id}")
|
||||
|
||||
bot = self._bots.get(bot_token)
|
||||
if not bot:
|
||||
# Якщо бот ще не запущений (наприклад, перший виклик через /send)
|
||||
await self.add_bot(bot_token)
|
||||
bot = self._bots[bot_token]
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
reply_to_message_id=reply_to_message_id
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
|
||||
telegram_listener = TelegramListener()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. FastAPI: main + HTTP-ендпоінти (app/main.py)
|
||||
|
||||
Онови `app/main.py` так, щоб:
|
||||
|
||||
* при старті сервісу:
|
||||
* підключатися до NATS;
|
||||
* за потреби — попередньо запускати polling для вже відомих ботів (MVP можна пропустити);
|
||||
* надавати HTTP-ендпоінти:
|
||||
* `GET /healthz`
|
||||
* `POST /bots/register` — реєстрація нового бота для агента
|
||||
* `POST /send` — надсилання повідомлення в Telegram від агента
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
|
||||
from .config import settings
|
||||
from .models import BotRegistration, TelegramSendCommand
|
||||
from .bots_registry import bots_registry
|
||||
from .nats_client import nats_client
|
||||
from .telegram_listener import telegram_listener
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logging.basicConfig(level=logging.INFO if settings.DEBUG else logging.WARNING)
|
||||
|
||||
app = FastAPI(title="telegram-gateway", version="0.1.0")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def on_startup():
|
||||
# Підключаємося до NATS
|
||||
await nats_client.connect()
|
||||
logger.info("Connected to NATS at %s", settings.NATS_URL)
|
||||
|
||||
# На цьому етапі список ботів пустий; їх додаватимуть через /bots/register.
|
||||
# За потреби сюди можна додати завантаження конфігів з БД.
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def on_shutdown():
|
||||
await telegram_listener.shutdown()
|
||||
await nats_client.close()
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/bots/register")
|
||||
async def register_bot(reg: BotRegistration):
|
||||
"""
|
||||
Прив'язати Telegram-бота до agent_id.
|
||||
1) Зберегти в реєстрі (in-memory);
|
||||
2) Запустити polling для цього bot_token.
|
||||
3) Опційно: опублікувати подію bot.registered у NATS.
|
||||
"""
|
||||
bots_registry.register(reg)
|
||||
|
||||
# Запускаємо polling
|
||||
asyncio.create_task(telegram_listener.add_bot(reg.bot_token))
|
||||
|
||||
# Публікуємо подію реєстрації (може ловити Router або інший сервіс)
|
||||
await nats_client.publish_json(
|
||||
subject="bot.registered",
|
||||
data={"agent_id": reg.agent_id, "bot_token": reg.bot_token}
|
||||
)
|
||||
|
||||
return {"status": "registered"}
|
||||
|
||||
|
||||
@app.post("/send")
|
||||
async def send_message(cmd: TelegramSendCommand):
|
||||
"""
|
||||
Відправити повідомлення в Telegram від імені агента.
|
||||
Викликається DAGI Router / microdao.
|
||||
"""
|
||||
try:
|
||||
await telegram_listener.send_message(
|
||||
agent_id=cmd.agent_id,
|
||||
chat_id=cmd.chat_id,
|
||||
text=cmd.text,
|
||||
reply_to_message_id=cmd.reply_to_message_id,
|
||||
)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
return {"status": "sent"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Dockerfile (telegram-gateway/Dockerfile)
|
||||
|
||||
Переконайся, що `Dockerfile` відповідає цим вимогам:
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. docker-compose.yml (корінь telegram-infrastructure)
|
||||
|
||||
Переконайся, що сервіс `telegram-gateway` описаний приблизно так:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
telegram-bot-api:
|
||||
image: ghcr.io/tdlib/telegram-bot-api:latest
|
||||
container_name: telegram-bot-api
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
command:
|
||||
- --local
|
||||
- --http-port=8081
|
||||
- --dir=/var/lib/telegram-bot-api
|
||||
volumes:
|
||||
- ./data/telegram-bot-api:/var/lib/telegram-bot-api
|
||||
ports:
|
||||
- "127.0.0.1:8081:8081"
|
||||
|
||||
nats:
|
||||
image: nats:2
|
||||
container_name: nats
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:4222:4222"
|
||||
|
||||
telegram-gateway:
|
||||
build: ./telegram-gateway
|
||||
container_name: telegram-gateway
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
- telegram-bot-api
|
||||
- nats
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Послідовність дій (для запуску й тесту)
|
||||
|
||||
1. Переконайся, що `.env` у корені `telegram-infrastructure` містить:
|
||||
|
||||
```env
|
||||
TELEGRAM_API_ID=XXXXXXX
|
||||
TELEGRAM_API_HASH=XXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
NATS_URL=nats://nats:4222
|
||||
TELEGRAM_API_BASE=http://telegram-bot-api:8081
|
||||
```
|
||||
|
||||
2. Запусти стек:
|
||||
|
||||
```bash
|
||||
docker compose build telegram-gateway
|
||||
docker compose up -d telegram-bot-api nats telegram-gateway
|
||||
```
|
||||
|
||||
3. Перевір `telegram-gateway`:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/healthz
|
||||
# очікується: {"status":"ok"}
|
||||
```
|
||||
|
||||
4. Перевір Local Telegram Bot API (з реальним BOT_TOKEN):
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8081/bot<YOUR_BOT_TOKEN>/getMe
|
||||
```
|
||||
|
||||
5. Зареєструй бота для агента:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/bots/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"agent_id": "ag_helion",
|
||||
"bot_token": "<YOUR_BOT_TOKEN>"
|
||||
}'
|
||||
```
|
||||
|
||||
6. Надішли тестове повідомлення з агента:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/send \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"agent_id": "ag_helion",
|
||||
"chat_id": <YOUR_CHAT_ID>,
|
||||
"text": "Привіт від агента Helion!",
|
||||
"reply_to_message_id": null
|
||||
}'
|
||||
```
|
||||
|
||||
7. Перевір у логах `nats` або через окремий subscriber, що події `agent.telegram.update` публікуються при вхідних повідомленнях.
|
||||
|
||||
---
|
||||
|
||||
## 11. Що далі (опційно)
|
||||
|
||||
Після того, як MVP працює:
|
||||
|
||||
* підключити реальну БД microdao для таблиці `telegram_bots` замість in-memory `BotsRegistry`;
|
||||
* додати підписника NATS на `agent.telegram.send`, щоб можна було слати повідомлення не тільки через HTTP `/send`, а й через події;
|
||||
* розширити payload подій (`mode`, `team_id`, `channel_id`) під існуючий Event Catalog microdao;
|
||||
* додати обмеження / rate limiting / логування в стилі microdao.
|
||||
|
||||
---
|
||||
@@ -1,33 +0,0 @@
|
||||
# Environment Variables Template
|
||||
|
||||
Скопіюй цей файл як `.env` і заповни реальні значення.
|
||||
|
||||
```env
|
||||
# Telegram Bot API (Local Bot API)
|
||||
TELEGRAM_API_ID=XXXXXXX
|
||||
TELEGRAM_API_HASH=XXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
|
||||
# Telegram Gateway Service
|
||||
TELEGRAM_API_BASE=http://telegram-bot-api:8081
|
||||
NATS_URL=nats://nats:4222
|
||||
ROUTER_BASE_URL=http://router:9102
|
||||
DEBUG=false
|
||||
|
||||
# Bot Tokens (опційно, якщо не використовується bots.yaml)
|
||||
# BOT_DAARWIZZ_TOKEN=8323412397:AAFxaru-hHRl08A3T6TC02uHLvO5wAB0m3M
|
||||
# BOT_HELION_TOKEN=8112062582:AAGI7tPFo4gvZ6bfbkFu9miq5GdAH2_LvcM
|
||||
```
|
||||
|
||||
## Опис змінних
|
||||
|
||||
- `TELEGRAM_API_ID` / `TELEGRAM_API_HASH` — отримай з https://my.telegram.org/apps
|
||||
- `TELEGRAM_API_BASE` — URL Local Telegram Bot API (за замовчуванням: `http://telegram-bot-api:8081`)
|
||||
- `NATS_URL` — URL NATS event bus (за замовчуванням: `nats://nats:4222`)
|
||||
- `ROUTER_BASE_URL` — URL DAGI Router (за замовчуванням: `http://router:9102`)
|
||||
- `DEBUG` — увімкнути детальне логування (`true`/`false`)
|
||||
|
||||
## Примітки
|
||||
|
||||
- Токени ботів краще зберігати в `bots.yaml` (не комітити в Git!)
|
||||
- `.env` файл не повинен потрапляти в Git (додай до `.gitignore`)
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
# Telegram Infrastructure
|
||||
|
||||
Мінімальний стек для інтеграції Telegram-ботів з системою агентів DAGI/microDAO.
|
||||
|
||||
## 🏗️ Архітектура
|
||||
|
||||
Цей стек складається з трьох сервісів:
|
||||
|
||||
### 1. **telegram-bot-api** (Local Telegram Bot API)
|
||||
- Офіційний Local Telegram Bot API від Telegram
|
||||
- Працює в режимі `--local` (HTTP only, без SSL)
|
||||
- Доступний тільки на `127.0.0.1:8081` (не публічний)
|
||||
- Дозволяє використовувати Bot API без зовнішніх запитів до Telegram серверів
|
||||
|
||||
### 2. **nats** (Message Bus)
|
||||
- NATS як шина подій між сервісами
|
||||
- Простий режим без кластера
|
||||
- Доступний на `127.0.0.1:4222`
|
||||
|
||||
### 3. **telegram-gateway** (Python FastAPI)
|
||||
- Міст між Telegram Bot API і NATS
|
||||
- Базовий FastAPI сервіс з `/healthz` endpoint
|
||||
- Буде розширений через Cursor для:
|
||||
- Читання апдейтів з Telegram
|
||||
- Публікації подій у NATS
|
||||
- Прийому HTTP команд від інших сервісів
|
||||
|
||||
## 📋 Передумови
|
||||
|
||||
- Docker + Docker Compose
|
||||
- Telegram API credentials (API ID, API Hash) з https://my.telegram.org/apps
|
||||
- Домен, що вказує на ваш сервер (опціонально для майбутнього HTTPS)
|
||||
|
||||
## 🚀 Швидкий старт
|
||||
|
||||
### 1. Налаштування змінних середовища
|
||||
|
||||
Створіть `.env` файл у кореневій директорії:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Заповніть `.env` вашими значеннями:
|
||||
|
||||
```env
|
||||
TELEGRAM_API_ID=YOUR_API_ID
|
||||
TELEGRAM_API_HASH=YOUR_API_HASH
|
||||
GATEWAY_DOMAIN=gateway.daarion.city
|
||||
```
|
||||
|
||||
### 2. Запуск стеку
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 3. Перевірка статусу
|
||||
|
||||
Перевірте, що всі контейнери запущені:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Очікуваний вивід:
|
||||
```
|
||||
NAME STATUS PORTS
|
||||
telegram-bot-api running 127.0.0.1:8081->8081/tcp
|
||||
nats running 127.0.0.1:4222->4222/tcp
|
||||
telegram-gateway running 127.0.0.1:8000->8000/tcp
|
||||
```
|
||||
|
||||
## 🧪 Тестування
|
||||
|
||||
### 1. Перевірка telegram-gateway
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/healthz
|
||||
```
|
||||
|
||||
Очікувана відповідь:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "telegram-gateway",
|
||||
"telegram_api": "http://telegram-bot-api:8081",
|
||||
"nats": "nats://nats:4222"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Перевірка Local Telegram Bot API
|
||||
|
||||
Замініть `<YOUR_BOT_TOKEN>` на ваш реальний токен бота:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8081/bot<YOUR_BOT_TOKEN>/getMe
|
||||
```
|
||||
|
||||
Очікувана відповідь (JSON з інформацією про бота):
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"result": {
|
||||
"id": 123456789,
|
||||
"is_bot": true,
|
||||
"first_name": "YourBot",
|
||||
"username": "your_bot"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Перевірка NATS
|
||||
|
||||
```bash
|
||||
docker logs nats
|
||||
```
|
||||
|
||||
Має показувати успішний запуск NATS сервера.
|
||||
|
||||
## 📁 Структура проєкту
|
||||
|
||||
```
|
||||
telegram-infrastructure/
|
||||
├── docker-compose.yml # Конфігурація Docker Compose
|
||||
├── .env # Змінні середовища (не в git)
|
||||
├── .env.example # Приклад змінних
|
||||
├── README.md # Ця документація
|
||||
├── data/ # Дані (автоматично створюється)
|
||||
│ └── telegram-bot-api/ # Дані Local Telegram Bot API
|
||||
└── telegram-gateway/ # Telegram Gateway сервіс
|
||||
├── Dockerfile
|
||||
├── requirements.txt
|
||||
└── app/
|
||||
├── __init__.py
|
||||
└── main.py # FastAPI додаток
|
||||
```
|
||||
|
||||
## 🔒 Безпека
|
||||
|
||||
- **telegram-bot-api** доступний тільки на localhost (`127.0.0.1:8081`)
|
||||
- **nats** доступний тільки на localhost (`127.0.0.1:4222`)
|
||||
- **telegram-gateway** доступний тільки на localhost (`127.0.0.1:8000`)
|
||||
- Для публічного доступу додайте reverse proxy (Caddy/Traefik/nginx) з HTTPS
|
||||
|
||||
## 🔄 Управління
|
||||
|
||||
### Зупинка сервісів
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### Перезапуск сервісу
|
||||
|
||||
```bash
|
||||
docker compose restart telegram-gateway
|
||||
```
|
||||
|
||||
### Перегляд логів
|
||||
|
||||
```bash
|
||||
# Всі сервіси
|
||||
docker compose logs -f
|
||||
|
||||
# Конкретний сервіс
|
||||
docker compose logs -f telegram-gateway
|
||||
```
|
||||
|
||||
### Rebuild після змін коду
|
||||
|
||||
```bash
|
||||
docker compose up -d --build telegram-gateway
|
||||
```
|
||||
|
||||
## 📝 Наступні кроки
|
||||
|
||||
Після того, як інфраструктура запрацює, використовуйте **Cursor** для розробки логіки `telegram-gateway`:
|
||||
|
||||
1. **Інтеграція aiogram** — для роботи з Telegram Bot API
|
||||
2. **NATS publisher** — публікація подій у NATS
|
||||
3. **Webhook endpoints** — прийом команд від інших сервісів
|
||||
4. **Message routing** — маршрутизація повідомлень між агентами
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Контейнери не запускаються
|
||||
|
||||
```bash
|
||||
docker compose logs telegram-bot-api
|
||||
```
|
||||
|
||||
### Порти зайняті
|
||||
|
||||
Переконайтесь, що порти 8081, 4222, 8000 вільні:
|
||||
|
||||
```bash
|
||||
lsof -i :8081
|
||||
lsof -i :4222
|
||||
lsof -i :8000
|
||||
```
|
||||
|
||||
### Проблеми з правами доступу
|
||||
|
||||
```bash
|
||||
chmod -R 755 data/
|
||||
```
|
||||
|
||||
## 📚 Посилання
|
||||
|
||||
- [Local Telegram Bot API](https://github.com/tdlib/telegram-bot-api)
|
||||
- [NATS](https://nats.io/)
|
||||
- [FastAPI](https://fastapi.tiangolo.com/)
|
||||
- [aiogram](https://docs.aiogram.dev/) (для майбутньої інтеграції)
|
||||
@@ -1,54 +0,0 @@
|
||||
services:
|
||||
telegram-bot-api:
|
||||
image: ghcr.io/tdlib/telegram-bot-api:latest
|
||||
container_name: telegram-bot-api
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
command:
|
||||
- --local
|
||||
- --http-port=8081
|
||||
- --dir=/var/lib/telegram-bot-api
|
||||
volumes:
|
||||
- ./data/telegram-bot-api:/var/lib/telegram-bot-api
|
||||
ports:
|
||||
- "127.0.0.1:8081:8081"
|
||||
networks:
|
||||
- telegram-net
|
||||
|
||||
nats:
|
||||
image: nats:2
|
||||
container_name: nats
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:4222:4222"
|
||||
networks:
|
||||
- telegram-net
|
||||
|
||||
telegram-gateway:
|
||||
build: ./telegram-gateway
|
||||
container_name: telegram-gateway
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- TELEGRAM_API_BASE=http://telegram-bot-api:8081
|
||||
- NATS_URL=nats://nats:4222
|
||||
- ROUTER_BASE_URL=http://dagi-router:9102
|
||||
- DEBUG=true
|
||||
depends_on:
|
||||
- telegram-bot-api
|
||||
- nats
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000"
|
||||
volumes:
|
||||
- ./telegram-gateway/bots.yaml:/app/bots.yaml:ro
|
||||
networks:
|
||||
- telegram-net
|
||||
- dagi-network
|
||||
|
||||
networks:
|
||||
telegram-net:
|
||||
driver: bridge
|
||||
dagi-network:
|
||||
external: true
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Health check script for telegram-gateway
|
||||
# Перевіряє статус всіх сервісів
|
||||
|
||||
set -e
|
||||
|
||||
echo "🏥 Health Check for Telegram Infrastructure"
|
||||
echo "=========================================="
|
||||
|
||||
# Check telegram-gateway
|
||||
echo -n "📡 telegram-gateway: "
|
||||
if curl -s http://localhost:8000/healthz > /dev/null; then
|
||||
echo "✅ OK"
|
||||
curl -s http://localhost:8000/bots/list | jq .
|
||||
else
|
||||
echo "❌ FAILED"
|
||||
fi
|
||||
|
||||
# Check NATS
|
||||
echo -n "📨 NATS: "
|
||||
if docker ps | grep -q nats; then
|
||||
echo "✅ Running"
|
||||
else
|
||||
echo "❌ Not running"
|
||||
fi
|
||||
|
||||
# Check telegram-bot-api
|
||||
echo -n "🤖 telegram-bot-api: "
|
||||
if docker ps | grep -q telegram-bot-api; then
|
||||
echo "✅ Running"
|
||||
else
|
||||
echo "❌ Not running"
|
||||
fi
|
||||
|
||||
# Check registered bots
|
||||
echo ""
|
||||
echo "📋 Registered Bots:"
|
||||
curl -s http://localhost:8000/bots/list | jq -r '.bots[]' | while read bot; do
|
||||
echo " - $bot"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "✅ Health check complete!"
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Deployment script for telegram-gateway
|
||||
# Використання: ./scripts/deploy.sh [production|development]
|
||||
|
||||
set -e
|
||||
|
||||
ENVIRONMENT=${1:-production}
|
||||
PROJECT_ROOT="/opt/telegram-infrastructure"
|
||||
REMOTE_HOST="root@144.76.224.179"
|
||||
LOCAL_ROOT="/Users/apple/github-projects/microdao-daarion/telegram-infrastructure"
|
||||
|
||||
echo "🚀 Deploying telegram-gateway to $ENVIRONMENT..."
|
||||
|
||||
if [ "$ENVIRONMENT" = "production" ]; then
|
||||
echo "📦 Syncing files to production server..."
|
||||
rsync -avz \
|
||||
--exclude='.git' \
|
||||
--exclude='__pycache__' \
|
||||
--exclude='*.pyc' \
|
||||
--exclude='data/' \
|
||||
--exclude='.env' \
|
||||
"$LOCAL_ROOT/" "$REMOTE_HOST:$PROJECT_ROOT/"
|
||||
|
||||
echo "🔄 Restarting services on production server..."
|
||||
ssh "$REMOTE_HOST" "cd $PROJECT_ROOT && \
|
||||
docker compose down telegram-gateway && \
|
||||
docker compose up -d --build telegram-gateway"
|
||||
|
||||
echo "✅ Deployment complete!"
|
||||
echo "📋 Check logs: ssh $REMOTE_HOST 'docker logs -f telegram-gateway'"
|
||||
elif [ "$ENVIRONMENT" = "development" ]; then
|
||||
echo "🔄 Restarting services locally..."
|
||||
cd "$LOCAL_ROOT"
|
||||
docker compose down telegram-gateway
|
||||
docker compose up -d --build telegram-gateway
|
||||
|
||||
echo "✅ Local deployment complete!"
|
||||
echo "📋 Check logs: docker compose logs -f telegram-gateway"
|
||||
else
|
||||
echo "❌ Unknown environment: $ENVIRONMENT"
|
||||
echo "Usage: ./scripts/deploy.sh [production|development]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Install dependencies
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY app ./app
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run uvicorn
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "info"]
|
||||
@@ -1,373 +0,0 @@
|
||||
# Telegram Gateway Service
|
||||
|
||||
Сервіс для інтеграції Telegram-ботів з DAGI/microDAO через NATS message bus.
|
||||
|
||||
**Частина DAGI Stack** — див. [INFRASTRUCTURE.md](../../INFRASTRUCTURE.md) для повної інформації про інфраструктуру.
|
||||
|
||||
## Інтеграція з DAGI Stack
|
||||
|
||||
- **Router** (порт 9102): маршрутизація повідомлень до агентів
|
||||
- **NATS** (порт 4222): event bus для подій `agent.telegram.update`
|
||||
- **Local Telegram Bot API** (порт 8081): long polling без SSL/webhook
|
||||
|
||||
**Network Nodes:**
|
||||
- **Node #1 (Production):** `144.76.224.179` — Hetzner GEX44
|
||||
- **Node #2 (Development):** `192.168.1.244` — MacBook Pro M4 Max
|
||||
|
||||
## Архітектура
|
||||
|
||||
```
|
||||
Telegram Bot → Local Bot API → telegram-gateway (polling) → NATS → Router/microDAO
|
||||
↓
|
||||
Telegram Bot ← Local Bot API ← telegram-gateway (/send) ← HTTP API ← Router/microDAO
|
||||
```
|
||||
|
||||
## Особливості
|
||||
|
||||
- **Long Polling** через Local Telegram Bot API (без SSL/webhook)
|
||||
- Автоматична ініціалізація ботів з конфігурації при старті
|
||||
- Публікація подій `agent.telegram.update` у NATS
|
||||
- HTTP API для відправки повідомлень (`/send`)
|
||||
- Підтримка кількох ботів одночасно (DAARWIZZ, Helion, тощо)
|
||||
|
||||
## Швидкий старт
|
||||
|
||||
### 1. Конфігурація ботів
|
||||
|
||||
Створи файл `bots.yaml` в корені `telegram-gateway/`:
|
||||
|
||||
```yaml
|
||||
bots:
|
||||
- agent_id: "daarwizz"
|
||||
bot_token: "YOUR_DAARWIZZ_BOT_TOKEN"
|
||||
enabled: true
|
||||
description: "DAARWIZZ agent bot"
|
||||
|
||||
- agent_id: "helion"
|
||||
bot_token: "YOUR_HELION_BOT_TOKEN"
|
||||
enabled: true
|
||||
description: "Helion agent bot"
|
||||
```
|
||||
|
||||
Або використовуй environment variables:
|
||||
|
||||
```bash
|
||||
export BOT_DAARWIZZ_TOKEN="your_token_here"
|
||||
export BOT_HELION_TOKEN="your_token_here"
|
||||
```
|
||||
|
||||
### 2. Environment variables
|
||||
|
||||
У `.env` файлі (в корені `telegram-infrastructure/`):
|
||||
|
||||
```env
|
||||
TELEGRAM_API_ID=XXXXXXX
|
||||
TELEGRAM_API_HASH=XXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
NATS_URL=nats://nats:4222
|
||||
TELEGRAM_API_BASE=http://telegram-bot-api:8081
|
||||
DEBUG=false
|
||||
```
|
||||
|
||||
### 3. Запуск
|
||||
|
||||
```bash
|
||||
cd telegram-infrastructure
|
||||
|
||||
# Збірка
|
||||
docker compose build telegram-gateway
|
||||
|
||||
# Запуск
|
||||
docker compose up -d telegram-bot-api nats telegram-gateway
|
||||
|
||||
# Перевірка
|
||||
curl http://localhost:8000/healthz
|
||||
curl http://localhost:8000/bots/list
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### `GET /healthz`
|
||||
Health check endpoint.
|
||||
|
||||
**Відповідь:**
|
||||
```json
|
||||
{"status": "ok"}
|
||||
```
|
||||
|
||||
### `GET /bots/list`
|
||||
Список зареєстрованих ботів.
|
||||
|
||||
**Відповідь:**
|
||||
```json
|
||||
{
|
||||
"bots": ["daarwizz", "helion"],
|
||||
"count": 2
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /bots/register`
|
||||
Реєстрація нового бота (якщо не використовується `bots.yaml`).
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"agent_id": "helion",
|
||||
"bot_token": "YOUR_BOT_TOKEN"
|
||||
}
|
||||
```
|
||||
|
||||
**Відповідь:**
|
||||
```json
|
||||
{
|
||||
"status": "registered",
|
||||
"agent_id": "helion"
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /send`
|
||||
Відправка повідомлення в Telegram від імені агента.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"agent_id": "helion",
|
||||
"chat_id": 123456789,
|
||||
"text": "Привіт від Helion!",
|
||||
"reply_to_message_id": null
|
||||
}
|
||||
```
|
||||
|
||||
**Відповідь:**
|
||||
```json
|
||||
{
|
||||
"status": "sent"
|
||||
}
|
||||
```
|
||||
|
||||
## NATS Events
|
||||
|
||||
### `agent.telegram.update`
|
||||
Подія, яка публікується при отриманні повідомлення з Telegram.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"agent_id": "helion",
|
||||
"bot_id": "bot:12345678",
|
||||
"chat_id": 123456789,
|
||||
"user_id": 987654321,
|
||||
"text": "Привіт!",
|
||||
"raw_update": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### `bot.registered`
|
||||
Подія, яка публікується при реєстрації нового бота.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"agent_id": "helion",
|
||||
"bot_token": "12345678..."
|
||||
}
|
||||
```
|
||||
|
||||
## Логування
|
||||
|
||||
Логи містять:
|
||||
- Отримання повідомлень: `agent_id`, `chat_id`, `user_id`, довжина тексту
|
||||
- Публікацію в NATS: subject, agent_id
|
||||
- Відправку повідомлень: agent_id, chat_id, довжина тексту
|
||||
|
||||
Для детального логування встанови `DEBUG=true` в `.env`.
|
||||
|
||||
## Діагностика
|
||||
|
||||
### Перевірка webhook статусу
|
||||
|
||||
```bash
|
||||
# DAARWIZZ
|
||||
curl -s "https://api.telegram.org/bot<DAARWIZZ_TOKEN>/getWebhookInfo"
|
||||
|
||||
# Helion
|
||||
curl -s "https://api.telegram.org/bot<HELION_TOKEN>/getWebhookInfo"
|
||||
```
|
||||
|
||||
Якщо є проблеми з SSL/webhook, видали webhook:
|
||||
|
||||
```bash
|
||||
curl -s "https://api.telegram.org/bot<HELION_TOKEN>/deleteWebhook"
|
||||
```
|
||||
|
||||
### Перевірка сервісу
|
||||
|
||||
```bash
|
||||
# Health check
|
||||
curl http://localhost:8000/healthz
|
||||
|
||||
# Список ботів
|
||||
curl http://localhost:8000/bots/list
|
||||
|
||||
# Логи
|
||||
docker compose logs -f telegram-gateway
|
||||
```
|
||||
|
||||
## Додавання нового бота
|
||||
|
||||
### Варіант 1: Через `bots.yaml`
|
||||
|
||||
Додай запис у `bots.yaml`:
|
||||
|
||||
```yaml
|
||||
bots:
|
||||
- agent_id: "new_agent"
|
||||
bot_token: "NEW_BOT_TOKEN"
|
||||
enabled: true
|
||||
```
|
||||
|
||||
Перезапусти сервіс:
|
||||
|
||||
```bash
|
||||
docker compose restart telegram-gateway
|
||||
```
|
||||
|
||||
### Варіант 2: Через HTTP API
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/bots/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"agent_id": "new_agent",
|
||||
"bot_token": "NEW_BOT_TOKEN"
|
||||
}'
|
||||
```
|
||||
|
||||
### Варіант 3: Через environment variable
|
||||
|
||||
```bash
|
||||
export BOT_NEW_AGENT_TOKEN="NEW_BOT_TOKEN"
|
||||
docker compose restart telegram-gateway
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Бот не отримує повідомлення
|
||||
|
||||
1. Перевір, чи бот зареєстрований: `curl http://localhost:8000/bots/list`
|
||||
2. Перевір логи: `docker compose logs telegram-gateway`
|
||||
3. Перевір, чи видалено webhook: `curl "https://api.telegram.org/bot<TOKEN>/getWebhookInfo"`
|
||||
4. Перевір, чи працює Local Bot API: `curl http://localhost:8081/bot<TOKEN>/getMe`
|
||||
|
||||
### Події не досягають NATS
|
||||
|
||||
1. Перевір підключення до NATS: `docker compose logs telegram-gateway | grep NATS`
|
||||
2. Перевір, чи працює NATS: `docker compose ps nats`
|
||||
3. Перевір логи на помилки публікації
|
||||
|
||||
### Повідомлення не відправляються
|
||||
|
||||
1. Перевір, чи бот зареєстрований
|
||||
2. Перевір логи на помилки відправки
|
||||
3. Перевір формат `agent_id` (має збігатися з тим, що в NATS подіях)
|
||||
|
||||
## Розробка
|
||||
|
||||
### Локальний запуск (без Docker)
|
||||
|
||||
```bash
|
||||
cd telegram-gateway
|
||||
|
||||
# Встановити залежності
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Запустити
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
```
|
||||
|
||||
### Структура проєкту
|
||||
|
||||
```
|
||||
telegram-gateway/
|
||||
├── Dockerfile
|
||||
├── requirements.txt
|
||||
├── bots.yaml.example
|
||||
├── README.md
|
||||
└── app/
|
||||
├── __init__.py
|
||||
├── config.py # Налаштування та завантаження конфігурації ботів
|
||||
├── models.py # Pydantic моделі
|
||||
├── nats_client.py # Клієнт NATS
|
||||
├── bots_registry.py # Реєстр ботів
|
||||
├── telegram_listener.py # Long polling та обробка повідомлень
|
||||
└── main.py # FastAPI додаток
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Production Deployment
|
||||
|
||||
```bash
|
||||
# З локальної машини
|
||||
cd /Users/apple/github-projects/microdao-daarion/telegram-infrastructure
|
||||
./scripts/deploy.sh production
|
||||
```
|
||||
|
||||
### Development Deployment
|
||||
|
||||
```bash
|
||||
# Локально
|
||||
cd telegram-infrastructure
|
||||
./scripts/deploy.sh development
|
||||
```
|
||||
|
||||
### Health Check
|
||||
|
||||
```bash
|
||||
./scripts/check-health.sh
|
||||
```
|
||||
|
||||
## Інтеграція з DAGI Stack
|
||||
|
||||
### Підключення до Router
|
||||
|
||||
Router отримує події через NATS:
|
||||
- Subject: `agent.telegram.update`
|
||||
- Payload: `TelegramUpdateEvent` (див. `app/models.py`)
|
||||
|
||||
### Підключення до microDAO
|
||||
|
||||
microDAO може відправляти повідомлення через HTTP API:
|
||||
- Endpoint: `POST /send`
|
||||
- Payload: `TelegramSendCommand` (див. `app/models.py`)
|
||||
|
||||
### Network Configuration
|
||||
|
||||
Для інтеграції з DAGI Stack додай в `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
telegram-gateway:
|
||||
networks:
|
||||
- telegram-net
|
||||
- dagi-network # Підключення до DAGI Stack
|
||||
```
|
||||
|
||||
І створи external network:
|
||||
```bash
|
||||
docker network create dagi-network
|
||||
```
|
||||
|
||||
## Майбутні покращення
|
||||
|
||||
- [ ] Персистентне сховище ботів (PostgreSQL/microDAO DB)
|
||||
- [ ] Підписка на NATS `agent.telegram.send` для відправки через події
|
||||
- [ ] Rate limiting
|
||||
- [ ] Метрики (Prometheus)
|
||||
- [ ] Підтримка інших типів повідомлень (документи, фото, тощо)
|
||||
- [ ] Інтеграція з Grafana для моніторингу
|
||||
|
||||
## Документація
|
||||
|
||||
- [INFRASTRUCTURE.md](../../INFRASTRUCTURE.md) — повна інформація про інфраструктуру
|
||||
- [docs/infrastructure_quick_ref.ipynb](../../docs/infrastructure_quick_ref.ipynb) — швидкий довідник
|
||||
- [CURSOR_INSTRUCTIONS.md](../CURSOR_INSTRUCTIONS.md) — інструкції для розробки
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Telegram Gateway Application"""
|
||||
@@ -1,59 +0,0 @@
|
||||
import logging
|
||||
from typing import Dict, Optional, List
|
||||
|
||||
from .models import BotRegistration
|
||||
from .config import BotConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BotsRegistry:
|
||||
"""
|
||||
Реєстр ботів з підтримкою ініціалізації з конфігурації.
|
||||
TODO: замінити на персистентне сховище (PostgreSQL/microdao DB).
|
||||
"""
|
||||
def __init__(self) -> None:
|
||||
self._agent_to_token: Dict[str, str] = {}
|
||||
self._token_to_agent: Dict[str, str] = {}
|
||||
|
||||
def register(self, reg: BotRegistration) -> None:
|
||||
"""Реєстрація бота через BotRegistration (HTTP API)"""
|
||||
self._agent_to_token[reg.agent_id] = reg.bot_token
|
||||
self._token_to_agent[reg.bot_token] = reg.agent_id
|
||||
logger.info(f"Registered bot: agent_id={reg.agent_id}, token={reg.bot_token[:8]}...")
|
||||
|
||||
def register_from_config(self, bot_config: BotConfig) -> None:
|
||||
"""Реєстрація бота з BotConfig (конфігурація)"""
|
||||
if not bot_config.enabled:
|
||||
logger.debug(f"Skipping disabled bot: agent_id={bot_config.agent_id}")
|
||||
return
|
||||
self._agent_to_token[bot_config.agent_id] = bot_config.bot_token
|
||||
self._token_to_agent[bot_config.bot_token] = bot_config.agent_id
|
||||
logger.info(f"Registered bot from config: agent_id={bot_config.agent_id}, token={bot_config.bot_token[:8]}...")
|
||||
|
||||
def register_batch(self, bot_configs: List[BotConfig]) -> None:
|
||||
"""Масове реєстрування ботів з конфігурації"""
|
||||
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)
|
||||
|
||||
def get_agent_by_token(self, bot_token: str) -> Optional[str]:
|
||||
return self._token_to_agent.get(bot_token)
|
||||
|
||||
def list_agents(self) -> List[str]:
|
||||
"""Повернути список всіх зареєстрованих agent_id"""
|
||||
return list(self._agent_to_token.keys())
|
||||
|
||||
|
||||
bots_registry = BotsRegistry()
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import os
|
||||
import yaml
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BotConfig(BaseModel):
|
||||
"""Конфігурація одного бота"""
|
||||
agent_id: str
|
||||
bot_token: str
|
||||
# Опційно: додаткові параметри
|
||||
enabled: bool = True
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""
|
||||
Налаштування telegram-gateway сервісу.
|
||||
|
||||
Інтеграція з DAGI Stack:
|
||||
- Router: порт 9102 (http://router:9102)
|
||||
- NATS: порт 4222 (nats://nats:4222)
|
||||
- Local Telegram Bot API: порт 8081 (http://telegram-bot-api:8081)
|
||||
|
||||
Див. INFRASTRUCTURE.md для повної інформації про інфраструктуру.
|
||||
"""
|
||||
# Local Telegram Bot API (Docker service)
|
||||
# Використовується для long polling без SSL/webhook
|
||||
TELEGRAM_API_BASE: str = "http://telegram-bot-api:8081"
|
||||
|
||||
# NATS event bus (DAGI Stack)
|
||||
# Публікація подій agent.telegram.update для Router/microDAO
|
||||
NATS_URL: str = "nats://nats:4222"
|
||||
|
||||
# DAGI Router (опційно, для майбутньої інтеграції)
|
||||
# Використовується для маршрутизації повідомлень до агентів
|
||||
ROUTER_BASE_URL: str = "http://router:9102"
|
||||
|
||||
# Debug логування (true для детальних логів)
|
||||
DEBUG: bool = False
|
||||
|
||||
# Шлях до файлу конфігурації ботів (опційно)
|
||||
# За замовчуванням: /app/bots.yaml (в контейнері) або ./bots.yaml (локально)
|
||||
BOTS_CONFIG_FILE: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
|
||||
def load_bots_config() -> List[BotConfig]:
|
||||
"""
|
||||
Завантажити конфігурацію ботів з:
|
||||
1. YAML файлу (якщо вказано BOTS_CONFIG_FILE або /app/bots.yaml в контейнері)
|
||||
2. Environment variables (BOT_<AGENT_ID>_TOKEN)
|
||||
3. Повернути порожній список, якщо нічого не знайдено
|
||||
|
||||
Пріоритет:
|
||||
1. bots.yaml (якщо існує)
|
||||
2. Environment variables: BOT_<AGENT_ID>_TOKEN
|
||||
"""
|
||||
bots: List[BotConfig] = []
|
||||
|
||||
# Спробувати завантажити з YAML
|
||||
config_file = settings.BOTS_CONFIG_FILE
|
||||
if not config_file:
|
||||
# Спочатку спробувати /app/bots.yaml (шлях в контейнері)
|
||||
container_path = Path("/app/bots.yaml")
|
||||
if container_path.exists():
|
||||
config_file = container_path
|
||||
else:
|
||||
# Fallback: шукати bots.yaml в корені telegram-gateway (для локальної розробки)
|
||||
gateway_root = Path(__file__).parent.parent
|
||||
config_file = gateway_root / "bots.yaml"
|
||||
|
||||
if config_file and Path(config_file).exists():
|
||||
try:
|
||||
with open(config_file, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
if data and "bots" in data:
|
||||
for bot_data in data["bots"]:
|
||||
bots.append(BotConfig(**bot_data))
|
||||
logger.info(f"✅ Loaded {len(bots)} bot(s) from {config_file}")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Failed to load bots from {config_file}: {e}")
|
||||
|
||||
# Fallback: завантажити з env variables
|
||||
if not bots:
|
||||
env_prefix = "BOT_"
|
||||
for key, value in os.environ.items():
|
||||
if key.startswith(env_prefix) and key.endswith("_TOKEN"):
|
||||
# Витягти agent_id з ключа: BOT_DAARWIZZ_TOKEN -> daarwizz
|
||||
agent_id = key[len(env_prefix):-len("_TOKEN")].lower()
|
||||
# Перевірити, чи вже не додано з YAML
|
||||
if not any(b.agent_id == agent_id for b in bots):
|
||||
bots.append(BotConfig(
|
||||
agent_id=agent_id,
|
||||
bot_token=value,
|
||||
enabled=True
|
||||
))
|
||||
logger.info(f"✅ Loaded bot '{agent_id}' from environment variable {key}")
|
||||
|
||||
return bots
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .bots_registry import bots_registry
|
||||
from .telegram_listener import telegram_listener
|
||||
|
||||
router = APIRouter(prefix="/debug", tags=["debug"])
|
||||
|
||||
|
||||
@router.get("/bots")
|
||||
async def list_bots():
|
||||
"""Список зареєстрованих ботів"""
|
||||
return {
|
||||
"registered_bots": len(telegram_listener._bots),
|
||||
"bot_tokens": [token[:16] + "..." for token in telegram_listener._bots.keys()],
|
||||
"registry_mappings": len(bots_registry._agent_to_token),
|
||||
"active_tasks": len(telegram_listener._tasks)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/bots/tasks")
|
||||
async def list_tasks():
|
||||
"""Статус polling tasks"""
|
||||
tasks_status = {}
|
||||
for token, task in telegram_listener._tasks.items():
|
||||
tasks_status[token[:16] + "..."] = {
|
||||
"done": task.done(),
|
||||
"cancelled": task.cancelled(),
|
||||
}
|
||||
if task.done() and not task.cancelled():
|
||||
try:
|
||||
task.result()
|
||||
except Exception as e:
|
||||
tasks_status[token[:16] + "..."]["error"] = str(e)
|
||||
return tasks_status
|
||||
@@ -1,182 +0,0 @@
|
||||
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
|
||||
from .bots_registry import bots_registry
|
||||
from .nats_client import nats_client
|
||||
from .telegram_listener import telegram_listener
|
||||
from .router_handler import router_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
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():
|
||||
# 1. Підключаємося до NATS
|
||||
await nats_client.connect()
|
||||
logger.info("✅ Connected to NATS at %s", settings.NATS_URL)
|
||||
|
||||
# 2. Завантажити конфігурацію ботів з bots.yaml або env
|
||||
try:
|
||||
bot_configs = load_bots_config()
|
||||
logger.info("📋 Loaded %d bot(s) from config", len(bot_configs))
|
||||
except Exception as e:
|
||||
logger.warning("⚠️ Failed to load bots config: %s", e)
|
||||
bot_configs = []
|
||||
|
||||
# 3. Зареєструвати всі боти в реєстрі
|
||||
if bot_configs:
|
||||
bots_registry.register_batch(bot_configs)
|
||||
logger.info("📝 Registered %d bot(s) in registry", len(bot_configs))
|
||||
|
||||
# 4. Запустити polling для кожного бота
|
||||
for bot_config in bot_configs:
|
||||
if not bot_config.enabled:
|
||||
logger.debug("⏭️ Skipping disabled bot: agent_id=%s", bot_config.agent_id)
|
||||
continue
|
||||
|
||||
agent_id = bot_config.agent_id
|
||||
bot_token = bot_config.bot_token
|
||||
|
||||
# Запускаємо polling в фоновій задачі
|
||||
asyncio.create_task(telegram_listener.add_bot(bot_token))
|
||||
logger.info("🚀 Started polling for agent=%s (token=%s...)", agent_id, bot_token[:16])
|
||||
|
||||
# Публікувати подію реєстрації
|
||||
await nats_client.publish_json(
|
||||
subject="bot.registered",
|
||||
data={"agent_id": agent_id, "bot_token": bot_token[:8] + "..."}
|
||||
)
|
||||
|
||||
enabled_count = len([b for b in bot_configs if b.enabled])
|
||||
logger.info("✅ Initialized %d bot(s)", enabled_count)
|
||||
else:
|
||||
logger.warning("⚠️ No bots configured. Use /bots/register to add bots manually.")
|
||||
|
||||
# 5. Запустити NATS subscriber для обробки подій та виклику Router
|
||||
try:
|
||||
await router_handler.start_subscription()
|
||||
logger.info("✅ RouterHandler subscription started")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Failed to start RouterHandler subscription: {e}")
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def on_shutdown():
|
||||
await router_handler.close()
|
||||
await telegram_listener.shutdown()
|
||||
await nats_client.close()
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/bots/register")
|
||||
async def register_bot(reg: BotRegistration):
|
||||
"""
|
||||
Прив'язати Telegram-бота до agent_id.
|
||||
1) Зберегти в реєстрі (in-memory);
|
||||
2) Запустити polling для цього bot_token.
|
||||
3) Опційно: опублікувати подію bot.registered у NATS.
|
||||
"""
|
||||
logger.info(f"Registering bot via API: agent_id={reg.agent_id}")
|
||||
bots_registry.register(reg)
|
||||
|
||||
# Запускаємо polling
|
||||
asyncio.create_task(telegram_listener.add_bot(reg.bot_token))
|
||||
|
||||
# Публікуємо подію реєстрації (може ловити Router або інший сервіс)
|
||||
await nats_client.publish_json(
|
||||
subject="bot.registered",
|
||||
data={"agent_id": reg.agent_id, "bot_token": reg.bot_token[:8] + "..."}
|
||||
)
|
||||
|
||||
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():
|
||||
"""Повернути список зареєстрованих ботів"""
|
||||
agents = bots_registry.list_agents()
|
||||
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):
|
||||
"""
|
||||
Відправити повідомлення в Telegram від імені агента.
|
||||
Викликається DAGI Router / microdao.
|
||||
"""
|
||||
try:
|
||||
await telegram_listener.send_message(
|
||||
agent_id=cmd.agent_id,
|
||||
chat_id=cmd.chat_id,
|
||||
text=cmd.text,
|
||||
reply_to_message_id=cmd.reply_to_message_id,
|
||||
)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
return {"status": "sent"}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint"""
|
||||
return {
|
||||
"service": "Telegram Gateway",
|
||||
"version": "0.1.0",
|
||||
"docs": "/docs",
|
||||
"endpoints": [
|
||||
"GET /healthz",
|
||||
"POST /bots/register",
|
||||
"POST /send"
|
||||
]
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
from typing import Optional, Any, Dict
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TelegramUpdateEvent(BaseModel):
|
||||
"""Подія 'agent.telegram.update' для NATS."""
|
||||
agent_id: str
|
||||
bot_id: str
|
||||
chat_id: int
|
||||
user_id: int
|
||||
text: Optional[str] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
raw_update: Dict[str, Any]
|
||||
|
||||
|
||||
class TelegramSendCommand(BaseModel):
|
||||
"""Команда від DAGI/microdao для надсилання повідомлення в Telegram."""
|
||||
agent_id: str
|
||||
chat_id: int
|
||||
text: str
|
||||
reply_to_message_id: Optional[int] = None
|
||||
|
||||
|
||||
class BotRegistration(BaseModel):
|
||||
"""HTTP payload для реєстрації нового бота/агента."""
|
||||
agent_id: str
|
||||
bot_token: str
|
||||
# опційно: allowed_chat_id, ім'я, тощо
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
import nats
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
class NatsClient:
|
||||
def __init__(self, url: str):
|
||||
self._url = url
|
||||
self._nc: Optional[nats.NATS] = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
if self._nc is None or self._nc.is_closed:
|
||||
self._nc = await nats.connect(self._url)
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._nc and not self._nc.is_closed:
|
||||
await self._nc.drain()
|
||||
await self._nc.close()
|
||||
|
||||
async def publish_json(self, subject: str, data: dict) -> None:
|
||||
if self._nc is None or self._nc.is_closed:
|
||||
await self.connect()
|
||||
await self._nc.publish(subject, json.dumps(data).encode("utf-8"))
|
||||
|
||||
|
||||
nats_client = NatsClient(settings.NATS_URL)
|
||||
|
||||
@@ -1,501 +0,0 @@
|
||||
"""
|
||||
NATS subscriber для обробки подій agent.telegram.update
|
||||
Викликає Router через HTTP API та відправляє відповідь назад в Telegram
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
import httpx
|
||||
|
||||
import nats
|
||||
|
||||
from .config import settings
|
||||
from .models import TelegramUpdateEvent, TelegramSendCommand
|
||||
from .telegram_listener import telegram_listener
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RouterHandler:
|
||||
"""Обробник подій з NATS, який викликає Router та відправляє відповіді"""
|
||||
|
||||
def __init__(self):
|
||||
self._nc = None
|
||||
self._sub = None
|
||||
self._router_url = settings.ROUTER_BASE_URL
|
||||
self._running = False
|
||||
|
||||
async def connect(self):
|
||||
"""Підключитися до NATS"""
|
||||
if self._nc is None or self._nc.is_closed:
|
||||
self._nc = await nats.connect(settings.NATS_URL)
|
||||
logger.info(f"✅ RouterHandler connected to NATS at {settings.NATS_URL}")
|
||||
|
||||
async def start_subscription(self):
|
||||
"""Підписатися на події agent.telegram.update"""
|
||||
await self.connect()
|
||||
|
||||
async def message_handler(msg):
|
||||
"""Обробка повідомлення з NATS"""
|
||||
try:
|
||||
data = json.loads(msg.data.decode())
|
||||
event = TelegramUpdateEvent(**data)
|
||||
|
||||
logger.info(
|
||||
f"📥 Received NATS event: agent={event.agent_id}, "
|
||||
f"chat={event.chat_id}, text_len={len(event.text or '')}"
|
||||
)
|
||||
|
||||
# Обробити подію асинхронно
|
||||
asyncio.create_task(self._handle_telegram_event(event))
|
||||
|
||||
# NOTE: No ack() for core NATS (non-JetStream) subscriptions
|
||||
# JetStream ack is only needed when using JetStream consumers
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error processing NATS message: {e}", exc_info=True)
|
||||
|
||||
# Підписатися на subject
|
||||
self._sub = await self._nc.subscribe("agent.telegram.update", cb=message_handler)
|
||||
self._running = True
|
||||
logger.info("✅ Subscribed to NATS subject: agent.telegram.update")
|
||||
|
||||
async def _handle_telegram_event(self, event: TelegramUpdateEvent):
|
||||
"""Обробити подію Telegram та викликати Router"""
|
||||
try:
|
||||
metadata = event.metadata or {}
|
||||
|
||||
# Обробка фото (Vision Encoder)
|
||||
if "photo" in metadata:
|
||||
await self._handle_photo(event, metadata)
|
||||
return
|
||||
|
||||
# Обробка документів (Parser Service)
|
||||
if "document" in metadata:
|
||||
await self._handle_document(event, metadata)
|
||||
return
|
||||
|
||||
# Звичайні текстові повідомлення
|
||||
if not event.text:
|
||||
logger.debug(f"Skipping event without text: agent={event.agent_id}")
|
||||
return
|
||||
|
||||
# Отримати системний промпт для агента
|
||||
system_prompt = self._get_system_prompt(event.agent_id)
|
||||
|
||||
# Викликати Router через HTTP API
|
||||
# Структура: payload.context.system_prompt (як очікує Router)
|
||||
router_request = {
|
||||
"message": event.text,
|
||||
"mode": "chat",
|
||||
"agent": event.agent_id,
|
||||
"source": "telegram",
|
||||
"user_id": f"tg:{event.user_id}",
|
||||
"session_id": f"telegram:{event.chat_id}",
|
||||
"payload": {
|
||||
"context": {
|
||||
"agent_name": event.agent_id.upper(),
|
||||
"system_prompt": system_prompt, # Системний промпт для агента
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Детальне логування перед відправкою
|
||||
payload_keys = list(router_request.get('payload', {}).keys())
|
||||
context_keys = list(router_request.get('payload', {}).get('context', {}).keys())
|
||||
sp_len = len(system_prompt) if system_prompt else 0
|
||||
|
||||
logger.info(
|
||||
f"📞 Calling Router: agent={event.agent_id}, chat={event.chat_id}"
|
||||
)
|
||||
logger.info(
|
||||
f" payload.keys={payload_keys}, "
|
||||
f"context.keys={context_keys}, "
|
||||
f"system_prompt_len={sp_len}"
|
||||
)
|
||||
if system_prompt:
|
||||
logger.info(f" system_prompt preview: {system_prompt[:80]}...")
|
||||
|
||||
# Логування JSON перед відправкою (з повним system_prompt для діагностики)
|
||||
import json
|
||||
full_json = json.dumps(router_request, ensure_ascii=False)
|
||||
logger.info(f"📤 Full request JSON length: {len(full_json)} bytes")
|
||||
logger.info(f"📤 Payload.context.system_prompt in request: {router_request.get('payload', {}).get('context', {}).get('system_prompt', '')[:100]}...")
|
||||
|
||||
async with httpx.AsyncClient(timeout=120.0) as client: # Збільшено timeout до 120 сек
|
||||
logger.info(f"📡 Sending HTTP POST to {self._router_url}/route")
|
||||
response = await client.post(
|
||||
f"{self._router_url}/route",
|
||||
json=router_request
|
||||
)
|
||||
logger.info(f"📡 Router response status: {response.status_code}")
|
||||
|
||||
# Перевірка на 502 Bad Gateway
|
||||
if response.status_code == 502:
|
||||
logger.error(f"❌ Router returned 502 Bad Gateway for agent={event.agent_id}")
|
||||
await telegram_listener.send_message(
|
||||
agent_id=event.agent_id,
|
||||
chat_id=event.chat_id,
|
||||
text="⚠️ Вибач, зараз велике навантаження. Спробуй через хвилину."
|
||||
)
|
||||
return
|
||||
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
# Отримати відповідь
|
||||
answer = None
|
||||
if isinstance(result, dict):
|
||||
answer = (
|
||||
result.get("data", {}).get("text") or
|
||||
result.get("data", {}).get("answer") or
|
||||
result.get("response") or
|
||||
result.get("text")
|
||||
)
|
||||
|
||||
if not answer:
|
||||
logger.warning(f"⚠️ No answer from Router for agent={event.agent_id}")
|
||||
answer = "Вибач, зараз не можу відповісти."
|
||||
|
||||
logger.info(f"📤 Sending response: agent={event.agent_id}, chat={event.chat_id}, len={len(answer)}")
|
||||
|
||||
# Перевірити чи треба відповідати голосом (якщо користувач надіслав voice)
|
||||
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:
|
||||
# Синтезувати голос
|
||||
audio_bytes = await self._text_to_speech(answer)
|
||||
if audio_bytes:
|
||||
logger.info(f"🔊 Sending voice response: agent={event.agent_id}, audio_size={len(audio_bytes)}")
|
||||
await telegram_listener.send_voice(
|
||||
agent_id=event.agent_id,
|
||||
chat_id=event.chat_id,
|
||||
audio_bytes=audio_bytes
|
||||
)
|
||||
else:
|
||||
# Fallback to text
|
||||
await telegram_listener.send_message(
|
||||
agent_id=event.agent_id,
|
||||
chat_id=event.chat_id,
|
||||
text=answer
|
||||
)
|
||||
else:
|
||||
# Звичайна текстова відповідь
|
||||
await telegram_listener.send_message(
|
||||
agent_id=event.agent_id,
|
||||
chat_id=event.chat_id,
|
||||
text=answer
|
||||
)
|
||||
|
||||
logger.info(f"✅ Response sent: agent={event.agent_id}, chat={event.chat_id}")
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"❌ HTTP error calling Router: {e}")
|
||||
# Відправити повідомлення про помилку користувачу
|
||||
try:
|
||||
await telegram_listener.send_message(
|
||||
agent_id=event.agent_id,
|
||||
chat_id=event.chat_id,
|
||||
text="❌ Помилка зв'язку з сервером. Спробуй ще раз."
|
||||
)
|
||||
except:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error handling Telegram event: {e}", exc_info=True)
|
||||
|
||||
async def _handle_photo(self, event: TelegramUpdateEvent, metadata: Dict[str, Any]):
|
||||
"""Обробити фото через Swapper vision-8b модель"""
|
||||
try:
|
||||
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_request = {
|
||||
"message": f"Опиши це зображення детально: {file_url}",
|
||||
"mode": "chat",
|
||||
"agent": event.agent_id,
|
||||
"metadata": {
|
||||
"source": "telegram",
|
||||
"chat_id": event.chat_id,
|
||||
"file_url": file_url,
|
||||
"has_image": True,
|
||||
},
|
||||
}
|
||||
|
||||
# Override LLM to use specialist_vision_8b for image understanding
|
||||
router_request["metadata"]["use_llm"] = "specialist_vision_8b"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=90.0) as client:
|
||||
response = await client.post(f"{self._router_url}/route", json=router_request)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
if result.get("ok"):
|
||||
answer_text = result.get("data", {}).get("text") or result.get("response", "")
|
||||
if answer_text:
|
||||
await telegram_listener.send_message(
|
||||
agent_id=event.agent_id,
|
||||
chat_id=event.chat_id,
|
||||
text=f"✅ **Фото оброблено**\n\n{answer_text}"
|
||||
)
|
||||
return
|
||||
|
||||
# Якщо помилка
|
||||
error_msg = result.get("error", "Unknown error")
|
||||
logger.error(f"Router error: {error_msg}")
|
||||
await telegram_listener.send_message(
|
||||
agent_id=event.agent_id,
|
||||
chat_id=event.chat_id,
|
||||
text=f"Вибач, не вдалося обробити фото: {error_msg}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error calling Router: {e}", exc_info=True)
|
||||
await telegram_listener.send_message(
|
||||
agent_id=event.agent_id,
|
||||
chat_id=event.chat_id,
|
||||
text="Вибач, не вдалося обробити фото. Переконайся, що Swapper Service з vision-8b моделлю запущений."
|
||||
)
|
||||
|
||||
logger.info(f"✅ Photo response sent: agent={event.agent_id}, chat={event.chat_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error handling photo: {e}", exc_info=True)
|
||||
try:
|
||||
await telegram_listener.send_message(
|
||||
agent_id=event.agent_id,
|
||||
chat_id=event.chat_id,
|
||||
text="❌ Помилка обробки зображення."
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
async def _handle_document(self, event: TelegramUpdateEvent, metadata: Dict[str, Any]):
|
||||
"""Обробити PDF через Parser Service"""
|
||||
try:
|
||||
doc_info = metadata.get("document", {})
|
||||
file_url = doc_info.get("file_url", "")
|
||||
file_name = doc_info.get("file_name", "document.pdf")
|
||||
|
||||
logger.info(f"📄 Processing document: agent={event.agent_id}, file={file_name}")
|
||||
|
||||
# Викликати Parser Service через DAGI Router
|
||||
parsed_content = await self._parse_document(file_url, file_name)
|
||||
|
||||
# Якщо є питання в caption/text - відповісти на основі parsed content
|
||||
user_question = event.text
|
||||
if user_question and user_question != f"[DOCUMENT] {file_name}":
|
||||
# Додати parsed content до контексту
|
||||
system_prompt = self._get_system_prompt(event.agent_id)
|
||||
|
||||
enhanced_text = f"Користувач запитує про документ '{file_name}':\n{user_question}\n\n[DOCUMENT_CONTENT]:\n{parsed_content[:2000]}"
|
||||
|
||||
# Викликати Router для відповіді
|
||||
router_request = {
|
||||
"message": enhanced_text,
|
||||
"mode": "chat",
|
||||
"agent": event.agent_id,
|
||||
"source": "telegram",
|
||||
"user_id": f"tg:{event.user_id}",
|
||||
"session_id": f"telegram:{event.chat_id}",
|
||||
"payload": {
|
||||
"context": {
|
||||
"agent_name": event.agent_id.upper(),
|
||||
"system_prompt": system_prompt,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
response = await client.post(
|
||||
f"{self._router_url}/route",
|
||||
json=router_request
|
||||
)
|
||||
|
||||
if response.status_code == 502:
|
||||
await telegram_listener.send_message(
|
||||
agent_id=event.agent_id,
|
||||
chat_id=event.chat_id,
|
||||
text="⚠️ Вибач, зараз велике навантаження. Спробуй через хвилину."
|
||||
)
|
||||
return
|
||||
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
# Отримати відповідь
|
||||
answer = (
|
||||
result.get("data", {}).get("text") or
|
||||
result.get("data", {}).get("answer") or
|
||||
result.get("response") or
|
||||
result.get("text") or
|
||||
"Вибач, не зміг проаналізувати документ."
|
||||
)
|
||||
|
||||
await telegram_listener.send_message(
|
||||
agent_id=event.agent_id,
|
||||
chat_id=event.chat_id,
|
||||
text=answer
|
||||
)
|
||||
else:
|
||||
# Просто парсинг без питання
|
||||
summary = parsed_content[:500] if parsed_content else "Документ оброблено"
|
||||
await telegram_listener.send_message(
|
||||
agent_id=event.agent_id,
|
||||
chat_id=event.chat_id,
|
||||
text=f"✅ Документ '{file_name}' оброблено.\n\n{summary}...\n\nЗадай питання про нього!"
|
||||
)
|
||||
|
||||
logger.info(f"✅ Document response sent: agent={event.agent_id}, chat={event.chat_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error handling document: {e}", exc_info=True)
|
||||
try:
|
||||
await telegram_listener.send_message(
|
||||
agent_id=event.agent_id,
|
||||
chat_id=event.chat_id,
|
||||
text="❌ Помилка обробки документу. Спробуй ще раз."
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
async def _parse_document(self, doc_url: str, file_name: str) -> str:
|
||||
"""Викликати Parser Service для PDF"""
|
||||
try:
|
||||
logger.info(f"📡 Calling Parser Service: url={doc_url[:50]}..., file={file_name}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=90.0) as client:
|
||||
# Виклик DAGI Router з mode: "doc_parse"
|
||||
response = await client.post(
|
||||
f"{self._router_url}/route",
|
||||
json={
|
||||
"mode": "doc_parse",
|
||||
"agent": "parser",
|
||||
"payload": {
|
||||
"context": {
|
||||
"doc_url": doc_url,
|
||||
"file_name": file_name,
|
||||
"output_mode": "markdown"
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
# Витягнути parsed content
|
||||
if "data" in result:
|
||||
markdown = result["data"].get("markdown", "")
|
||||
if markdown:
|
||||
return markdown
|
||||
|
||||
# Fallback
|
||||
return result.get("text", "") or result.get("response", "") or "Документ оброблено"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Parser Service error: {e}")
|
||||
return "[Не вдалося прочитати документ]"
|
||||
|
||||
async def _text_to_speech(self, text: str) -> bytes:
|
||||
"""Синтезувати голос через TTS Service"""
|
||||
try:
|
||||
logger.info(f"🔊 Calling TTS Service: text_len={len(text)}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(
|
||||
"http://dagi-tts:9100/tts",
|
||||
json={
|
||||
"text": text[:500], # Обмежуємо довжину для TTS
|
||||
"lang": "uk"
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
audio_bytes = response.content
|
||||
|
||||
logger.info(f"✅ TTS response: {len(audio_bytes)} bytes")
|
||||
return audio_bytes
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ TTS Service error: {e}")
|
||||
return b"" # Fallback to text
|
||||
|
||||
def _get_system_prompt(self, agent_id: str) -> str:
|
||||
"""Отримати системний промпт для агента"""
|
||||
# Системні промпти для агентів
|
||||
prompts = {
|
||||
"helion": """Ти - Helion, AI-агент платформи Energy Union екосистеми DAARION.city.
|
||||
Допомагай користувачам з технологіями EcoMiner/BioMiner, токеномікою та DAO governance.
|
||||
|
||||
Твої основні функції:
|
||||
- Консультації з енергетичними технологіями (сонячні панелі, вітряки, біогаз)
|
||||
- Пояснення токеноміки Energy Union (ENERGY токен, стейкінг, винагороди)
|
||||
- Допомога з onboarding в DAO
|
||||
- Відповіді на питання про EcoMiner/BioMiner устаткування
|
||||
|
||||
Стиль спілкування:
|
||||
- професійний, технічний, але зрозумілий
|
||||
- точний у цифрах та даних
|
||||
- конструктивний у рекомендаціях
|
||||
|
||||
Важливо:
|
||||
- Не вигадуй дані, яких немає в системі
|
||||
- Якщо дані недоступні — чесно скажи про це
|
||||
- Не давай фінансових порад без консультації з експертами""",
|
||||
|
||||
"daarwizz": """Ти — DAARWIZZ, офіційний AI-агент екосистеми DAARION.city.
|
||||
Допомагай учасникам з microDAO, ролями та процесами.
|
||||
Відповідай коротко, практично, враховуй RBAC контекст користувача.""",
|
||||
|
||||
"greenfood": """Ти — GREENFOOD Assistant, фронтовий оркестратор ERP-системи для крафтових виробників, хабів та покупців.
|
||||
|
||||
Твоя місія: зрозуміти, хто з тобою говорить (комітент, менеджер складу, логіст, бухгалтер, маркетолог, покупець), виявити намір і делегувати завдання спеціалізованим агентам GREENFOOD.
|
||||
|
||||
У твоєму розпорядженні 12 спеціалізованих агентів:
|
||||
- Product & Catalog (каталог товарів)
|
||||
- Batch & Quality (партії та якість)
|
||||
- Vendor Success (успіх комітентів)
|
||||
- Warehouse (склад)
|
||||
- Logistics & Delivery (доставка)
|
||||
- Seller (продажі)
|
||||
- Customer Care (підтримка)
|
||||
- Finance & Pricing (фінанси)
|
||||
- SMM & Campaigns (маркетинг)
|
||||
- SEO & Web (SEO)
|
||||
- Analytics & BI (аналітика)
|
||||
- Compliance & Audit (аудит)
|
||||
|
||||
Правила роботи:
|
||||
- Спочатку уточнюй роль і контекст
|
||||
- Перетворюй запит на чітку дію
|
||||
- Не вигадуй дані - якщо чогось немає, чесно кажи
|
||||
- Завжди давай коротке резюме: що зроблено, наступні кроки
|
||||
|
||||
Відповідай українською, чітко та по-діловому.""",
|
||||
}
|
||||
|
||||
prompt = prompts.get(agent_id.lower(), "")
|
||||
if prompt:
|
||||
logger.debug(f"Using system prompt for agent={agent_id}, len={len(prompt)}")
|
||||
else:
|
||||
logger.warning(f"No system prompt found for agent={agent_id}")
|
||||
|
||||
return prompt
|
||||
|
||||
async def close(self):
|
||||
"""Закрити підписку та з'єднання"""
|
||||
self._running = False
|
||||
if self._sub:
|
||||
await self._sub.unsubscribe()
|
||||
if self._nc and not self._nc.is_closed:
|
||||
await self._nc.drain()
|
||||
await self._nc.close()
|
||||
logger.info("RouterHandler closed")
|
||||
|
||||
|
||||
router_handler = RouterHandler()
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
import asyncio
|
||||
import logging
|
||||
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
|
||||
|
||||
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__)
|
||||
|
||||
|
||||
class TelegramListener:
|
||||
def __init__(self) -> None:
|
||||
self._bots: Dict[str, Bot] = {} # bot_token -> Bot
|
||||
self._dispatchers: Dict[str, Dispatcher] = {}
|
||||
self._tasks: Dict[str, asyncio.Task] = {}
|
||||
self._server = TelegramAPIServer.from_base(settings.TELEGRAM_API_BASE)
|
||||
|
||||
async def _create_bot(self, bot_token: str) -> Bot:
|
||||
session = AiohttpSession(api=self._server)
|
||||
bot = Bot(token=bot_token, session=session)
|
||||
return bot
|
||||
|
||||
async def add_bot(self, bot_token: str) -> None:
|
||||
if bot_token in self._bots:
|
||||
logger.info("🔄 Bot already registered: %s...", bot_token[:16])
|
||||
return
|
||||
|
||||
logger.info("🤖 Creating bot: %s...", bot_token[:16])
|
||||
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)
|
||||
if not agent_id:
|
||||
logger.warning("⚠️ No agent_id for bot_token=%s...", bot_token[:16])
|
||||
return
|
||||
|
||||
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,
|
||||
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.text,
|
||||
raw_update=message.model_dump(),
|
||||
)
|
||||
|
||||
await nats_client.publish_json(
|
||||
subject="agent.telegram.update",
|
||||
data=event.model_dump(),
|
||||
)
|
||||
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("❌ Помилка обробки зображення. Спробуйте ще раз.")
|
||||
|
||||
async def _polling():
|
||||
try:
|
||||
logger.info("🔁 Start polling for bot %s...", bot_token[:16])
|
||||
await dp.start_polling(bot)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("🛑 Polling cancelled for bot %s...", bot_token[:16])
|
||||
except Exception as e:
|
||||
logger.exception("💥 Polling error for bot %s...: %s", bot_token[:16], e)
|
||||
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: Optional[int] = None,
|
||||
):
|
||||
logger.info(
|
||||
"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("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)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
reply_to_message_id=reply_to_message_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):
|
||||
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()
|
||||
|
||||
|
||||
telegram_listener = TelegramListener()
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
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 .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__)
|
||||
|
||||
|
||||
class TelegramListener:
|
||||
def __init__(self) -> None:
|
||||
self._bots: Dict[str, Bot] = {} # bot_token -> Bot
|
||||
self._dispatchers: Dict[str, Dispatcher] = {}
|
||||
self._tasks: Dict[str, asyncio.Task] = {}
|
||||
self._server = TelegramAPIServer.from_base(settings.TELEGRAM_API_BASE)
|
||||
|
||||
async def _create_bot(self, bot_token: str) -> Bot:
|
||||
session = AiohttpSession(api=self._server)
|
||||
bot = Bot(token=bot_token, session=session)
|
||||
return bot
|
||||
|
||||
async def add_bot(self, bot_token: str) -> None:
|
||||
if bot_token in self._bots:
|
||||
logger.info("🔄 Bot already registered: %s...", bot_token[:16])
|
||||
return
|
||||
|
||||
logger.info("🤖 Creating bot: %s...", bot_token[:16])
|
||||
bot = await self._create_bot(bot_token)
|
||||
dp = Dispatcher()
|
||||
|
||||
# Handler for text messages
|
||||
@dp.message(F.text)
|
||||
async def on_message(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 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,
|
||||
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.text,
|
||||
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()
|
||||
)
|
||||
logger.debug("✅ Published to NATS: agent=%s, chat_id=%s", agent_id, message.chat.id)
|
||||
|
||||
# Handler for voice messages
|
||||
@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)
|
||||
|
||||
# Send "processing" message
|
||||
await message.answer("🎤 Обробляю голосове повідомлення...")
|
||||
|
||||
# Process voice through STT
|
||||
transcribed_text = await handle_voice_message(message, bot_token)
|
||||
|
||||
if not transcribed_text:
|
||||
await message.answer("❌ Не вдалося розпізнати голос. Спробуйте ще раз.")
|
||||
return
|
||||
|
||||
logger.info("📝 Transcribed (%d chars): %s...", len(transcribed_text), transcribed_text[:50])
|
||||
|
||||
# Publish transcribed text as regular message to NATS
|
||||
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()
|
||||
)
|
||||
|
||||
logger.info("📤 Publishing transcribed text to NATS: agent=%s", agent_id)
|
||||
await nats_client.publish_json(
|
||||
subject="agent.telegram.update",
|
||||
data=event.model_dump()
|
||||
)
|
||||
|
||||
# Handler for 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")
|
||||
|
||||
# Process document
|
||||
doc_info = await handle_document_message(message, bot_token)
|
||||
|
||||
if not doc_info:
|
||||
logger.info("⏭️ Not a PDF or processing skipped")
|
||||
return
|
||||
|
||||
# Send "processing" message
|
||||
await message.answer(f"📄 Обробляю документ: {doc_info.get('file_name')}...")
|
||||
|
||||
# Publish document info to NATS
|
||||
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=f"[DOCUMENT] {doc_info.get('file_name')}",
|
||||
raw_update=message.model_dump(),
|
||||
metadata={"document": doc_info}
|
||||
)
|
||||
|
||||
logger.info("📤 Publishing document to NATS: agent=%s, file=%s", agent_id, doc_info.get('file_name'))
|
||||
await nats_client.publish_json(
|
||||
subject="agent.telegram.update",
|
||||
data=event.model_dump()
|
||||
)
|
||||
|
||||
# Handler for photos/images
|
||||
@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)
|
||||
|
||||
# Get largest photo
|
||||
photo = message.photo[-1] if message.photo else None
|
||||
if not photo:
|
||||
return
|
||||
|
||||
# Send "processing" message
|
||||
await message.answer("🖼️ Обробляю зображення...")
|
||||
|
||||
# Get file info
|
||||
file_id = photo.file_id
|
||||
file_size = photo.file_size
|
||||
|
||||
try:
|
||||
# Get file from bot
|
||||
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}"
|
||||
|
||||
logger.info("📥 Photo URL: %s", file_url)
|
||||
|
||||
# Prepare caption or default text
|
||||
caption = message.caption or "[IMAGE]"
|
||||
|
||||
# Publish photo info to NATS
|
||||
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=caption,
|
||||
raw_update=message.model_dump(),
|
||||
metadata={
|
||||
"photo": {
|
||||
"file_url": file_url,
|
||||
"file_id": file_id,
|
||||
"file_size": file_size,
|
||||
"width": photo.width,
|
||||
"height": photo.height,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
logger.info("📤 Publishing photo to NATS: agent=%s", agent_id)
|
||||
await nats_client.publish_json(
|
||||
subject="agent.telegram.update",
|
||||
data=event.model_dump()
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error processing photo: {e}", exc_info=True)
|
||||
await message.answer("❌ Помилка обробки зображення. Спробуйте ще раз.")
|
||||
|
||||
# Запускаємо polling у фоні
|
||||
async def _polling():
|
||||
try:
|
||||
logger.info("🔁 Start polling for bot %s...", bot_token[:16])
|
||||
await dp.start_polling(bot)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("🛑 Polling cancelled for bot %s...", bot_token[:16])
|
||||
except Exception as e:
|
||||
logger.exception("💥 Polling error for bot %s...: %s", bot_token[:16], e)
|
||||
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):
|
||||
# Логування перед відправкою
|
||||
logger.info(
|
||||
f"Sending message: agent_id={agent_id}, chat_id={chat_id}, "
|
||||
f"text_length={len(text)}, reply_to={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}")
|
||||
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]
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
reply_to_message_id=reply_to_message_id
|
||||
)
|
||||
logger.info(f"Message sent successfully: agent_id={agent_id}, chat_id={chat_id}")
|
||||
|
||||
async def send_voice(self, agent_id: str, chat_id: int, audio_bytes: bytes, reply_to_message_id: int | None = None):
|
||||
"""Відправити голосове повідомлення"""
|
||||
logger.info(
|
||||
f"Sending voice: agent_id={agent_id}, chat_id={chat_id}, "
|
||||
f"audio_size={len(audio_bytes)} bytes, reply_to={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}")
|
||||
raise RuntimeError(f"No bot token for agent_id={agent_id}")
|
||||
|
||||
bot = self._bots.get(bot_token)
|
||||
if not bot:
|
||||
logger.info(f"Bot not started yet, initializing: agent_id={agent_id}")
|
||||
await self.add_bot(bot_token)
|
||||
bot = self._bots[bot_token]
|
||||
|
||||
if not audio_bytes:
|
||||
logger.warning(f"Empty audio_bytes for agent_id={agent_id}, skipping")
|
||||
return
|
||||
|
||||
# Створити BytesIO об'єкт для aiogram
|
||||
from io import BytesIO
|
||||
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(f"Voice sent successfully: agent_id={agent_id}, chat_id={chat_id}")
|
||||
|
||||
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()
|
||||
|
||||
|
||||
telegram_listener = TelegramListener()
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
"""
|
||||
Voice and Document Handler for Telegram Gateway
|
||||
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__)
|
||||
|
||||
STT_SERVICE_URL = "http://dagi-stt:9000/stt"
|
||||
PARSER_SERVICE_URL = "http://dagi-parser:9400"
|
||||
|
||||
|
||||
async def handle_voice_message(message: Message, bot: Bot) -> str:
|
||||
"""
|
||||
Process voice/audio message through STT
|
||||
|
||||
Args:
|
||||
message: Telegram message with voice/audio
|
||||
bot_token: Bot token for file download
|
||||
|
||||
Returns:
|
||||
Transcribed text
|
||||
"""
|
||||
# Get file_id from different message types
|
||||
file_id = None
|
||||
if message.voice:
|
||||
file_id = message.voice.file_id
|
||||
duration = message.voice.duration
|
||||
elif message.audio:
|
||||
file_id = message.audio.file_id
|
||||
duration = message.audio.duration
|
||||
elif message.video_note:
|
||||
file_id = message.video_note.file_id
|
||||
duration = message.video_note.duration
|
||||
|
||||
if not file_id:
|
||||
logger.error("No file_id found in voice message")
|
||||
return ""
|
||||
|
||||
logger.info(f"🎤 Processing voice: file_id={file_id}, duration={duration}s")
|
||||
|
||||
try:
|
||||
# Get file path from Telegram
|
||||
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}"
|
||||
|
||||
logger.info(f"📥 Downloading audio: {file_url}")
|
||||
|
||||
# Download audio file
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
audio_response = await client.get(file_url)
|
||||
audio_response.raise_for_status()
|
||||
audio_bytes = audio_response.content
|
||||
|
||||
logger.info(f"✅ Downloaded {len(audio_bytes)} bytes")
|
||||
|
||||
# Send to STT service
|
||||
logger.info(f"🔊 Sending to STT: {STT_SERVICE_URL}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
files = {"file": ("audio.ogg", audio_bytes, "audio/ogg")}
|
||||
stt_response = await client.post(STT_SERVICE_URL, files=files)
|
||||
stt_response.raise_for_status()
|
||||
result = stt_response.json()
|
||||
|
||||
transcribed_text = result.get("text", "")
|
||||
logger.info(f"📝 Transcribed: {transcribed_text[:100]}...")
|
||||
|
||||
return transcribed_text
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"❌ HTTP error in voice processing: {e}")
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error in voice processing: {e}", exc_info=True)
|
||||
return ""
|
||||
|
||||
|
||||
async def handle_document_message(message: Message, bot: Bot) -> dict:
|
||||
"""
|
||||
Process document (PDF) message through Parser
|
||||
|
||||
Args:
|
||||
message: Telegram message with document
|
||||
bot_token: Bot token for file download
|
||||
|
||||
Returns:
|
||||
Dict with document info or empty dict
|
||||
"""
|
||||
if not message.document:
|
||||
return {}
|
||||
|
||||
file_name = message.document.file_name or "document"
|
||||
mime_type = message.document.mime_type
|
||||
file_id = message.document.file_id
|
||||
file_size = message.document.file_size
|
||||
|
||||
# Check if it's a PDF
|
||||
is_pdf = mime_type == "application/pdf" or file_name.lower().endswith(".pdf")
|
||||
|
||||
if not is_pdf:
|
||||
logger.info(f"⏭️ Skipping non-PDF document: {file_name} ({mime_type})")
|
||||
return {}
|
||||
|
||||
logger.info(f"📄 Processing PDF: {file_name}, size={file_size} bytes")
|
||||
|
||||
try:
|
||||
# Get file path from Telegram
|
||||
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}"
|
||||
|
||||
logger.info(f"📥 PDF URL: {file_url}")
|
||||
|
||||
# Return document info (processing will happen through Router)
|
||||
return {
|
||||
"file_url": file_url,
|
||||
"file_name": file_name,
|
||||
"file_size": file_size,
|
||||
"mime_type": mime_type,
|
||||
"file_id": file_id,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error processing document: {e}", exc_info=True)
|
||||
return {}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
# Приклад конфігурації ботів для telegram-gateway
|
||||
# Скопіюй цей файл як bots.yaml і заповни реальні токени
|
||||
|
||||
bots:
|
||||
- agent_id: "daarwizz"
|
||||
bot_token: "YOUR_DAARWIZZ_BOT_TOKEN"
|
||||
enabled: true
|
||||
description: "DAARWIZZ agent bot"
|
||||
|
||||
- agent_id: "helion"
|
||||
bot_token: "YOUR_HELION_BOT_TOKEN"
|
||||
enabled: true
|
||||
description: "Helion agent bot"
|
||||
|
||||
# Додаткові боти можна додати тут
|
||||
# - agent_id: "another_agent"
|
||||
# bot_token: "ANOTHER_BOT_TOKEN"
|
||||
# enabled: true
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
pydantic==2.5.3
|
||||
aiogram==3.*
|
||||
nats-py
|
||||
pydantic-settings
|
||||
httpx
|
||||
pyyaml
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test Router connectivity"""
|
||||
import asyncio
|
||||
import httpx
|
||||
import sys
|
||||
|
||||
async def test():
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
r = await client.get("http://dagi-router:9102/health")
|
||||
print(f"Router health: {r.json()}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = asyncio.run(test())
|
||||
sys.exit(0 if result else 1)
|
||||
|
||||
Reference in New Issue
Block a user