Spaces:
Paused
Paused
| #!/usr/bin/env python3 | |
| """Odysseus ↔ Telegram Bot Bridge. | |
| Bridges Telegram messages to the Odysseus chat API, enabling conversational AI | |
| through Telegram. Supports per-user sessions, web search toggle, and streaming. | |
| """ | |
| import os | |
| import sys | |
| import re | |
| import json | |
| import time | |
| import logging | |
| import asyncio | |
| import httpx | |
| from typing import Optional | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s [TG-BOT] %(levelname)s %(message)s", | |
| ) | |
| logger = logging.getLogger("telegram_bot") | |
| # ── Config ────────────────────────────────────────────────────────────────── | |
| TELEGRAM_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "") | |
| ODYSSEUS_URL = os.getenv("ODYSSEUS_INTERNAL_URL", "http://127.0.0.1:7860") | |
| ALLOWED_USERS = os.getenv("TELEGRAM_ALLOWED_USERS", "").split(",") | |
| ALLOWED_USERS = [u.strip() for u in ALLOWED_USERS if u.strip()] | |
| # Per-user sessions stored in memory (reset on restart) | |
| user_sessions: dict[str, str] = {} | |
| TG_API = f"https://api.telegram.org/bot{TELEGRAM_TOKEN}" | |
| def get_api_token() -> str: | |
| """Get Odysseus API token from env or file (written by auto_configure).""" | |
| token = os.getenv("ODYSSEUS_API_TOKEN", "") | |
| if token: | |
| return token | |
| try: | |
| with open("/tmp/tg_api_token", "r") as f: | |
| return f.read().strip() | |
| except FileNotFoundError: | |
| return "" | |
| async def tg_request(method: str, data: dict, client: httpx.AsyncClient) -> dict: | |
| """Make a Telegram Bot API request.""" | |
| resp = await client.post(f"{TG_API}/{method}", json=data, timeout=30) | |
| result = resp.json() | |
| if not result.get("ok"): | |
| logger.error(f"Telegram API error on {method}: {result}") | |
| return result | |
| async def send_message(chat_id: int, text: str, client: httpx.AsyncClient, | |
| reply_to: Optional[int] = None, parse_mode: str = "Markdown"): | |
| """Send a message, splitting if > 4096 chars.""" | |
| chunks = [text[i:i+4000] for i in range(0, len(text), 4000)] | |
| for chunk in chunks: | |
| data = {"chat_id": chat_id, "text": chunk} | |
| if parse_mode: | |
| data["parse_mode"] = parse_mode | |
| if reply_to: | |
| data["reply_to_message_id"] = reply_to | |
| try: | |
| await tg_request("sendMessage", data, client) | |
| except Exception: | |
| # Retry without parse_mode if markdown fails | |
| data.pop("parse_mode", None) | |
| try: | |
| await tg_request("sendMessage", data, client) | |
| except Exception as e: | |
| logger.error(f"Failed to send message: {e}") | |
| async def send_typing(chat_id: int, client: httpx.AsyncClient): | |
| """Send typing indicator.""" | |
| try: | |
| await tg_request("sendChatAction", {"chat_id": chat_id, "action": "typing"}, client) | |
| except Exception: | |
| pass | |
| async def get_or_create_session(user_id: str, client: httpx.AsyncClient) -> str: | |
| """Get existing session or create a new one for this Telegram user.""" | |
| api_token = get_api_token() | |
| headers = {"Authorization": f"Bearer {api_token}"} if api_token else {} | |
| if user_id in user_sessions: | |
| return user_sessions[user_id] | |
| # Create a new Odysseus session | |
| try: | |
| resp = await client.post( | |
| f"{ODYSSEUS_URL}/api/session", | |
| json={ | |
| "name": f"Telegram-{user_id}", | |
| "endpoint_url": "", | |
| "model": "", | |
| }, | |
| headers=headers, | |
| timeout=15, | |
| ) | |
| if resp.status_code == 200: | |
| data = resp.json() | |
| sid = data.get("id", "") | |
| if sid: | |
| user_sessions[user_id] = sid | |
| logger.info(f"Created session {sid} for user {user_id}") | |
| return sid | |
| except Exception as e: | |
| logger.error(f"Failed to create session: {e}") | |
| # Fallback: try to use an existing session | |
| try: | |
| resp = await client.get( | |
| f"{ODYSSEUS_URL}/api/sessions", | |
| headers=headers, | |
| timeout=10, | |
| ) | |
| if resp.status_code == 200: | |
| sessions = resp.json() | |
| if isinstance(sessions, list): | |
| for s in sessions: | |
| if f"Telegram-{user_id}" in s.get("name", ""): | |
| sid = s["id"] | |
| user_sessions[user_id] = sid | |
| return sid | |
| if sessions: | |
| sid = sessions[0]["id"] | |
| user_sessions[user_id] = sid | |
| return sid | |
| except Exception as e: | |
| logger.error(f"Failed to list sessions: {e}") | |
| return "" | |
| async def chat_with_odysseus(message: str, session_id: str, client: httpx.AsyncClient, | |
| use_web: bool = False) -> str: | |
| """Send a message to Odysseus and get the response.""" | |
| api_token = get_api_token() | |
| headers = {"Content-Type": "application/json"} | |
| if api_token: | |
| headers["Authorization"] = f"Bearer {api_token}" | |
| try: | |
| resp = await client.post( | |
| f"{ODYSSEUS_URL}/api/chat", | |
| json={ | |
| "message": message, | |
| "session": session_id, | |
| "use_web": use_web, | |
| "attachments": [], | |
| }, | |
| headers=headers, | |
| timeout=180, | |
| ) | |
| if resp.status_code == 200: | |
| data = resp.json() | |
| return data.get("response", "⚠️ No response from model") | |
| else: | |
| error = resp.text[:500] | |
| logger.error(f"Chat API error {resp.status_code}: {error}") | |
| return f"⚠️ Error ({resp.status_code}): {error}" | |
| except httpx.TimeoutException: | |
| return "⏳ Response timed out. The model is taking too long." | |
| except Exception as e: | |
| logger.error(f"Chat error: {e}") | |
| return f"⚠️ Error: {str(e)}" | |
| def clean_response(text: str) -> str: | |
| """Clean up Odysseus response for Telegram display.""" | |
| # Remove ```tool_name ... ``` blocks (tool execution output) | |
| text = re.sub(r'```(?:bash|python|web_search|web_fetch|read_file|write_file|edit_file|grep|glob|ls|create_document|edit_document|manage_memory|manage_tasks|trigger_research)\n.*?\n```', '', text, flags=re.DOTALL) | |
| # Remove [doc:...] references | |
| text = re.sub(r'\[doc:[^\]]+\]', '', text) | |
| # Remove <tool_result>...</tool_result> blocks | |
| text = re.sub(r'<tool_result>.*?</tool_result>', '', text, flags=re.DOTALL) | |
| # Clean up excess whitespace | |
| text = re.sub(r'\n{3,}', '\n\n', text).strip() | |
| return text | |
| async def handle_message(update: dict, client: httpx.AsyncClient): | |
| """Process an incoming Telegram message.""" | |
| msg = update.get("message", {}) | |
| if not msg: | |
| return | |
| chat_id = msg.get("chat", {}).get("id") | |
| user_id = str(msg.get("from", {}).get("id", "")) | |
| username = msg.get("from", {}).get("username", "unknown") | |
| text = msg.get("text", "").strip() | |
| message_id = msg.get("message_id") | |
| if not text or not chat_id: | |
| return | |
| # Check allowed users | |
| if ALLOWED_USERS and user_id not in ALLOWED_USERS: | |
| await send_message(chat_id, "⛔ You are not authorized to use this bot.", client) | |
| logger.warning(f"Unauthorized user: {user_id} ({username})") | |
| return | |
| logger.info(f"Message from {username} ({user_id}): {text[:100]}") | |
| # Handle commands | |
| if text.startswith("/"): | |
| cmd = text.split()[0].lower().split("@")[0] | |
| if cmd == "/start": | |
| await send_message( | |
| chat_id, | |
| "🚀 *Welcome to Odysseus AI!*\n\n" | |
| "Send me any message and I'll respond using AI.\n\n" | |
| "*Commands:*\n" | |
| "• /new — Start a new conversation\n" | |
| "• /web `query` — Search the web\n" | |
| "• /research `topic` — Deep research\n" | |
| "• /help — Show this help\n\n" | |
| "Powered by NVIDIA Nemotron 🧠", | |
| client, | |
| ) | |
| return | |
| elif cmd == "/new": | |
| user_sessions.pop(user_id, None) | |
| await send_message(chat_id, "🔄 New conversation started!", client) | |
| return | |
| elif cmd == "/help": | |
| await send_message( | |
| chat_id, | |
| "*Odysseus AI Bot* 🏛️\n\n" | |
| "Just send any message to chat with AI.\n\n" | |
| "*Agent Capabilities:*\n" | |
| "• 🧠 Persistent memory across chats\n" | |
| "• 🌐 Web search & fetching\n" | |
| "• 💻 Code execution (Python & Bash)\n" | |
| "• 📄 Document creation & editing\n" | |
| "• 🔬 Deep research mode\n" | |
| "• 📁 File operations\n\n" | |
| "*Commands:*\n" | |
| "• /new — New conversation\n" | |
| "• /web `query` — Web search\n" | |
| "• /research `topic` — Deep research\n" | |
| "• /help — This message", | |
| client, | |
| ) | |
| return | |
| elif cmd == "/web": | |
| query = text[len("/web"):].strip() | |
| if not query: | |
| await send_message(chat_id, "Usage: /web `your query`", client) | |
| return | |
| text = f"Search the web for: {query}" | |
| elif cmd == "/research": | |
| topic = text[len("/research"):].strip() | |
| if not topic: | |
| await send_message(chat_id, "Usage: /research `your topic`", client) | |
| return | |
| text = f"Do deep research on: {topic}" | |
| # Send typing indicator | |
| await send_typing(chat_id, client) | |
| # Get or create session | |
| session_id = await get_or_create_session(user_id, client) | |
| if not session_id: | |
| await send_message( | |
| chat_id, | |
| "⚠️ Could not create a chat session. Please try again later.", | |
| client, | |
| ) | |
| return | |
| # Determine if web search should be used | |
| use_web = any(text.lower().startswith(p) for p in [ | |
| "search the web", "ابحث", "/web", "search for" | |
| ]) | |
| # Keep sending typing while waiting | |
| typing_task = asyncio.create_task(_keep_typing(chat_id, client)) | |
| try: | |
| # Chat with Odysseus | |
| response = await chat_with_odysseus(text, session_id, client, use_web=use_web) | |
| response = clean_response(response) | |
| if not response: | |
| response = "✅ Done! (The result was shown in a document/tool output)" | |
| await send_message(chat_id, response, client, reply_to=message_id) | |
| finally: | |
| typing_task.cancel() | |
| async def _keep_typing(chat_id: int, client: httpx.AsyncClient): | |
| """Keep sending typing indicator every 5 seconds.""" | |
| try: | |
| while True: | |
| await send_typing(chat_id, client) | |
| await asyncio.sleep(5) | |
| except asyncio.CancelledError: | |
| pass | |
| async def poll_updates(client: httpx.AsyncClient): | |
| """Long-poll Telegram for updates.""" | |
| offset = 0 | |
| logger.info("Starting Telegram bot polling...") | |
| while True: | |
| try: | |
| resp = await client.post( | |
| f"{TG_API}/getUpdates", | |
| json={"offset": offset, "timeout": 30, "allowed_updates": ["message"]}, | |
| timeout=35, | |
| ) | |
| data = resp.json() | |
| if data.get("ok") and data.get("result"): | |
| for update in data["result"]: | |
| offset = update["update_id"] + 1 | |
| try: | |
| await handle_message(update, client) | |
| except Exception as e: | |
| logger.error(f"Error handling update: {e}", exc_info=True) | |
| except httpx.TimeoutException: | |
| continue | |
| except Exception as e: | |
| logger.error(f"Polling error: {e}") | |
| await asyncio.sleep(5) | |
| async def main(): | |
| if not TELEGRAM_TOKEN: | |
| logger.error("TELEGRAM_BOT_TOKEN not set — Telegram bot disabled") | |
| return | |
| logger.info(f"Telegram bot starting (allowed users: {ALLOWED_USERS or 'all'})") | |
| # Wait for auto_configure to finish and write the token | |
| for i in range(60): | |
| token = get_api_token() | |
| if token: | |
| logger.info(f"Got API token (prefix: {token[:8]}...)") | |
| break | |
| logger.info(f"Waiting for API token... ({i+1}/60)") | |
| await asyncio.sleep(2) | |
| if not get_api_token(): | |
| logger.error("No API token available — Telegram bot cannot authenticate!") | |
| return | |
| async with httpx.AsyncClient() as client: | |
| # Wait for Odysseus to be ready | |
| for i in range(60): | |
| try: | |
| resp = await client.get(f"{ODYSSEUS_URL}/api/auth/status", timeout=5) | |
| if resp.status_code == 200: | |
| logger.info("Odysseus is ready!") | |
| break | |
| except Exception: | |
| pass | |
| if i % 10 == 0: | |
| logger.info(f"Waiting for Odysseus... ({i+1}/60)") | |
| await asyncio.sleep(2) | |
| # Delete any existing webhook so polling works | |
| await tg_request("deleteWebhook", {"drop_pending_updates": False}, client) | |
| # Set bot commands | |
| await tg_request("setMyCommands", { | |
| "commands": [ | |
| {"command": "new", "description": "Start new conversation"}, | |
| {"command": "web", "description": "Search the web"}, | |
| {"command": "research", "description": "Deep research on a topic"}, | |
| {"command": "help", "description": "Show help"}, | |
| ] | |
| }, client) | |
| logger.info("Telegram bot is live! Polling for messages...") | |
| # Start polling | |
| await poll_updates(client) | |
| if __name__ == "__main__": | |
| asyncio.run(main()) | |