238 lines
8.8 KiB
Python
Executable File
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()
|