back to scripts

agent-ticket-router.py

python 400 lines secrets redacted

Routes incoming tickets/requests to the agent that owns that domain.

Note Live script from my home-lab server. Tokens, IDs, phone numbers and other secrets have been replaced with placeholders like <WHATSAPP_GROUP_ID> — everything else is the real, running code.
#!/usr/bin/env python3
import datetime
import fcntl
import json
import os
import shutil
import subprocess
import sys
from zoneinfo import ZoneInfo

REPORT_DIR = "/home/lanky/reports"
TICKET_LOG = os.path.join(REPORT_DIR, "agent-tickets.jsonl")
JOB_FILE = os.path.join(REPORT_DIR, "service-desk-jobs.json")
COUNTER_FILE = os.path.join(REPORT_DIR, "service-desk-job-counter")
SCRIPT_DIR = "/home/lanky/scripts"
GROUP_ID = os.environ.get("LANKY_ALERT_GROUP_ID", "<WHATSAPP_GROUP_ID>")
MEDIA_DIR = "/home/lanky/.openclaw/media/service-desk"
LOCAL_TZ = ZoneInfo("Australia/Adelaide")

AGENTS = {
    "all": "all-agents",
    "media": "media",
    "docker": "docker",
    "dns": "dns",
    "cyber": "cyber",
    "backup": "backup",
    "network": "network",
    "updates": "updates",
    "nas": "nas", "ssl": "ssl",
}

ETA_MINUTES = {
    "all-agents": 30,
    "run-area-agent": 15,
    "media-file-health": 24 * 60,
    "nas-media-review": 24 * 60,
    "cyber-cve-scan": 6 * 60,
    "cyber-av-scan": 2 * 60,
}


def now_utc() -> datetime.datetime:
    return datetime.datetime.now(datetime.timezone.utc)


def norm(text: str) -> str:
    return " ".join(text.lower().split())


def load_jobs() -> dict:
    try:
        with open(JOB_FILE, encoding="utf-8") as handle:
            value = json.load(handle)
            return value if isinstance(value, dict) else {}
    except (OSError, ValueError):
        return {}


def save_jobs(jobs: dict) -> None:
    temporary = JOB_FILE + ".tmp"
    with open(temporary, "w", encoding="utf-8") as handle:
        json.dump(jobs, handle, indent=2, ensure_ascii=True)
        handle.write("\n")
    os.replace(temporary, JOB_FILE)


def append_log(record: dict) -> None:
    with open(TICKET_LOG, "a", encoding="utf-8") as handle:
        handle.write(json.dumps(record, ensure_ascii=True) + "\n")


def with_job_lock(callback):
    os.makedirs(REPORT_DIR, exist_ok=True)
    with open(JOB_FILE + ".lock", "a+", encoding="ascii") as lock:
        fcntl.flock(lock, fcntl.LOCK_EX)
        return callback()


def create_job(agent_key: str, request: str, action: str) -> dict:
    def create() -> dict:
        try:
            with open(COUNTER_FILE, encoding="ascii") as counter:
                current = int(counter.read().strip())
        except (OSError, ValueError):
            current = 1000
        job_id = current + 1
        with open(COUNTER_FILE, "w", encoding="ascii") as counter:
            counter.write(f"{job_id}\n")

        created = now_utc()
        eta_minutes = ETA_MINUTES[action]
        job = {
            "id": job_id,
            "created": created.isoformat(),
            "updated": created.isoformat(),
            "eta": (created + datetime.timedelta(minutes=eta_minutes)).isoformat(),
            "eta_minutes": eta_minutes,
            "agent": AGENTS[agent_key],
            "level": 2,
            "request": request,
            "action": action,
            "status": "queued",
        }
        jobs = load_jobs()
        jobs[str(job_id)] = job
        save_jobs(jobs)
        append_log(job)
        return job

    return with_job_lock(create)


def update_job(job_id: int, status: str, **values) -> dict:
    def update() -> dict:
        jobs = load_jobs()
        job = jobs.get(str(job_id))
        if not job:
            raise KeyError(f"Unknown job ID {job_id}")
        job.update(values)
        job["status"] = status
        job["updated"] = now_utc().isoformat()
        jobs[str(job_id)] = job
        save_jobs(jobs)
        append_log(job)
        return job

    return with_job_lock(update)


def eta_text(job: dict) -> str:
    minutes = int(job["eta_minutes"])
    deadline = datetime.datetime.fromisoformat(job["eta"]).astimezone(LOCAL_TZ)
    if minutes >= 1440 and minutes % 1440 == 0:
        duration = f"{minutes // 1440} day"
    elif minutes >= 60 and minutes % 60 == 0:
        duration = f"{minutes // 60} hours"
    else:
        duration = f"{minutes} minutes"
    return f"{duration}; by {deadline.strftime('%I:%M %p, %d %b %Y').lstrip('0')} Adelaide time"


