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:
parent
3c99378044
commit
2f5050b29f
6 changed files with 199 additions and 4 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
# Theme Song Booth (MusicGPT Edition)
|
# Theme Song Booth (MusicGPT Edition)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.8.9
|
0.9.0
|
||||||
|
|
|
||||||
72
app.py
72
app.py
|
|
@ -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(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1967,4 +2037,4 @@ with app.app_context():
|
||||||
# If the database path is not yet reachable (e.g. volume not mounted),
|
# If the database path is not yet reachable (e.g. volume not mounted),
|
||||||
# defer to the first request or the explicit init-db command.
|
# defer to the first request or the explicit init-db command.
|
||||||
import logging
|
import logging
|
||||||
logging.getLogger('app').warning('Startup init_db() failed; database may need manual initialization.', exc_info=True)
|
logging.getLogger('app').warning('Startup init_db() failed; database may need manual initialization.', exc_info=True)
|
||||||
91
helpers.py
91
helpers.py
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 & 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 %}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue