""" Hermes Telegram bot β€” tekst + voice relay naar Cockpit Herman/Voice API. Deploy naar VM105: ~/foodlinkk-ai/hermes/main.py """ from __future__ import annotations import io import logging import os from pathlib import Path import httpx from telegram import Update from telegram.constants import ChatAction from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes TOKEN = os.environ["TELEGRAM_BOT_TOKEN"] COCKPIT_CHAT_URL = os.getenv("COCKPIT_HERMAN_URL", "http://10.4.7.18:8600/api/herman/chat") COCKPIT_VOICE_TURN_URL = os.getenv("COCKPIT_VOICE_TURN_URL", "http://10.4.7.18:8600/api/voice/turn") CHAT_IDS_FILE = Path(os.getenv("CHAT_IDS_FILE", os.path.expanduser("~/foodlinkk-ai/hermes/chat_ids.txt"))) logging.basicConfig(level=logging.INFO) log = logging.getLogger("hermes") _chat_sessions: dict[int, str] = {} def read_chat_ids() -> list[int]: if not CHAT_IDS_FILE.is_file(): return [] return [int(line.strip()) for line in CHAT_IDS_FILE.read_text().splitlines() if line.strip().isdigit()] def save_chat_id(chat_id: int) -> None: if chat_id in set(read_chat_ids()): return CHAT_IDS_FILE.parent.mkdir(parents=True, exist_ok=True) with CHAT_IDS_FILE.open("a") as f: f.write(f"{chat_id}\n") def _session_id(chat_id: int) -> str: if chat_id not in _chat_sessions: _chat_sessions[chat_id] = f"tg-{chat_id}-{os.getpid()}" return _chat_sessions[chat_id] def _format_delegation(data: dict) -> str: agents = data.get("delegated_agents") or [] agents = [a for a in agents if str(a).lower() != "herman"] if not agents: return "" reason = (data.get("routing_reason") or "").strip() lines = ["", "πŸ‘₯ Agents: " + " β†’ ".join(agents)] steps = data.get("agent_steps") or [] for step in steps[:6]: msg = step.get("message") or step.get("status") or "" if msg: lines.append(f" Β· {step.get('agent', '?')}: {msg[:120]}") if not steps and reason: lines.append(f" β”” {reason[:200]}") return "\n".join(lines) def _format_ui_actions(data: dict) -> str: herman = data.get("herman") if "herman" in data else data actions = herman.get("ui_actions") or [] lines: list[str] = [] for action in actions: if action.get("type") == "show_export_results": items = action.get("entities") or [] total = action.get("total") or len(items) lines.append(f"\nπŸ“¦ Export Intel: {total} resultaten") for ent in items[:12]: name = ent.get("name") or "?" city = ent.get("city") or "" etype = ent.get("entity_type") or "" contact = ent.get("primary_email") or ent.get("primary_phone") or "" line = f" Β· {name}" if city: line += f" ({city})" if etype: line += f" [{etype}]" if contact: line += f" β€” {contact[:40]}" lines.append(line) if total > 12: lines.append(f" … en {total - 12} meer") url = action.get("open_url") or "/export-intel" lines.append(f" πŸ”— http://10.4.7.18:8600{url}") elif action.get("type") == "open_webbuilder_build": project = action.get("project") or "website" preview = action.get("preview_url") or "" lines.append(f"\n🌐 Agy website build: {project}") if preview: lines.append(f" Preview: {preview}") lines.append(" πŸ”— http://10.4.7.18:8600/agents") return "\n".join(lines) def _format_pending(data: dict) -> str: herman = data.get("herman") if "herman" in data else data if not herman.get("needs_confirmation"): return "" pa = herman.get("pending_action") or {} if not pa: return "" return ( "\n\n⚠️ Bevestiging: antwoord **ja** om te starten of **nee** om te annuleren." + ( f"\n(Agy website: {pa.get('project', '')})" if pa.get("type") == "webbuilder_build" else (f"\n({pa.get('entity_label', '')} in {pa.get('location_label', '')})" if pa.get("location_label") else "") ) ) async def _call_herman(message: str, chat_id: int) -> dict: async with httpx.AsyncClient(timeout=300.0) as client: resp = await client.post( COCKPIT_CHAT_URL, json={ "message": message, "channel": "telegram", "session_id": _session_id(chat_id), }, ) resp.raise_for_status() data = resp.json() return data if isinstance(data, dict) else {} async def _call_voice_turn(audio_bytes: bytes, filename: str, mime: str, chat_id: int) -> dict: async with httpx.AsyncClient(timeout=300.0) as client: resp = await client.post( COCKPIT_VOICE_TURN_URL, data={"session_id": _session_id(chat_id)}, files={"file": (filename, audio_bytes, mime)}, ) resp.raise_for_status() return resp.json() async def _reply_herman_result(update: Update, status_msg, data: dict) -> None: herman = data.get("herman") if data.get("herman") is not None else data reply = (herman.get("reply") or data.get("reply") or "Geen antwoord.").strip() if data.get("text") and not reply.startswith(str(data["text"])[:20]): reply = f"🎀 {data['text']}\n\n{reply}" delegated = herman.get("delegated_agents") or data.get("delegated_agents") or [] show = [a for a in delegated if str(a).lower() != "herman"] if show: await status_msg.edit_text( f"πŸ”„ Herman stuurt aan: {', '.join(show)}\n" + ((herman.get("routing_reason") or "")[:180]) ) reply += _format_delegation(herman) reply += _format_ui_actions(data) reply += _format_pending(data) await update.message.reply_text(reply[:4000]) async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if update.effective_chat: save_chat_id(update.effective_chat.id) await update.message.reply_text( "Foodlinkk Herman β€” stel je vraag (tekst of voice). " "Bij zoekopdrachten vraag ik eerst om bevestiging.\n/ping voor test." ) async def cmd_ping(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: await update.message.reply_text("pong β€” Herman bereikbaar via Cockpit relay") async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not update.message or not update.message.text: return chat_id = update.effective_chat.id if update.effective_chat else 0 save_chat_id(chat_id) text = update.message.text.strip() if not text: return await update.message.chat.send_action(ChatAction.TYPING) status_msg = await update.message.reply_text("⏳ Herman denkt na…") try: data = await _call_herman(text, chat_id) await _reply_herman_result(update, status_msg, data) except Exception as exc: log.exception("herman relay failed") await status_msg.edit_text(f"Herman niet bereikbaar: {exc}") async def handle_voice(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not update.message: return voice = update.message.voice or update.message.audio if not voice: return chat_id = update.effective_chat.id if update.effective_chat else 0 save_chat_id(chat_id) await update.message.chat.send_action(ChatAction.RECORD_VOICE) status_msg = await update.message.reply_text("🎀 Voice β†’ Whisper β†’ Herman…") try: tg_file = await context.bot.get_file(voice.file_id) buf = io.BytesIO() await tg_file.download_to_memory(buf) audio_bytes = buf.getvalue() mime = "audio/ogg" filename = "voice.ogg" if update.message.audio and update.message.audio.mime_type: mime = update.message.audio.mime_type filename = "audio." + (mime.split("/")[-1] or "ogg") data = await _call_voice_turn(audio_bytes, filename, mime, chat_id) await _reply_herman_result(update, status_msg, data) except Exception as exc: log.exception("voice relay failed") await status_msg.edit_text(f"Voice relay mislukt: {exc}") def main() -> None: app = Application.builder().token(TOKEN).build() app.add_handler(CommandHandler("start", cmd_start)) app.add_handler(CommandHandler("ping", cmd_ping)) app.add_handler(MessageHandler(filters.VOICE | filters.AUDIO, handle_voice)) app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message)) log.info("Hermes Telegram β†’ chat %s | voice %s", COCKPIT_CHAT_URL, COCKPIT_VOICE_TURN_URL) app.run_polling(allowed_updates=Update.ALL_TYPES) if __name__ == "__main__": main()