generado bot de telegram con capacidades de kanboard
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
"""Bot de Telegram para conversar con el agente Kanboard."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import json
|
||||
from contextlib import suppress
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from agentes import DEFAULT_AGENT_NAME, create_agent
|
||||
from LokiLogger import LokiLogger
|
||||
from mcp_wrapper import close_mcp_tools
|
||||
|
||||
TELEGRAM_API_BASE = "https://api.telegram.org"
|
||||
MAX_MESSAGE_LENGTH = 3800 # margen para markdown/formatting
|
||||
|
||||
|
||||
def _parse_int(value: Optional[str], *, env_name: str) -> Optional[int]:
|
||||
if value is None or value.strip() == "":
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError as error: # pragma: no cover - salida controlada
|
||||
raise ValueError(f"{env_name} debe ser un entero válido, se recibió: {value!r}") from error
|
||||
|
||||
|
||||
class TelegramKanBot:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
token: str,
|
||||
allowed_user_id: int,
|
||||
allowed_chat_id: int,
|
||||
agent_key: str,
|
||||
logger: LokiLogger,
|
||||
) -> None:
|
||||
self.token = token
|
||||
self.allowed_user_id = allowed_user_id
|
||||
self.allowed_chat_id = allowed_chat_id
|
||||
self.agent_key = agent_key
|
||||
self.logger = logger
|
||||
self.api_timeout = 30
|
||||
self.http_timeout = 35
|
||||
self.idle_sleep = 1
|
||||
self.offset: Optional[int] = None
|
||||
self.base_url = f"{TELEGRAM_API_BASE}/bot{token}"
|
||||
self.session = requests.Session()
|
||||
|
||||
self.agent = None
|
||||
self.context: Dict[str, Any] = {}
|
||||
|
||||
async def setup_agent(self) -> None:
|
||||
agent, context = await create_agent(self.logger, agent=self.agent_key)
|
||||
self.agent = agent
|
||||
self.context = context
|
||||
self.logger.info(
|
||||
"🤖 Agente Kan para Telegram listo",
|
||||
add_fields={
|
||||
"agent_call": {"action": "telegram_agent_ready", "agent": self.agent_key},
|
||||
"agent_response": {"status": "ready", "allowed_chat": self.allowed_chat_id},
|
||||
},
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
if self.context.get("mcp_tools"):
|
||||
await close_mcp_tools(self.context["mcp_tools"], logger=self.logger)
|
||||
self.session.close()
|
||||
|
||||
async def run(self) -> None:
|
||||
if self.agent is None:
|
||||
await self.setup_agent()
|
||||
|
||||
self.logger.info(
|
||||
"🚀 Escuchando mensajes de Telegram",
|
||||
add_fields={
|
||||
"agent_call": {"action": "telegram_poll"},
|
||||
"agent_response": {
|
||||
"status": "listening",
|
||||
"chat_id": self.allowed_chat_id,
|
||||
"user_id": self.allowed_user_id,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
updates = await self._fetch_updates()
|
||||
for update in updates:
|
||||
self.offset = update["update_id"] + 1
|
||||
await self._handle_update(update)
|
||||
await asyncio.sleep(self.idle_sleep)
|
||||
except asyncio.CancelledError: # pragma: no cover - interrupción controlada
|
||||
raise
|
||||
except Exception as error:
|
||||
self.logger.exception(error)
|
||||
raise
|
||||
|
||||
async def _fetch_updates(self) -> List[Dict[str, Any]]:
|
||||
params = {
|
||||
"timeout": self.api_timeout,
|
||||
"allowed_updates": json.dumps(["message", "edited_message"]),
|
||||
}
|
||||
if self.offset is not None:
|
||||
params["offset"] = self.offset
|
||||
try:
|
||||
response = await asyncio.to_thread(
|
||||
self.session.get,
|
||||
f"{self.base_url}/getUpdates",
|
||||
params=params,
|
||||
timeout=self.http_timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except Exception as error: # pragma: no cover - manejo defensivo
|
||||
self.logger.exception(
|
||||
error,
|
||||
add_fields={
|
||||
"agent_call": {"action": "telegram_get_updates"},
|
||||
"agent_response": {"status": "error"},
|
||||
},
|
||||
)
|
||||
await asyncio.sleep(5)
|
||||
return []
|
||||
|
||||
if not data.get("ok"):
|
||||
self.logger.error(
|
||||
"La API de Telegram devolvió un estado no-ok",
|
||||
add_fields={
|
||||
"agent_call": {"action": "telegram_get_updates"},
|
||||
"agent_response": {"status": data.get("ok"), "description": data.get("description")},
|
||||
},
|
||||
)
|
||||
return []
|
||||
|
||||
result = data.get("result", [])
|
||||
if result:
|
||||
self.logger.debug(
|
||||
"📩 Nuevos updates recibidos",
|
||||
add_fields={
|
||||
"agent_call": {"action": "telegram_get_updates"},
|
||||
"agent_response": {"status": "updates", "count": len(result)},
|
||||
},
|
||||
)
|
||||
return result
|
||||
|
||||
async def _handle_update(self, update: Dict[str, Any]) -> None:
|
||||
message = update.get("message") or update.get("edited_message")
|
||||
if not message:
|
||||
return
|
||||
|
||||
chat = message.get("chat", {})
|
||||
chat_id = chat.get("id")
|
||||
user = message.get("from", {})
|
||||
user_id = user.get("id")
|
||||
|
||||
if chat_id != self.allowed_chat_id or user_id != self.allowed_user_id:
|
||||
self.logger.warn(
|
||||
"Mensaje ignorado por no cumplir filtros",
|
||||
add_fields={
|
||||
"agent_call": {"action": "telegram_filter_message"},
|
||||
"agent_response": {"status": "ignored", "chat_id": chat_id, "user_id": user_id},
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
text = message.get("text") or ""
|
||||
if not text:
|
||||
await self._send_message(chat_id, "Solo puedo procesar mensajes de texto por ahora.")
|
||||
return
|
||||
|
||||
if text.strip().lower() in {"/start", "hola", "hi"}:
|
||||
await self._send_message(chat_id, "¡Hola! Soy KAN, listo para ayudarte con Kanboard.")
|
||||
return
|
||||
|
||||
await self._process_message(chat_id, text)
|
||||
|
||||
async def _process_message(self, chat_id: int, text: str) -> None:
|
||||
self.logger.info(
|
||||
"📨 Mensaje recibido desde Telegram",
|
||||
add_fields={
|
||||
"agent_call": {"action": "telegram_user_message", "content": text},
|
||||
"agent_response": {"status": "processing"},
|
||||
},
|
||||
)
|
||||
typing_task = asyncio.create_task(self._typing_indicator(chat_id))
|
||||
try:
|
||||
response = await self.agent.arun(text)
|
||||
except Exception as error: # pragma: no cover - manejo defensivo
|
||||
self.logger.exception(
|
||||
error,
|
||||
add_fields={
|
||||
"agent_call": {"action": "agent_run", "content": text},
|
||||
"agent_response": {"status": "error"},
|
||||
},
|
||||
)
|
||||
await self._send_message(chat_id, "Hubo un error procesando tu mensaje. Revisa los logs para más detalles.")
|
||||
return
|
||||
finally:
|
||||
typing_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await typing_task
|
||||
|
||||
content = (response.content or "(respuesta vacía)").strip()
|
||||
if not content:
|
||||
content = "(respuesta vacía)"
|
||||
|
||||
await self._send_message(chat_id, content)
|
||||
|
||||
if response.metrics:
|
||||
self.logger.info(
|
||||
"📊 Métricas de ejecución",
|
||||
add_fields={
|
||||
"agent_call": {"action": "agent_run_metrics"},
|
||||
"agent_response": {
|
||||
"status": "completed",
|
||||
"metrics": {
|
||||
"input_tokens": response.metrics.input_tokens,
|
||||
"output_tokens": response.metrics.output_tokens,
|
||||
"total_tokens": response.metrics.total_tokens,
|
||||
"duration_seconds": response.metrics.duration,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
async def _send_message(self, chat_id: int, text: str) -> None:
|
||||
for chunk in self._chunk_text(text):
|
||||
payload = {"chat_id": chat_id, "text": chunk}
|
||||
try:
|
||||
response = await asyncio.to_thread(
|
||||
self.session.post,
|
||||
f"{self.base_url}/sendMessage",
|
||||
json=payload,
|
||||
timeout=self.http_timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if not data.get("ok"):
|
||||
self.logger.error(
|
||||
"Telegram rechazó el mensaje",
|
||||
add_fields={
|
||||
"agent_call": {"action": "telegram_send_message"},
|
||||
"agent_response": {"status": data.get("ok"), "description": data.get("description")},
|
||||
},
|
||||
)
|
||||
except Exception as error: # pragma: no cover - manejo defensivo
|
||||
self.logger.exception(
|
||||
error,
|
||||
add_fields={
|
||||
"agent_call": {"action": "telegram_send_message", "payload": payload},
|
||||
"agent_response": {"status": "error"},
|
||||
},
|
||||
)
|
||||
|
||||
async def _typing_indicator(self, chat_id: int) -> None:
|
||||
try:
|
||||
while True:
|
||||
await self._send_chat_action(chat_id, "typing")
|
||||
await asyncio.sleep(4)
|
||||
except asyncio.CancelledError: # pragma: no cover - interrupción esperada
|
||||
raise
|
||||
|
||||
async def _send_chat_action(self, chat_id: int, action: str) -> None:
|
||||
payload = {"chat_id": chat_id, "action": action}
|
||||
try:
|
||||
response = await asyncio.to_thread(
|
||||
self.session.post,
|
||||
f"{self.base_url}/sendChatAction",
|
||||
json=payload,
|
||||
timeout=self.http_timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if not data.get("ok"):
|
||||
self.logger.error(
|
||||
"Telegram rechazó la acción del chat",
|
||||
add_fields={
|
||||
"agent_call": {"action": "telegram_send_chat_action"},
|
||||
"agent_response": {"status": data.get("ok"), "description": data.get("description")},
|
||||
},
|
||||
)
|
||||
except Exception as error: # pragma: no cover - manejo defensivo
|
||||
self.logger.exception(
|
||||
error,
|
||||
add_fields={
|
||||
"agent_call": {"action": "telegram_send_chat_action", "payload": payload},
|
||||
"agent_response": {"status": "error"},
|
||||
},
|
||||
)
|
||||
|
||||
def _chunk_text(self, text: str) -> List[str]:
|
||||
if len(text) <= MAX_MESSAGE_LENGTH:
|
||||
return [text]
|
||||
|
||||
chunks: List[str] = []
|
||||
current = []
|
||||
current_len = 0
|
||||
for paragraph in text.split("\n\n"):
|
||||
paragraph = paragraph.strip()
|
||||
if not paragraph:
|
||||
continue
|
||||
paragraph_len = len(paragraph) + 2 # margen para doble salto
|
||||
if current_len + paragraph_len > MAX_MESSAGE_LENGTH and current:
|
||||
chunks.append("\n\n".join(current))
|
||||
current = [paragraph]
|
||||
current_len = len(paragraph)
|
||||
elif paragraph_len > MAX_MESSAGE_LENGTH:
|
||||
chunks.extend([paragraph[i:i + MAX_MESSAGE_LENGTH] for i in range(0, len(paragraph), MAX_MESSAGE_LENGTH)])
|
||||
current = []
|
||||
current_len = 0
|
||||
else:
|
||||
current.append(paragraph)
|
||||
current_len += paragraph_len
|
||||
if current:
|
||||
chunks.append("\n\n".join(current))
|
||||
return chunks
|
||||
|
||||
|
||||
async def run_bot() -> None:
|
||||
load_dotenv()
|
||||
|
||||
token = os.getenv("TELEGRAM_BOT_TOKEN")
|
||||
allowed_user_id = _parse_int(os.getenv("TELEGRAM_ALLOWED_USER_ID"), env_name="TELEGRAM_ALLOWED_USER_ID")
|
||||
allowed_chat_id = _parse_int(os.getenv("TELEGRAM_ALLOWED_CHAT_ID"), env_name="TELEGRAM_ALLOWED_CHAT_ID")
|
||||
agent_key = (os.getenv("TELEGRAM_AGENT_KEY") or DEFAULT_AGENT_NAME).lower()
|
||||
|
||||
if not token:
|
||||
raise RuntimeError("TELEGRAM_BOT_TOKEN debe definirse en el entorno")
|
||||
if allowed_user_id is None or allowed_chat_id is None:
|
||||
raise RuntimeError("TELEGRAM_ALLOWED_USER_ID y TELEGRAM_ALLOWED_CHAT_ID son obligatorios")
|
||||
|
||||
logger = LokiLogger(
|
||||
service_name="agente_kanboard",
|
||||
add_labels={"env": "local", "agent": "telegram"},
|
||||
)
|
||||
|
||||
bot = TelegramKanBot(
|
||||
token=token,
|
||||
allowed_user_id=allowed_user_id,
|
||||
allowed_chat_id=allowed_chat_id,
|
||||
agent_key=agent_key,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
try:
|
||||
await bot.run()
|
||||
finally:
|
||||
await bot.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
asyncio.run(run_bot())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - ejecución directa
|
||||
main()
|
||||
Reference in New Issue
Block a user