/ hermes_cli / webhook.py
webhook.py
1 """hermes webhook — manage dynamic webhook subscriptions from the CLI. 2 3 Usage: 4 hermes webhook subscribe <name> [options] 5 hermes webhook list 6 hermes webhook remove <name> 7 hermes webhook test <name> [--payload '{"key": "value"}'] 8 9 Subscriptions persist to ~/.hermes/webhook_subscriptions.json and are 10 hot-reloaded by the webhook adapter without a gateway restart. 11 """ 12 13 import json 14 import re 15 import secrets 16 import time 17 from pathlib import Path 18 from typing import Dict 19 20 from hermes_constants import display_hermes_home 21 from utils import atomic_replace 22 from hermes_cli.config import cfg_get 23 24 25 _SUBSCRIPTIONS_FILENAME = "webhook_subscriptions.json" 26 27 28 def _hermes_home() -> Path: 29 from hermes_constants import get_hermes_home 30 return get_hermes_home() 31 32 33 def _subscriptions_path() -> Path: 34 return _hermes_home() / _SUBSCRIPTIONS_FILENAME 35 36 37 def _load_subscriptions() -> Dict[str, dict]: 38 path = _subscriptions_path() 39 if not path.exists(): 40 return {} 41 try: 42 data = json.loads(path.read_text(encoding="utf-8")) 43 return data if isinstance(data, dict) else {} 44 except Exception: 45 return {} 46 47 48 def _save_subscriptions(subs: Dict[str, dict]) -> None: 49 path = _subscriptions_path() 50 path.parent.mkdir(parents=True, exist_ok=True) 51 tmp_path = path.with_suffix(".tmp") 52 tmp_path.write_text( 53 json.dumps(subs, indent=2, ensure_ascii=False), 54 encoding="utf-8", 55 ) 56 atomic_replace(tmp_path, path) 57 58 59 def _get_webhook_config() -> dict: 60 """Load webhook platform config. Returns {} if not configured.""" 61 try: 62 from hermes_cli.config import load_config 63 cfg = load_config() 64 return cfg_get(cfg, "platforms", "webhook", default={}) 65 except Exception: 66 return {} 67 68 69 def _is_webhook_enabled() -> bool: 70 return bool(_get_webhook_config().get("enabled")) 71 72 73 def _get_webhook_base_url() -> str: 74 wh = _get_webhook_config().get("extra", {}) 75 host = wh.get("host", "0.0.0.0") 76 port = wh.get("port", 8644) 77 display_host = "localhost" if host == "0.0.0.0" else host 78 return f"http://{display_host}:{port}" 79 80 81 def _setup_hint() -> str: 82 _dhh = display_hermes_home() 83 return f""" 84 Webhook platform is not enabled. To set it up: 85 86 1. Run the gateway setup wizard: 87 hermes gateway setup 88 89 2. Or manually add to {_dhh}/config.yaml: 90 platforms: 91 webhook: 92 enabled: true 93 extra: 94 host: "0.0.0.0" 95 port: 8644 96 secret: "your-global-hmac-secret" 97 98 3. Or set environment variables in {_dhh}/.env: 99 WEBHOOK_ENABLED=true 100 WEBHOOK_PORT=8644 101 WEBHOOK_SECRET=your-global-secret 102 103 Then start the gateway: hermes gateway run 104 """ 105 106 107 def _require_webhook_enabled() -> bool: 108 """Check webhook is enabled. Print setup guide and return False if not.""" 109 if _is_webhook_enabled(): 110 return True 111 print(_setup_hint()) 112 return False 113 114 115 def webhook_command(args): 116 """Entry point for 'hermes webhook' subcommand.""" 117 sub = getattr(args, "webhook_action", None) 118 119 if not sub: 120 print("Usage: hermes webhook {subscribe|list|remove|test}") 121 print("Run 'hermes webhook --help' for details.") 122 return 123 124 if not _require_webhook_enabled(): 125 return 126 127 if sub in ("subscribe", "add"): 128 _cmd_subscribe(args) 129 elif sub in ("list", "ls"): 130 _cmd_list(args) 131 elif sub in ("remove", "rm"): 132 _cmd_remove(args) 133 elif sub == "test": 134 _cmd_test(args) 135 136 137 def _cmd_subscribe(args): 138 name = args.name.strip().lower().replace(" ", "-") 139 if not re.match(r'^[a-z0-9][a-z0-9_-]*$', name): 140 print(f"Error: Invalid name '{name}'. Use lowercase alphanumeric with hyphens/underscores.") 141 return 142 143 subs = _load_subscriptions() 144 is_update = name in subs 145 146 secret = args.secret or secrets.token_urlsafe(32) 147 events = [e.strip() for e in args.events.split(",")] if args.events else [] 148 149 route = { 150 "description": args.description or f"Agent-created subscription: {name}", 151 "events": events, 152 "secret": secret, 153 "prompt": args.prompt or "", 154 "skills": [s.strip() for s in args.skills.split(",")] if args.skills else [], 155 "deliver": args.deliver or "log", 156 "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), 157 } 158 159 if getattr(args, "deliver_only", False): 160 if route["deliver"] == "log": 161 print( 162 "Error: --deliver-only requires --deliver to be a real target " 163 "(telegram, discord, slack, github_comment, etc.) — not 'log'." 164 ) 165 return 166 route["deliver_only"] = True 167 168 if args.deliver_chat_id: 169 route["deliver_extra"] = {"chat_id": args.deliver_chat_id} 170 171 subs[name] = route 172 _save_subscriptions(subs) 173 174 base_url = _get_webhook_base_url() 175 status = "Updated" if is_update else "Created" 176 177 print(f"\n {status} webhook subscription: {name}") 178 print(f" URL: {base_url}/webhooks/{name}") 179 print(f" Secret: {secret}") 180 if events: 181 print(f" Events: {', '.join(events)}") 182 else: 183 print(" Events: (all)") 184 print(f" Deliver: {route['deliver']}") 185 if route.get("deliver_only"): 186 print(" Mode: direct delivery (no agent, zero LLM cost)") 187 if route.get("prompt"): 188 prompt_preview = route["prompt"][:80] + ("..." if len(route["prompt"]) > 80 else "") 189 label = "Message" if route.get("deliver_only") else "Prompt" 190 print(f" {label}: {prompt_preview}") 191 print(f"\n Configure your service to POST to the URL above.") 192 print(f" Use the secret for HMAC-SHA256 signature validation.") 193 print(f" The gateway must be running to receive events (hermes gateway run).\n") 194 195 196 def _cmd_list(args): 197 subs = _load_subscriptions() 198 if not subs: 199 print(" No dynamic webhook subscriptions.") 200 print(" Create one with: hermes webhook subscribe <name>") 201 return 202 203 base_url = _get_webhook_base_url() 204 print(f"\n {len(subs)} webhook subscription(s):\n") 205 for name, route in subs.items(): 206 events = ", ".join(route.get("events", [])) or "(all)" 207 deliver = route.get("deliver", "log") 208 if route.get("deliver_only"): 209 deliver = f"{deliver} (direct — no agent)" 210 desc = route.get("description", "") 211 print(f" ◆ {name}") 212 if desc: 213 print(f" {desc}") 214 print(f" URL: {base_url}/webhooks/{name}") 215 print(f" Events: {events}") 216 print(f" Deliver: {deliver}") 217 print() 218 219 220 def _cmd_remove(args): 221 name = args.name.strip().lower() 222 subs = _load_subscriptions() 223 224 if name not in subs: 225 print(f" No subscription named '{name}'.") 226 print(" Note: Static routes from config.yaml cannot be removed here.") 227 return 228 229 del subs[name] 230 _save_subscriptions(subs) 231 print(f" Removed webhook subscription: {name}") 232 233 234 def _cmd_test(args): 235 """Send a test POST to a webhook route.""" 236 name = args.name.strip().lower() 237 subs = _load_subscriptions() 238 239 if name not in subs: 240 print(f" No subscription named '{name}'.") 241 return 242 243 route = subs[name] 244 secret = route.get("secret", "") 245 base_url = _get_webhook_base_url() 246 url = f"{base_url}/webhooks/{name}" 247 248 payload = args.payload or '{"test": true, "event_type": "test", "message": "Hello from hermes webhook test"}' 249 250 import hmac 251 import hashlib 252 sig = "sha256=" + hmac.new( 253 secret.encode(), payload.encode(), hashlib.sha256 254 ).hexdigest() 255 256 print(f" Sending test POST to {url}") 257 try: 258 import urllib.request 259 req = urllib.request.Request( 260 url, 261 data=payload.encode(), 262 headers={ 263 "Content-Type": "application/json", 264 "X-Hub-Signature-256": sig, 265 "X-GitHub-Event": "test", 266 }, 267 method="POST", 268 ) 269 with urllib.request.urlopen(req, timeout=10) as resp: 270 body = resp.read().decode() 271 print(f" Response ({resp.status}): {body}") 272 except Exception as e: 273 print(f" Error: {e}") 274 print(" Is the gateway running? (hermes gateway run)")