SysOps: voice-agy-webbuilder-backup — 2026-06-23 10:04 UTC
This commit is contained in:
Executable
+237
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user