SysOps: voice-agy-webbuilder-backup — 2026-06-23 10:04 UTC
This commit is contained in:
Executable
+98
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Matrix-driven agent scheduler — daily handoff chains with correlation_id."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
COCKPIT_URL = "http://127.0.0.1:8600"
|
||||
TOOLS_URL = "http://127.0.0.1:8700"
|
||||
|
||||
|
||||
def _post(url: str, payload: dict | None = None, timeout: int = 120) -> dict:
|
||||
data = json.dumps(payload or {}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _handoff(from_agent: str, to_agent: str, handoff_type: str, payload: dict, cid: str) -> None:
|
||||
try:
|
||||
_post(
|
||||
f"{COCKPIT_URL}/api/agents/handoff",
|
||||
{
|
||||
"from_agent": from_agent,
|
||||
"to_agent": to_agent,
|
||||
"handoff_type": handoff_type,
|
||||
"payload": payload,
|
||||
"correlation_id": cid,
|
||||
},
|
||||
)
|
||||
print(f"handoff {from_agent} -> {to_agent} ({handoff_type})")
|
||||
except Exception as exc:
|
||||
print(f"handoff failed {from_agent}->{to_agent}: {exc}", file=sys.stderr)
|
||||
|
||||
|
||||
def _trigger_tools(path: str, label: str) -> dict:
|
||||
try:
|
||||
return _post(f"{TOOLS_URL}{path}", timeout=180)
|
||||
except Exception as exc:
|
||||
print(f"{label} failed: {exc}", file=sys.stderr)
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
|
||||
def run_chain_0600(cid: str) -> None:
|
||||
research = _trigger_tools("/research/run", "research")
|
||||
_handoff("browser", "research", "scrape", {"step": "morning_research"}, cid)
|
||||
_handoff("research", "marketing", "intel", {"research_ok": research.get("ok", False)}, cid)
|
||||
rss = _trigger_tools("/retail/rss/refresh", "rss")
|
||||
_handoff("marketing", "retail", "trends", {"rss_ok": rss.get("ok", True), "items": rss.get("count", 0)}, cid)
|
||||
|
||||
|
||||
def run_chain_0630(cid: str) -> None:
|
||||
_handoff("sourcing", "product", "suppliers", {"step": "morning_sourcing"}, cid)
|
||||
_handoff("product", "halal", "compliance", {"step": "ingredient_check"}, cid)
|
||||
|
||||
|
||||
def run_chain_0700(cid: str) -> None:
|
||||
_handoff("sysops", "knowledge", "logs", {"step": "infra_archive"}, cid)
|
||||
_handoff("hr", "sysops", "onboarding", {"step": "hr_check", "note": "placeholder check-in"}, cid)
|
||||
_handoff("sysops", "herman", "infra_status", {"step": "daily_ops"}, cid)
|
||||
|
||||
|
||||
def run_chain_0800(cid: str) -> None:
|
||||
_handoff("email", "bizdev", "leads", {"step": "morning_leads_digest"}, cid)
|
||||
_handoff("retail", "bizdev", "opportunities", {"step": "top_opportunities"}, cid)
|
||||
_handoff("bizdev", "finance", "valuation", {"step": "pipeline_margin"}, cid)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
base_cid = str(uuid.uuid4())
|
||||
print(f"agent_scheduler start {today} correlation={base_cid}")
|
||||
|
||||
run_chain_0600(base_cid)
|
||||
run_chain_0630(str(uuid.uuid4()))
|
||||
run_chain_0700(str(uuid.uuid4()))
|
||||
run_chain_0800(str(uuid.uuid4()))
|
||||
|
||||
try:
|
||||
_post(f"{COCKPIT_URL}/api/herman/briefing", timeout=300)
|
||||
print("briefing triggered")
|
||||
except Exception as exc:
|
||||
print(f"briefing skip: {exc}", file=sys.stderr)
|
||||
|
||||
print("agent_scheduler done")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
# Remount Synology share with permissive options + restart doc-ingest
|
||||
set -euo pipefail
|
||||
PASS="${NAS_PASS:-Foodlinkk#2026}"
|
||||
FSTAB_ENTRY='//10.4.7.11/share /mnt/synology cifs credentials=/etc/smbcredentials/synology,vers=3.0,uid=1000,gid=1000,file_mode=0775,dir_mode=0775,_netdev 0 0'
|
||||
|
||||
fix_host() {
|
||||
local host="$1"
|
||||
echo "==> Fix NAS mount on $host"
|
||||
sshpass -p "$PASS" ssh -o StrictHostKeyChecking=no "aissa@$host" "PASS='$PASS' FSTAB_ENTRY='$FSTAB_ENTRY' bash -s" <<'REMOTE'
|
||||
set -e
|
||||
echo "$PASS" | sudo -S mkdir -p /etc/smbcredentials /mnt/synology
|
||||
if [ ! -f /etc/smbcredentials/synology ]; then
|
||||
echo "$PASS" | sudo -S bash -c "printf '%s\n' 'username=aissa' 'password=$PASS' > /etc/smbcredentials/synology"
|
||||
echo "$PASS" | sudo -S chmod 600 /etc/smbcredentials/synology
|
||||
fi
|
||||
echo "$PASS" | sudo -S sed -i '/10\.4\.7\.11\/share/d' /etc/fstab
|
||||
echo "$PASS" | sudo -S bash -c "echo '$FSTAB_ENTRY' >> /etc/fstab"
|
||||
echo "$PASS" | sudo -S umount /mnt/synology 2>/dev/null || true
|
||||
echo "$PASS" | sudo -S mount /mnt/synology
|
||||
echo "Share files: $(find /mnt/synology -type f ! -path '*#recycle*' 2>/dev/null | wc -l)"
|
||||
echo "CUCINA files: $(find /mnt/synology/CUCINA -type f 2>/dev/null | wc -l)"
|
||||
REMOTE
|
||||
}
|
||||
|
||||
fix_host "10.4.7.19"
|
||||
fix_host "10.4.7.18"
|
||||
|
||||
echo "==> Restart doc-ingest"
|
||||
sshpass -p "$PASS" ssh -o StrictHostKeyChecking=no aissa@10.4.7.19 \
|
||||
'cd ~/foodlinkk-ai/doc-ingest && docker compose restart 2>/dev/null || docker restart foodlinkk-doc-ingest'
|
||||
|
||||
echo "==> Done"
|
||||
echo "Als CUCINA/Foodlinkk nog 0 bestanden tonen: Synology DSM -> share -> Permissions -> aissa Read/Write + submappen"
|
||||
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Hermes Web Builder API — agy builds voor Cockpit Web Builder agent."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
app = FastAPI(title="Hermes Web Builder API", version="1.0.0")
|
||||
|
||||
PROJECTS_BASE = Path(os.getenv("WEBBUILDER_PROJECTS", "/home/hermes/projects"))
|
||||
PREVIEW_PORT = int(os.getenv("WEBBUILDER_PREVIEW_PORT", "8080"))
|
||||
PREVIEW_HOST = os.getenv("WEBBUILDER_PREVIEW_HOST", "10.4.7.27")
|
||||
NAS_PREFIX = os.getenv("WEBBUILDER_NAS_PREFIX", "//10.4.7.11/share/Websites")
|
||||
AGY_BIN = os.getenv("AGY_BIN", "/home/hermes/.local/bin/agy")
|
||||
BUILD_TIMEOUT = os.getenv("WEBBUILDER_BUILD_TIMEOUT", "30m")
|
||||
|
||||
JOBS: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
s = (name or "").strip().lower()
|
||||
s = re.sub(r"[^a-z0-9_-]+", "-", s)
|
||||
s = re.sub(r"-+", "-", s).strip("-")
|
||||
return s[:64] or "website"
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _list_files(project_dir: Path) -> list[str]:
|
||||
if not project_dir.is_dir():
|
||||
return []
|
||||
out: list[str] = []
|
||||
for p in sorted(project_dir.rglob("*")):
|
||||
if p.is_file():
|
||||
out.append(str(p.relative_to(project_dir)))
|
||||
return out[:50]
|
||||
|
||||
|
||||
def _preview_url() -> str:
|
||||
return f"http://{PREVIEW_HOST}:{PREVIEW_PORT}/"
|
||||
|
||||
|
||||
async def _run_shell(cmd: str, *, timeout: float = 120.0) -> tuple[int, str, str]:
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
return 124, "", "timeout"
|
||||
return proc.returncode or 0, stdout.decode(errors="replace"), stderr.decode(errors="replace")
|
||||
|
||||
|
||||
async def _ensure_project(slug: str) -> Path:
|
||||
proj = PROJECTS_BASE / slug
|
||||
proj.mkdir(parents=True, exist_ok=True)
|
||||
await _run_shell(f"chown hermes:hermes '{proj}'")
|
||||
return proj
|
||||
|
||||
|
||||
async def _start_preview(slug: str) -> None:
|
||||
cmd = (
|
||||
f"pkill -f 'http.server.*{PREVIEW_PORT}' 2>/dev/null || true; "
|
||||
f"su - hermes -c 'cd {PROJECTS_BASE}/{slug} && "
|
||||
f"nohup python3 -m http.server {PREVIEW_PORT} > /tmp/webbuilder-preview.log 2>&1 &'"
|
||||
)
|
||||
await _run_shell(cmd, timeout=15.0)
|
||||
|
||||
|
||||
async def _run_build(job_id: str, slug: str, prompt: str) -> None:
|
||||
job = JOBS[job_id]
|
||||
job["status"] = "running"
|
||||
job["message"] = f"agy bouwt {slug}…"
|
||||
job["updated_at"] = _now()
|
||||
|
||||
proj = await _ensure_project(slug)
|
||||
safe_prompt = prompt.replace('"', '\\"')[:8000]
|
||||
agy_cmd = (
|
||||
f'su - hermes -c \'export PATH="$HOME/.local/bin:$PATH"; '
|
||||
f"cd '{proj}' && "
|
||||
f"{AGY_BIN} --print --dangerously-skip-permissions --print-timeout {BUILD_TIMEOUT} "
|
||||
f'"{safe_prompt}"\''
|
||||
)
|
||||
|
||||
job["message"] = "Antigravity CLI gestart…"
|
||||
rc, out, err = await _run_shell(agy_cmd, timeout=3600.0)
|
||||
log_tail = (out + "\n" + err).strip()[-4000:]
|
||||
job["log_tail"] = log_tail
|
||||
job["updated_at"] = _now()
|
||||
|
||||
files = _list_files(proj)
|
||||
job["files"] = files
|
||||
|
||||
if rc == 0 and files:
|
||||
await _start_preview(slug)
|
||||
job["status"] = "completed"
|
||||
job["message"] = f"Website klaar — {len(files)} bestand(en)"
|
||||
job["preview_url"] = _preview_url()
|
||||
job["completed_at"] = _now()
|
||||
elif rc == 0:
|
||||
job["status"] = "completed"
|
||||
job["message"] = "agy klaar (geen bestanden gevonden)"
|
||||
job["preview_url"] = _preview_url() if files else None
|
||||
job["completed_at"] = _now()
|
||||
else:
|
||||
job["status"] = "failed"
|
||||
job["message"] = f"agy exit code {rc}"
|
||||
job["error"] = log_tail[-800:] or f"exit {rc}"
|
||||
job["completed_at"] = _now()
|
||||
|
||||
|
||||
class BuildRequest(BaseModel):
|
||||
project: str = Field(..., min_length=1, max_length=64)
|
||||
prompt: str = Field(..., min_length=1, max_length=8000)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, Any]:
|
||||
return {"ok": True, "service": "hermes-webbuilder", "projects_base": str(PROJECTS_BASE)}
|
||||
|
||||
|
||||
@app.get("/api/projects")
|
||||
async def list_projects() -> dict[str, Any]:
|
||||
items: list[dict[str, Any]] = []
|
||||
if PROJECTS_BASE.is_dir():
|
||||
for p in sorted(PROJECTS_BASE.iterdir()):
|
||||
if p.is_dir() and not p.name.startswith("."):
|
||||
files = _list_files(p)
|
||||
items.append({"slug": p.name, "files": len(files), "has_index": (p / "index.html").is_file()})
|
||||
return {"ok": True, "items": items, "count": len(items)}
|
||||
|
||||
|
||||
@app.post("/api/build")
|
||||
async def start_build(body: BuildRequest) -> dict[str, Any]:
|
||||
slug = slugify(body.project)
|
||||
if not slug:
|
||||
raise HTTPException(400, "Ongeldige projectnaam")
|
||||
|
||||
await _ensure_project(slug)
|
||||
job_id = str(uuid.uuid4())
|
||||
JOBS[job_id] = {
|
||||
"job_id": job_id,
|
||||
"project": slug,
|
||||
"status": "queued",
|
||||
"message": "Build in wachtrij",
|
||||
"prompt": body.prompt[:500],
|
||||
"nas_path": f"{NAS_PREFIX}/{slug}/",
|
||||
"preview_url": _preview_url(),
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
}
|
||||
JOBS[slug] = JOBS[job_id]
|
||||
|
||||
asyncio.create_task(_run_build(job_id, slug, body.prompt))
|
||||
return {
|
||||
"ok": True,
|
||||
"job_id": job_id,
|
||||
"project": slug,
|
||||
"status": "queued",
|
||||
"nas_path": f"{NAS_PREFIX}/{slug}/",
|
||||
"preview_url": _preview_url(),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/build/{project}/status")
|
||||
async def build_status(project: str) -> dict[str, Any]:
|
||||
slug = slugify(project)
|
||||
job = JOBS.get(slug) or JOBS.get(project)
|
||||
if not job:
|
||||
proj = PROJECTS_BASE / slug
|
||||
files = _list_files(proj)
|
||||
if files:
|
||||
return {
|
||||
"ok": True,
|
||||
"project": slug,
|
||||
"status": "completed",
|
||||
"message": "Project bestaat (geen actieve job)",
|
||||
"files": files,
|
||||
"preview_url": _preview_url(),
|
||||
"nas_path": f"{NAS_PREFIX}/{slug}/",
|
||||
}
|
||||
return {"ok": True, "project": slug, "status": "idle", "message": "Geen build actief", "files": []}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"project": slug,
|
||||
"job_id": job.get("job_id"),
|
||||
"status": job.get("status"),
|
||||
"message": job.get("message"),
|
||||
"files": job.get("files") or [],
|
||||
"preview_url": job.get("preview_url"),
|
||||
"nas_path": f"{NAS_PREFIX}/{slug}/",
|
||||
"log_tail": job.get("log_tail", ""),
|
||||
"error": job.get("error"),
|
||||
"updated_at": job.get("updated_at"),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", "8798")))
|
||||
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()
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# Installeer Web Builder API op Hermes VM107 (10.4.7.27)
|
||||
set -euo pipefail
|
||||
|
||||
HERMES="${HERMES_SSH:-root@10.4.7.27}"
|
||||
PASS="${HERMES_SSH_PASS:-Foodlinkk#2026}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
echo "=== Hermes Web Builder API → $HERMES ==="
|
||||
|
||||
sshpass -p "$PASS" ssh -o StrictHostKeyChecking=no "$HERMES" 'mkdir -p /opt/webbuilder-api'
|
||||
|
||||
sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \
|
||||
"$SCRIPT_DIR/hermes-webbuilder-api.py" \
|
||||
"$HERMES:/opt/webbuilder-api/main.py"
|
||||
|
||||
sshpass -p "$PASS" ssh -o StrictHostKeyChecking=no "$HERMES" bash -s <<'REMOTE'
|
||||
set -euo pipefail
|
||||
mkdir -p /opt/webbuilder-api
|
||||
python3 -m pip install -q fastapi uvicorn 2>/dev/null || pip3 install -q fastapi uvicorn
|
||||
|
||||
cat > /etc/systemd/system/webbuilder-api.service <<'UNIT'
|
||||
[Unit]
|
||||
Description=Hermes Web Builder API (agy builds)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/webbuilder-api
|
||||
Environment=PORT=8798
|
||||
Environment=WEBBUILDER_PROJECTS=/home/hermes/projects
|
||||
Environment=WEBBUILDER_PREVIEW_HOST=10.4.7.27
|
||||
Environment=WEBBUILDER_PREVIEW_PORT=8080
|
||||
ExecStart=/usr/bin/python3 /opt/webbuilder-api/main.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable webbuilder-api
|
||||
systemctl restart webbuilder-api
|
||||
sleep 2
|
||||
curl -sf http://127.0.0.1:8798/health && echo " webbuilder-api OK"
|
||||
REMOTE
|
||||
|
||||
echo "=== Klaar: http://10.4.7.27:8798/health ==="
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Publish due scheduled social posts."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
COCKPIT_URL = "http://127.0.0.1:8600"
|
||||
TOOLS_URL = "http://127.0.0.1:8700"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{COCKPIT_URL}/api/admin/scheduled-posts/publish-due",
|
||||
data=b"{}",
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
print(json.dumps(data))
|
||||
return 0
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
print("publish-due endpoint not yet available — skipped")
|
||||
return 0
|
||||
print(f"publish failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
except Exception as exc:
|
||||
print(f"publish failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# Voeg webbuilder toe aan Herman orchestrator op VM105
|
||||
set -euo pipefail
|
||||
|
||||
ORCH_HOST="${ORCH_HOST:-aissa@10.4.7.19}"
|
||||
MAIN="$HOME/foodlinkk-ai/herman-orchestrator/main.py"
|
||||
|
||||
ssh -o StrictHostKeyChecking=no "$ORCH_HOST" bash -s <<'REMOTE'
|
||||
set -euo pipefail
|
||||
MAIN=~/foodlinkk-ai/herman-orchestrator/main.py
|
||||
cp "$MAIN" "${MAIN}.bak.$(date +%Y%m%d%H%M)"
|
||||
|
||||
python3 <<'PY'
|
||||
from pathlib import Path
|
||||
p = Path.home() / "foodlinkk-ai/herman-orchestrator/main.py"
|
||||
text = p.read_text()
|
||||
|
||||
if "webbuilder" in text and '"webbuilder"' in text:
|
||||
print("webbuilder already present")
|
||||
else:
|
||||
text = text.replace(
|
||||
' "browser",\n)',
|
||||
' "browser",\n "webbuilder",\n)',
|
||||
)
|
||||
insert = ''' "webbuilder": {
|
||||
"name": "Web Builder",
|
||||
"persona": (
|
||||
"Website development via Hermes/agy on NAS. HTML/CSS/JS sites, "
|
||||
"landing pages, preview servers. Reports back to Herman when done."
|
||||
),
|
||||
"keywords": "website,bouw website,maak website,landingspagina,webbuilder,webpagina,nieuwe site",
|
||||
},
|
||||
'''
|
||||
marker = ' "browser": {'
|
||||
if insert.strip() not in text:
|
||||
text = text.replace(marker, insert + marker, 1)
|
||||
|
||||
text = text.replace(
|
||||
'"keywords": "website,browser,site,monitor,crawl,bidfood,web,url",',
|
||||
'"keywords": "browser,site,monitor,crawl,bidfood,url,concurrent,scrape",',
|
||||
)
|
||||
p.write_text(text)
|
||||
print("patched orchestrator main.py")
|
||||
PY
|
||||
|
||||
# Restart orchestrator if systemd service exists
|
||||
if systemctl is-active herman-orchestrator >/dev/null 2>&1; then
|
||||
sudo systemctl restart herman-orchestrator
|
||||
elif pgrep -f "herman-orchestrator/main.py" >/dev/null; then
|
||||
pkill -f "herman-orchestrator/main.py" || true
|
||||
sleep 1
|
||||
cd ~/foodlinkk-ai/herman-orchestrator && nohup python3 -m uvicorn main:app --host 0.0.0.0 --port 8090 >> /tmp/herman-orchestrator.log 2>&1 &
|
||||
fi
|
||||
sleep 2
|
||||
curl -sf http://127.0.0.1:8090/health | python3 -c "import sys,json; d=json.load(sys.stdin); print('agents:', d.get('agents'))"
|
||||
REMOTE
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
# Push volledige foodlinkk-command-center naar Gitea (VM106).
|
||||
set -euo pipefail
|
||||
|
||||
HOST="${DEPLOY_HOST:-aissa@10.4.7.18}"
|
||||
REASON="${1:-manual-backup}"
|
||||
|
||||
echo "=== Rsync naar VM106 ==="
|
||||
rsync -az --exclude '.git' --exclude '__pycache__' --exclude 'node_modules' \
|
||||
"$(dirname "$0")/../" "${HOST}:~/foodlinkk-command-center/"
|
||||
|
||||
echo "=== Gitea sync via tools-api ==="
|
||||
ssh "$HOST" "docker exec foodlinkk_tools_api python -c \"
|
||||
from app.connectors.gitea_repo_sync import run_gitea_repo_sync
|
||||
import json
|
||||
print(json.dumps(run_gitea_repo_sync('${REASON}'), indent=2))
|
||||
\""
|
||||
|
||||
echo "=== Klaar — Gitea: http://10.4.7.18:3001/aissa/foodlinkk-command-center ==="
|
||||
Reference in New Issue
Block a user