feat: send song requests to Hermes via webhook

- Add Hermes webhook settings form in /admin/settings (URL + HMAC secret)
- Add /admin/request/<id>/send-to-hermes action
- POST request data to Hermes with V2 HMAC signature and callback URL
- Hermes can POST the generated MusicGPT prompt back to /api/prompt/<id>
- Add test button, update README workflow, bump version to 0.9.0
This commit is contained in:
Troll (Hermes Agent) 2026-08-24 23:01:33 +00:00
parent 3c99378044
commit 2f5050b29f
6 changed files with 199 additions and 4 deletions

View file

@ -409,6 +409,97 @@ def get_ntfy_config():
}
def get_hermes_webhook_config():
"""Return the Hermes webhook URL and HMAC secret from runtime settings."""
cfg = load_booth_settings()
return {
'url': cfg.get('hermes_webhook_url', '').strip(),
'secret': decrypt_value(cfg.get('hermes_webhook_secret', '')) or '',
}
def set_hermes_webhook_config(url, secret):
"""Persist Hermes webhook URL and secret (encrypted) to runtime settings."""
cfg = load_booth_settings()
cfg['hermes_webhook_url'] = url.strip().rstrip('/')
if secret:
cfg['hermes_webhook_secret'] = encrypt_value(secret)
save_booth_settings(cfg)
def send_hermes_webhook(rid, req, callback_url=None):
"""
POST a song request payload to the configured Hermes webhook.
Returns (success: bool, message: str). On success, Hermes receives the
customer data and can generate a Suno/MusicGPT prompt. If callback_url is
provided, Hermes can POST the generated prompt directly back to
/api/prompt/<rid>.
"""
cfg = get_hermes_webhook_config()
url = cfg.get('url', '')
secret = cfg.get('secret', '')
if not url or not secret:
return False, 'Hermes webhook is not configured in /admin/settings'
style_genre = req.get('style_genre') or ''
style_parts = [p.strip() for p in style_genre.split(',') if p.strip()]
style_sentence = ''
if style_parts:
parts = []
if style_parts[0]:
parts.append(f"{style_parts[0]}-era")
if len(style_parts) > 1 and style_parts[1]:
parts.append(style_parts[1])
if len(style_parts) > 2:
parts.append(f"with {', '.join(style_parts[2:])} influences")
style_sentence = ' '.join(parts)
def _bool(value):
if isinstance(value, bool):
return value
return bool(int(value or 0))
payload = {
'event_type': 'song_request',
'request_id': rid,
'email': req.get('email', ''),
'name': req.get('name', ''),
'pronouns': req.get('pronouns', ''),
'hobbies': req.get('hobbies', ''),
'notable_facts': req.get('notable_facts', ''),
'style_genre': style_sentence or style_genre,
'vocal_gender': req.get('vocal_gender', ''),
'extra_requests': req.get('extra_requests', ''),
'stems_interest': _bool(req.get('stems_interest')),
'is_revision': bool(req.get('revision_count', 0)) and bool(req.get('revision_note')),
'revision_count': int(req.get('revision_count') or 0),
'revision_note': req.get('revision_note', ''),
'previous_title': req.get('suno_title', ''),
'previous_style': req.get('suno_style', ''),
'previous_lyrics': req.get('suno_lyrics', ''),
'callback_url': callback_url or '',
}
body = json.dumps(payload, separators=(',', ':')).encode('utf-8')
timestamp = str(int(time.time()))
sig_data = f"{timestamp}.{body.decode('utf-8')}"
signature = hmac.new(secret.encode('utf-8'), sig_data.encode('utf-8'), hashlib.sha256).hexdigest()
headers = {
'Content-Type': 'application/json',
'X-Webhook-Signature-V2': signature,
'X-Webhook-Timestamp': timestamp,
}
try:
resp = requests.post(url, data=body, headers=headers, timeout=15)
if resp.status_code in (200, 202):
return True, f"Sent to Hermes (HTTP {resp.status_code})"
return False, f"Hermes webhook returned HTTP {resp.status_code}: {resp.text[:200]}"
except Exception as e:
return False, f"Failed to reach Hermes webhook: {e}"
def send_ntfy(message, title='Theme Song Booth', priority='default', tags='bell'):
"""Send a push notification to the configured ntfy topic, if configured."""
ntfy = get_ntfy_config()