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

@ -1,6 +1,6 @@
# Theme Song Booth (MusicGPT Edition) # Theme Song Booth (MusicGPT Edition)
![Version](https://img.shields.io/badge/version-v0.8.9-blue) ![Version](https://img.shields.io/badge/version-v0.9.0-blue)
A Flask web application for running a convention booth where visitors request a custom AI-generated theme song. Operators manage the queue from an admin dashboard, generate prompts via Hermes, queue generations through the MusicGPT API, and deliver final songs by email. A Flask web application for running a convention booth where visitors request a custom AI-generated theme song. Operators manage the queue from an admin dashboard, generate prompts via Hermes, queue generations through the MusicGPT API, and deliver final songs by email.

View file

@ -1 +1 @@
0.8.9 0.9.0

70
app.py
View file

@ -77,6 +77,7 @@ from helpers import (
download_album_cover, download_album_cover,
process_stems_to_gokapi, process_stems_to_gokapi,
get_ntfy_config, send_ntfy, get_ntfy_config, send_ntfy,
get_hermes_webhook_config, set_hermes_webhook_config, send_hermes_webhook,
sign_prompt_callback, verify_prompt_callback, build_prompt_callback_url, sign_prompt_callback, verify_prompt_callback, build_prompt_callback_url,
send_email, build_signature_images, send_email, build_signature_images,
get_booth_open, get_booth_open,
@ -1503,6 +1504,38 @@ def admin_request(rid):
) )
@app.route('/admin/request/<int:rid>/send-to-hermes', methods=['POST'])
def send_to_hermes(rid):
"""
Operator action: push the current request data to the configured Hermes webhook.
Hermes will generate a MusicGPT prompt and POST it back to the callback URL.
"""
redir = require_admin()
if redir:
return redir
req = get_request_by_id(rid)
if not req:
abort(404)
if req['status'] not in ('pending', 'revisions_requested'):
flash('Request must be pending or awaiting revision to send to Hermes.', 'error')
return redirect(url_for('admin_request', rid=rid))
cfg = get_hermes_webhook_config()
if not cfg['url'] or not cfg['secret']:
flash('Hermes webhook is not configured. Set URL and secret in /admin/settings.', 'error')
return redirect(url_for('admin_request', rid=rid))
callback_url = build_prompt_callback_url(rid)
ok, msg = send_hermes_webhook(rid, req, callback_url=callback_url)
if ok:
flash(f'Sent to Hermes. {msg}', 'success')
else:
flash(f'Failed to send to Hermes: {msg}', 'error')
return redirect(url_for('admin_request', rid=rid))
@app.route('/admin/request/<int:rid>/delete', methods=['POST']) @app.route('/admin/request/<int:rid>/delete', methods=['POST'])
def admin_delete_request(rid): def admin_delete_request(rid):
"""Delete a single request and remove its uploaded MP3 files.""" """Delete a single request and remove its uploaded MP3 files."""
@ -1850,6 +1883,42 @@ def admin_settings():
flash('ntfy notification settings saved.', 'success') flash('ntfy notification settings saved.', 'success')
return redirect(url_for('admin_settings')) return redirect(url_for('admin_settings'))
elif action == 'save_hermes_webhook':
# Update Hermes webhook URL and secret from the settings form.
webhook_url = request.form.get('hermes_webhook_url', '').strip().rstrip('/')
webhook_secret = request.form.get('hermes_webhook_secret', '').strip()
set_hermes_webhook_config(webhook_url, webhook_secret)
flash('Hermes webhook settings saved.', 'success')
return redirect(url_for('admin_settings'))
elif action == 'send_test_hermes_webhook':
# Send a test request to the configured Hermes webhook.
cfg = get_hermes_webhook_config()
if not cfg['url'] or not cfg['secret']:
flash('Configure Hermes webhook URL and secret first.', 'error')
return redirect(url_for('admin_settings'))
ok, msg = send_hermes_webhook(0, {
'email': 'test@example.com',
'name': 'Test Customer',
'pronouns': 'They/Them/Their',
'hobbies': 'testing',
'notable_facts': 'none',
'style_genre': "1980's, Pop, Funk",
'vocal_gender': 'female',
'extra_requests': 'Test webhook',
'stems_interest': 0,
'revision_count': 0,
'revision_note': '',
'suno_title': '',
'suno_style': '',
'suno_lyrics': '',
})
if ok:
flash(f'Test Hermes webhook sent. {msg}', 'success')
else:
flash(f'Test Hermes webhook failed: {msg}', 'error')
return redirect(url_for('admin_settings'))
elif action == 'send_test_ntfy': elif action == 'send_test_ntfy':
# Send a test push notification to the configured ntfy topic. # Send a test push notification to the configured ntfy topic.
ntfy = get_ntfy_config() ntfy = get_ntfy_config()
@ -1911,6 +1980,7 @@ def admin_settings():
musicgpt_api_key_set=bool(get_musicgpt_api_key()), musicgpt_api_key_set=bool(get_musicgpt_api_key()),
musicgpt_webhook_url=build_musicgpt_webhook_url(), musicgpt_webhook_url=build_musicgpt_webhook_url(),
musicgpt_autopoll=runtime_settings.get('musicgpt_autopoll', True), musicgpt_autopoll=runtime_settings.get('musicgpt_autopoll', True),
hermes_webhook=get_hermes_webhook_config(),
) )

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'): def send_ntfy(message, title='Theme Song Booth', priority='default', tags='bell'):
"""Send a push notification to the configured ntfy topic, if configured.""" """Send a push notification to the configured ntfy topic, if configured."""
ntfy = get_ntfy_config() ntfy = get_ntfy_config()