def send_completion(job: dict, result: str, report_path: str = "") -> tuple[bool, str]:
    state = "COMPLETE" if job["status"] == "completed" else "FAILED"
    result = result.strip() or "No result details were returned."
    if len(result) > 3500:
        result = result[:3500].rstrip() + "\n[Result shortened; see attached report.]"
    completed_at = datetime.datetime.now(LOCAL_TZ).strftime("%I:%M %p, %d %b %Y").lstrip("0")
    message = (
        f"JOB ID {job['id']} {state}\n"
        f"Task: {job['request']}\n"
        f"Owner: {job['agent']}\n"
        f"Completed: {completed_at}\n\n"
        f"Results:\n{result}"
    )
    command = [
        "openclaw", "message", "send",
        "--channel", "whatsapp", "--account", "default",
        "--target", GROUP_ID, "--message", message,
    ]
    if report_path and os.path.isfile(report_path):
        os.makedirs(MEDIA_DIR, exist_ok=True)
        extension = os.path.splitext(report_path)[1] or ".txt"
        staged_report = os.path.join(MEDIA_DIR, f"job-{job['id']}-report{extension}")
        shutil.copy2(report_path, staged_report)
        command.extend(["--media", staged_report, "--force-document"])
    completed = subprocess.run(
        command,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
        timeout=180,
        check=False,
    )
    return completed.returncode == 0, completed.stdout.strip()


def run_area_agent(agent_id: str) -> tuple[int, str, str]:
    unit = f"lanky-agent@{agent_id}.service"
    try:
        completed = subprocess.run(
            ["sudo", "-n", "/usr/bin/systemctl", "start", unit],
            text=True,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            timeout=420,
            check=False,
        )
    except subprocess.TimeoutExpired:
        return 124, "The area agent did not finish within seven minutes.", ""
    if completed.returncode:
        detail = completed.stdout.strip() or "systemd did not return details"
        return completed.returncode, f"The area agent could not be started: {detail}", ""
    report_path = os.path.join(REPORT_DIR, f"agent-{agent_id}-latest.md")
    try:
        with open(report_path, encoding="utf-8", errors="replace") as report:
            content = report.read().strip()
    except OSError as exc:
        return 1, f"The area agent ran but its report could not be read: {exc}", ""
    if len(content) < 80 or "status" not in content.lower():
        return 1, "The area agent returned an invalid report. L3 has been notified.", report_path
    return 0, content, report_path


def run_all_agents() -> tuple[int, str, str]:
    reports = []
    failures = 0
    for agent_key in ("media", "docker", "dns", "cyber", "backup", "network", "updates"):
        agent_id = AGENTS[agent_key]
        status, report, _ = run_area_agent(agent_id)
        reports.append(f"=== {agent_id} ===\n{report}")
        failures += int(status != 0)
    return (1 if failures else 0), "\n\n".join(reports), ""


def run_external(action: str, job_id: int) -> tuple[int, str, str]:
    env = os.environ.copy()
    env["SERVICE_DESK_JOB_ID"] = str(job_id)
    commands = {
        "media-file-health": (["media-english-check.sh", "--worker"], 25 * 60 * 60),
        "nas-media-review": (["nas-media-review.sh", "--worker"], 25 * 60 * 60),
        "cyber-cve-scan": (["cyber-cve-scan.sh"], 7 * 60 * 60),
        "cyber-av-scan": (["cyber-av-scan.sh", "quick"], 3 * 60 * 60),
    }
    args, timeout = commands[action]
    try:
        completed = subprocess.run(
            [os.path.join(SCRIPT_DIR, args[0]), *args[1:]],
            env=env,
            text=True,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            timeout=timeout,
            check=False,
        )
    except subprocess.TimeoutExpired:
        return 124, f"The {action} job exceeded its execution window.", ""
    report_paths = {
        "media-file-health": os.path.join(REPORT_DIR, "media-english-check-latest.txt"),
        "nas-media-review": os.path.join(REPORT_DIR, "nas-media-check-latest.txt"),
        "cyber-cve-scan": os.path.join(REPORT_DIR, "cyber-cve-scan-latest.txt"),
        "cyber-av-scan": os.path.join(REPORT_DIR, "cyber-av-scan-latest.txt"),
    }
    report_path = report_paths[action]
    if os.path.isfile(report_path):
        with open(report_path, encoding="utf-8", errors="replace") as report:
            result = report.read().strip()
    else:
        result = completed.stdout.strip()
    return completed.returncode, result, report_path


def worker(job_id: int) -> int:
    job = load_jobs().get(str(job_id))
    if not job:
        return 2
    update_job(job_id, "running", started=now_utc().isoformat())
    action = job["action"]
    if action == "all-agents":
        code, result, report_path = run_all_agents()
    elif action == "run-area-agent":
        code, result, report_path = run_area_agent(job["agent"])
    else:
        code, result, report_path = run_external(action, job_id)
    status = "completed" if code == 0 else "failed"
    job = update_job(
        job_id,
        status,
        exit_code=code,
        result=result[:12000],
        report_path=report_path,
        completed=now_utc().isoformat(),
    )
    sent, delivery_detail = send_completion(job, result, report_path)
    update_job(
        job_id,
        status,
        notification_status="sent" if sent else "failed",
        notification_detail=delivery_detail[-2000:],
    )
    return code


def choose_action(agent_key: str, text: str) -> str:
    if agent_key == "all":
        return "all-agents"
    if agent_key == "media" and any(
        term in text
        for term in [
            "health check", "healthcheck", "english", "language", "playable",
            "codec", "audio", "movie", "movies", "media files",
        ]
    ):
        return "media-file-health"
    if agent_key == "media" and any(
        term in text for term in ["duplicate", "duplicates", "same show", "junk"]
    ):
        return "nas-media-review"
    if agent_key == "cyber" and any(
        term in text for term in ["cve", "vulnerability", "vulnerabilities", "scan images", "docker image"]
    ):
        return "cyber-cve-scan"
    if agent_key == "cyber" and any(
        term in text for term in ["virus", "malware", "clamav", "av scan", "antivirus"]
    ):
        return "cyber-av-scan"
    return "run-area-agent"


def show_jobs(job_id: str = "") -> int:
    jobs = load_jobs()
    if job_id:
        job = jobs.get(job_id)
        if not job:
            print(f"No Service Desk job exists with ID {job_id}.")
            return 0
        print(
            f"JOB ID {job['id']}\n"
            f"Status: {job['status']}\n"
            f"Task: {job['request']}\n"
            f"Owner: {job['agent']}\n"
            f"ETA: {eta_text(job)}"
        )
        if job.get("result"):
            print(f"\nResults:\n{job['result']}")
        return 0
    active = [job for job in jobs.values() if job.get("status") in {"queued", "running"}]
    if not active:
        print("There are no active Service Desk jobs.")
        return 0
    for job in sorted(active, key=lambda item: item["id"]):
        print(
            f"ID {job['id']} | {job['status']} | {job['agent']} | "
            f"ETA {eta_text(job)} | {job['request']}"
        )
    return 0


def retry_notification(job_id: int) -> int:
    job = load_jobs().get(str(job_id))
    if not job:
        print(f"No Service Desk job exists with ID {job_id}.")
        return 2
    sent, detail = send_completion(
        job,
        job.get("result", "No result details were stored."),
        job.get("report_path", ""),
    )
    update_job(
        job_id,
        job["status"],
        notification_status="sent" if sent else "failed",
        notification_detail=detail[-2000:],
    )
    print(f"Job {job_id} WhatsApp notification: {'sent' if sent else 'failed'}")
    if detail:
        print(detail)
    return 0 if sent else 1


def main() -> int:
    if len(sys.argv) >= 3 and sys.argv[1] == "--worker":
        return worker(int(sys.argv[2]))
    if len(sys.argv) >= 3 and sys.argv[1] == "--notify":
        return retry_notification(int(sys.argv[2]))
    if len(sys.argv) >= 2 and sys.argv[1] == "--list":
        return show_jobs(sys.argv[2] if len(sys.argv) > 2 else "")
    if len(sys.argv) < 3:
        print(
            "Usage: agent-ticket-router.py all|media|home-assistant|ops-nas|cyber <request>",
            file=sys.stderr,
        )
        return 2

    agent_key = sys.argv[1]
    request = " ".join(sys.argv[2:]).strip()
    if agent_key not in AGENTS:
        print(f"Unknown agent: {agent_key}", file=sys.stderr)
        return 2
    action = choose_action(agent_key, norm(request))
    job = create_job(agent_key, request, action)
    log_path = os.path.join(REPORT_DIR, f"service-desk-job-{job['id']}.log")
    with open(log_path, "ab") as log:
        subprocess.Popen(
            [sys.executable, __file__, "--worker", str(job["id"])],
            stdout=log,
            stderr=subprocess.STDOUT,
            start_new_session=True,
        )
    print("JOB LOGGED")
    print(f"ID: {job['id']}")
    print(f"Task: {job['request']}")
    print(f"Owner: {job['agent']} (level {job['level']})")
    print(f"ETA: {eta_text(job)}")
    print("Status: queued")
    print("No confirmation is required. I will send the results on WhatsApp when it completes.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

back to scripts