feat: send song requests to Hermes via webhook
- Add Hermes webhook platform support on ntfy.hallsworth.ca - Add /admin/settings form to configure webhook 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 reply via chat or POST the Suno prompt back to /api/prompt/<id> - Add test button, update README, bump version to 0.7.0
This commit is contained in:
parent
d058d8db1d
commit
d7ed56f34e
6 changed files with 213 additions and 15 deletions
33
README.md
33
README.md
|
|
@ -1,6 +1,6 @@
|
||||||
# Theme Song Booth
|
# Theme Song Booth
|
||||||
|
|
||||||
**Version:** `v0.6.3`
|
**Version:** `v0.7.0`
|
||||||
|
|
||||||
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 Suno prompts, upload MP3 previews, collect payment, 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 Suno prompts, upload MP3 previews, collect payment, and deliver final songs by email.
|
||||||
|
|
||||||
|
|
@ -25,12 +25,13 @@ A Flask web application for running a convention booth where visitors request a
|
||||||
## What the booth does
|
## What the booth does
|
||||||
|
|
||||||
1. A visitor fills out a short form at `/request`.
|
1. A visitor fills out a short form at `/request`.
|
||||||
2. The operator reviews the request in the admin dashboard and generates a Suno Custom Mode prompt.
|
2. The operator reviews the request in the admin dashboard and clicks **Send to Hermes** to push the customer data to a Hermes webhook.
|
||||||
3. The operator (or an AI assistant via the `/api/prompt` callback) saves the prompt to the request.
|
3. Hermes generates a Suno Custom Mode prompt and can POST it directly back to the `/api/prompt` callback endpoint, or reply in chat with the prompt text.
|
||||||
4. The operator creates two song versions in Suno, downloads them, and uploads **Version A** and **Version B** to the request page.
|
4. The operator (or the Hermes callback) saves the prompt to the request.
|
||||||
5. The operator sends a preview email; the customer visits their private player page, listens to both versions, and either approves one/both or requests changes.
|
5. The operator creates two song versions in Suno, downloads them, and uploads **Version A** and **Version B** to the request page.
|
||||||
6. After the customer approves and pays, the operator records the payment reference and delivers the selected MP3(s) by email.
|
6. The operator sends a preview email; the customer visits their private player page, listens to both versions, and either approves one/both or requests changes.
|
||||||
7. Optional stems / extras can be delivered via a share link that appears on the player page after delivery.
|
7. After the customer approves and pays, the operator records the payment reference and delivers the selected MP3(s) by email.
|
||||||
|
8. Optional stems / extras can be delivered via a share link that appears on the player page after delivery.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -74,10 +75,10 @@ The selected pronouns are stored in the `pronouns` column and included in confir
|
||||||
|------|------|---------|
|
|------|------|---------|
|
||||||
| Login | `/admin/login` | Simple session-based login. Password comes from the `ADMIN_PASSWORD` environment variable. |
|
| Login | `/admin/login` | Simple session-based login. Password comes from the `ADMIN_PASSWORD` environment variable. |
|
||||||
| Dashboard | `/admin` | Main queue. Filter by status and auto-refresh at a configurable interval. |
|
| Dashboard | `/admin` | Main queue. Filter by status and auto-refresh at a configurable interval. |
|
||||||
| Request detail | `/admin/request/<id>` | Full control of one request: edit customer info (including pronouns and structured style), save prompt, copy Hermes callback, view revision history, upload MP3s, send preview, record payment, deliver files, add operator notes, and cancel. |
|
| Request detail | `/admin/request/<id>` | Full control of one request: edit customer info (including pronouns and structured style), save prompt, copy Hermes callback, **Send to Hermes** webhook, view revision history, upload MP3s, send preview, record payment, deliver files, add operator notes, and cancel. |
|
||||||
| Pricing | `/admin/pricing` | Configure fixed prices (one song, both songs, WAV per song, STEMs per song) and up to 5 custom items. |
|
| Pricing | `/admin/pricing` | Configure fixed prices (one song, both songs, WAV per song, STEMs per song) and up to 5 custom items. |
|
||||||
| Sales | `/admin/sales` | Report of all delivered requests with customer details and Square payment references. |
|
| Sales | `/admin/sales` | Report of all delivered requests with customer details and Square payment references. |
|
||||||
| Settings | `/admin/settings` | Database health, backups, SMTP config, MP3 metadata defaults, revision limit, auto-refresh interval, kiosk mode, booth open/closed switch, Hermes API key management, and system reset. |
|
| Settings | `/admin/settings` | Database health, backups, SMTP config, MP3 metadata defaults, revision limit, auto-refresh interval, kiosk mode, booth open/closed switch, Hermes API key, Hermes webhook configuration, and system reset. |
|
||||||
| Reset | `/admin/reset` | Clears all requests and uploaded files. Requires admin password confirmation. |
|
| Reset | `/admin/reset` | Clears all requests and uploaded files. Requires admin password confirmation. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -110,9 +111,14 @@ The `/admin/settings` page is split into functional sections:
|
||||||
|
|
||||||
### Hermes API key
|
### Hermes API key
|
||||||
- Displays whether a key is configured.
|
- Displays whether a key is configured.
|
||||||
- **Regenerate API Key** creates a new random key stored in runtime settings.
|
|
||||||
- The key is used by the `/api/prompt/<id>` callback and by the `/api/key-test` diagnostic endpoint.
|
- The key is used by the `/api/prompt/<id>` callback and by the `/api/key-test` diagnostic endpoint.
|
||||||
- Copy this key into your Hermes skill or AI assistant config.
|
- Copy this key into your Hermes webhook subscription and AI assistant config.
|
||||||
|
|
||||||
|
### Hermes webhook
|
||||||
|
- Configure the public webhook URL and HMAC secret so the booth can push song request data to Hermes.
|
||||||
|
- The operator clicks **Send to Hermes** on a request to trigger prompt generation.
|
||||||
|
- The payload includes the callback URL so Hermes can POST the generated prompt back to `/api/prompt/<id>`.
|
||||||
|
- **Send Test Request** verifies the URL, secret, and signature.
|
||||||
|
|
||||||
### Customer revision limit
|
### Customer revision limit
|
||||||
- Maximum number of times a customer can click **Request Changes** on the player page.
|
- Maximum number of times a customer can click **Request Changes** on the player page.
|
||||||
|
|
@ -174,9 +180,8 @@ python init_db.py
|
||||||
```
|
```
|
||||||
|
|
||||||
7. Point your reverse proxy at the host port you chose (default `127.0.0.1:8000`).
|
7. Point your reverse proxy at the host port you chose (default `127.0.0.1:8000`).
|
||||||
8. Visit `/admin/settings` and click **Regenerate API Key**.
|
8. Visit `/admin/settings` and configure the **Hermes webhook** URL and secret.
|
||||||
9. Copy the key to your Hermes skill / AI assistant.
|
9. Print or display a QR code pointing to `https://your-domain/request`.
|
||||||
10. Print or display a QR code pointing to `https://your-domain/request`.
|
|
||||||
|
|
||||||
### Updating the deployment
|
### Updating the deployment
|
||||||
|
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.6.3
|
0.7.0
|
||||||
|
|
|
||||||
69
app.py
69
app.py
|
|
@ -57,6 +57,7 @@ from helpers import (
|
||||||
get_max_revisions, get_callback_expiry_hours,
|
get_max_revisions, get_callback_expiry_hours,
|
||||||
get_hermes_api_key, set_hermes_api_key, generate_hermes_api_key, mask_api_key,
|
get_hermes_api_key, set_hermes_api_key, generate_hermes_api_key, mask_api_key,
|
||||||
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,
|
||||||
|
|
@ -905,6 +906,37 @@ 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 Suno prompt and either POST it back to the callback URL
|
||||||
|
or reply in chat.
|
||||||
|
"""
|
||||||
|
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."""
|
||||||
|
|
@ -1247,6 +1279,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()
|
||||||
|
|
@ -1289,6 +1357,7 @@ def admin_settings():
|
||||||
version=current_app.config['VERSION'],
|
version=current_app.config['VERSION'],
|
||||||
current_callback_expiry_hours=get_callback_expiry_hours(),
|
current_callback_expiry_hours=get_callback_expiry_hours(),
|
||||||
ntfy=runtime_settings,
|
ntfy=runtime_settings,
|
||||||
|
hermes_webhook=get_hermes_webhook_config(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
90
helpers.py
90
helpers.py
|
|
@ -374,6 +374,96 @@ 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 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()
|
||||||
|
|
|
||||||
|
|
@ -326,6 +326,9 @@
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>1. Generate & Save Suno Prompt</h2>
|
<h2>1. Generate & Save Suno 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 Suno prompt?')">🚀 Send to Hermes</button>
|
||||||
|
</form>
|
||||||
<p class="copy-hint">Paste Hermes' Title, Style, and Lyrics directly into the fields below, then save.</p>
|
<p class="copy-hint">Paste Hermes' Title, Style, and Lyrics directly into the fields below, then save.</p>
|
||||||
|
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
|
|
|
||||||
|
|
@ -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 Suno prompts 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>
|
||||||
|
|
||||||
<!-- Email / SMTP configuration -->
|
<!-- Email / SMTP configuration -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Email (SMTP) Settings</h2>
|
<h2>Email (SMTP) Settings</h2>
|
||||||
|
|
|
||||||
Reference in a new issue