View file

@ -473,8 +473,11 @@
<div class="right"> <div class="right">
<!-- Generate Music Prompt --> <!-- Generate Music Prompt -->
<div class="section"> <div class="section">
<h2>1. Generate & Save Music Prompt</h2> <h2>1. Generate &amp; Save Music Prompt</h2>
<button type="button" onclick="copyPromptForHermes()">Copy customer info for Hermes</button> <button type="button" onclick="copyPromptForHermes()">Copy customer info for Hermes</button>
<form method="POST" action="{{ url_for('send_to_hermes', rid=req.id) }}" style="display:inline">
<button type="submit" onclick="return confirm('Send this request to Hermes to generate a MusicGPT prompt?')">🚀 Send to Hermes</button>
</form>
<p class="copy-hint">Paste Hermes' Title, Style, and Lyrics directly into the fields below, then save or generate.</p> <p class="copy-hint">Paste Hermes' Title, Style, and Lyrics directly into the fields below, then save or generate.</p>
{% if req.musicgpt_error %} {% if req.musicgpt_error %}

View file

@ -339,6 +339,37 @@
</form> </form>
</div> </div>
<!-- Hermes webhook integration -->
<div class="section">
<h2>Hermes Webhook</h2>
<p class="copy-hint">Push song request data to Hermes so it can generate a MusicGPT prompt automatically. The URL must include the route name (e.g. https://ntfy.hallsworth.ca/webhooks/trollgorithm-song-info). The secret is encrypted before storage.</p>
<form method="POST">
<input type="hidden" name="action" value="save_hermes_webhook">
<table class="meta-table">
<tr>
<td>Webhook URL</td>
<td><input type="text" id="hermes_webhook_url" name="hermes_webhook_url" value="{{ hermes_webhook.get('url', '') }}" placeholder="https://ntfy.hallsworth.ca/webhooks/trollgorithm-song-info"></td>
</tr>
<tr>
<td>HMAC Secret</td>
<td>
<input type="password" id="hermes_webhook_secret" name="hermes_webhook_secret" placeholder="{% if hermes_webhook.get('secret') %}Stored encrypted — type to replace{% else %}Enter secret{% endif %}">
<p class="copy-hint">From the Hermes webhook subscription. Leave blank to keep the existing stored secret.</p>
</td>
</tr>
</table>
<button type="submit">Save Hermes Webhook</button>
</form>
<hr style="border:none;border-top:1px solid var(--border);margin:1.5rem 0;">
<h3>Send Test Request</h3>
<form method="POST">
<input type="hidden" name="action" value="send_test_hermes_webhook">
<button type="submit">Send Test to Hermes</button>
</form>
</div>
<!-- MusicGPT integration --> <!-- MusicGPT integration -->
<div class="section"> <div class="section">
<h2>MusicGPT Integration</h2> <h2>MusicGPT Integration</h2>