Spaces:
Paused
Paused
| """hermes webhook — manage dynamic webhook subscriptions from the CLI. | |
| Usage: | |
| hermes webhook subscribe <name> [options] | |
| hermes webhook list | |
| hermes webhook remove <name> | |
| hermes webhook test <name> [--payload '{"key": "value"}'] | |
| Subscriptions persist to ~/.hermes/webhook_subscriptions.json and are | |
| hot-reloaded by the webhook adapter without a gateway restart. | |
| """ | |
| import json | |
| import os | |
| import re | |
| import secrets | |
| import time | |
| from pathlib import Path | |
| from typing import Dict | |
| from hermes_constants import display_hermes_home | |
| _SUBSCRIPTIONS_FILENAME = "webhook_subscriptions.json" | |
| def _hermes_home() -> Path: | |
| from hermes_constants import get_hermes_home | |
| return get_hermes_home() | |
| def _subscriptions_path() -> Path: | |
| return _hermes_home() / _SUBSCRIPTIONS_FILENAME | |
| def _load_subscriptions() -> Dict[str, dict]: | |
| path = _subscriptions_path() | |
| if not path.exists(): | |
| return {} | |
| try: | |
| data = json.loads(path.read_text(encoding="utf-8")) | |
| return data if isinstance(data, dict) else {} | |
| except Exception: | |
| return {} | |
| def _save_subscriptions(subs: Dict[str, dict]) -> None: | |
| path = _subscriptions_path() | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| tmp_path = path.with_suffix(".tmp") | |
| tmp_path.write_text( | |
| json.dumps(subs, indent=2, ensure_ascii=False), | |
| encoding="utf-8", | |
| ) | |
| os.replace(str(tmp_path), str(path)) | |
| def _get_webhook_config() -> dict: | |
| """Load webhook platform config. Returns {} if not configured.""" | |
| try: | |
| from hermes_cli.config import load_config | |
| cfg = load_config() | |
| return cfg.get("platforms", {}).get("webhook", {}) | |
| except Exception: | |
| return {} | |
| def _is_webhook_enabled() -> bool: | |
| return bool(_get_webhook_config().get("enabled")) | |
| def _get_webhook_base_url() -> str: | |
| wh = _get_webhook_config().get("extra", {}) | |
| host = wh.get("host", "0.0.0.0") | |
| port = wh.get("port", 8644) | |
| display_host = "localhost" if host == "0.0.0.0" else host | |
| return f"http://{display_host}:{port}" | |
| def _setup_hint() -> str: | |
| _dhh = display_hermes_home() | |
| return f""" | |
| Webhook platform is not enabled. To set it up: | |
| 1. Run the gateway setup wizard: | |
| hermes gateway setup | |
| 2. Or manually add to {_dhh}/config.yaml: | |
| platforms: | |
| webhook: | |
| enabled: true | |
| extra: | |
| host: "0.0.0.0" | |
| port: 8644 | |
| secret: "your-global-hmac-secret" | |
| 3. Or set environment variables in {_dhh}/.env: | |
| WEBHOOK_ENABLED=true | |
| WEBHOOK_PORT=8644 | |
| WEBHOOK_SECRET=your-global-secret | |
| Then start the gateway: hermes gateway run | |
| """ | |
| def _require_webhook_enabled() -> bool: | |
| """Check webhook is enabled. Print setup guide and return False if not.""" | |
| if _is_webhook_enabled(): | |
| return True | |
| print(_setup_hint()) | |
| return False | |
| def webhook_command(args): | |
| """Entry point for 'hermes webhook' subcommand.""" | |
| sub = getattr(args, "webhook_action", None) | |
| if not sub: | |
| print("Usage: hermes webhook {subscribe|list|remove|test}") | |
| print("Run 'hermes webhook --help' for details.") | |
| return | |
| if not _require_webhook_enabled(): | |
| return | |
| if sub in ("subscribe", "add"): | |
| _cmd_subscribe(args) | |
| elif sub in ("list", "ls"): | |
| _cmd_list(args) | |
| elif sub in ("remove", "rm"): | |
| _cmd_remove(args) | |
| elif sub == "test": | |
| _cmd_test(args) | |
| def _cmd_subscribe(args): | |
| name = args.name.strip().lower().replace(" ", "-") | |
| if not re.match(r'^[a-z0-9][a-z0-9_-]*$', name): | |
| print(f"Error: Invalid name '{name}'. Use lowercase alphanumeric with hyphens/underscores.") | |
| return | |
| subs = _load_subscriptions() | |
| is_update = name in subs | |
| secret = args.secret or secrets.token_urlsafe(32) | |
| events = [e.strip() for e in args.events.split(",")] if args.events else [] | |
| route = { | |
| "description": args.description or f"Agent-created subscription: {name}", | |
| "events": events, | |
| "secret": secret, | |
| "prompt": args.prompt or "", | |
| "skills": [s.strip() for s in args.skills.split(",")] if args.skills else [], | |
| "deliver": args.deliver or "log", | |
| "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), | |
| } | |
| if args.deliver_chat_id: | |
| route["deliver_extra"] = {"chat_id": args.deliver_chat_id} | |
| subs[name] = route | |
| _save_subscriptions(subs) | |
| base_url = _get_webhook_base_url() | |
| status = "Updated" if is_update else "Created" | |
| print(f"\n {status} webhook subscription: {name}") | |
| print(f" URL: {base_url}/webhooks/{name}") | |
| print(f" Secret: {secret}") | |
| if events: | |
| print(f" Events: {', '.join(events)}") | |
| else: | |
| print(" Events: (all)") | |
| print(f" Deliver: {route['deliver']}") | |
| if route.get("prompt"): | |
| prompt_preview = route["prompt"][:80] + ("..." if len(route["prompt"]) > 80 else "") | |
| print(f" Prompt: {prompt_preview}") | |
| print(f"\n Configure your service to POST to the URL above.") | |
| print(f" Use the secret for HMAC-SHA256 signature validation.") | |
| print(f" The gateway must be running to receive events (hermes gateway run).\n") | |
| def _cmd_list(args): | |
| subs = _load_subscriptions() | |
| if not subs: | |
| print(" No dynamic webhook subscriptions.") | |
| print(" Create one with: hermes webhook subscribe <name>") | |
| return | |
| base_url = _get_webhook_base_url() | |
| print(f"\n {len(subs)} webhook subscription(s):\n") | |
| for name, route in subs.items(): | |
| events = ", ".join(route.get("events", [])) or "(all)" | |
| deliver = route.get("deliver", "log") | |
| desc = route.get("description", "") | |
| print(f" ◆ {name}") | |
| if desc: | |
| print(f" {desc}") | |
| print(f" URL: {base_url}/webhooks/{name}") | |
| print(f" Events: {events}") | |
| print(f" Deliver: {deliver}") | |
| print() | |
| def _cmd_remove(args): | |
| name = args.name.strip().lower() | |
| subs = _load_subscriptions() | |
| if name not in subs: | |
| print(f" No subscription named '{name}'.") | |
| print(" Note: Static routes from config.yaml cannot be removed here.") | |
| return | |
| del subs[name] | |
| _save_subscriptions(subs) | |
| print(f" Removed webhook subscription: {name}") | |
| def _cmd_test(args): | |
| """Send a test POST to a webhook route.""" | |
| name = args.name.strip().lower() | |
| subs = _load_subscriptions() | |
| if name not in subs: | |
| print(f" No subscription named '{name}'.") | |
| return | |
| route = subs[name] | |
| secret = route.get("secret", "") | |
| base_url = _get_webhook_base_url() | |
| url = f"{base_url}/webhooks/{name}" | |
| payload = args.payload or '{"test": true, "event_type": "test", "message": "Hello from hermes webhook test"}' | |
| import hmac | |
| import hashlib | |
| sig = "sha256=" + hmac.new( | |
| secret.encode(), payload.encode(), hashlib.sha256 | |
| ).hexdigest() | |
| print(f" Sending test POST to {url}") | |
| try: | |
| import urllib.request | |
| req = urllib.request.Request( | |
| url, | |
| data=payload.encode(), | |
| headers={ | |
| "Content-Type": "application/json", | |
| "X-Hub-Signature-256": sig, | |
| "X-GitHub-Event": "test", | |
| }, | |
| method="POST", | |
| ) | |
| with urllib.request.urlopen(req, timeout=10) as resp: | |
| body = resp.read().decode() | |
| print(f" Response ({resp.status}): {body}") | |
| except Exception as e: | |
| print(f" Error: {e}") | |
| print(" Is the gateway running? (hermes gateway run)") | |