Files

238 lines
8.8 KiB
Python
Executable File

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