diff --git a/README.md b/README.md index 147bbf8..db2b28d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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. @@ -25,12 +25,13 @@ A Flask web application for running a convention booth where visitors request a ## What the booth does 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. -3. The operator (or an AI assistant via the `/api/prompt` callback) saves the prompt to the request. -4. The operator creates two song versions in Suno, downloads them, and uploads **Version A** and **Version B** to the request page. -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. -6. After the customer approves and pays, the operator records the payment reference and delivers the selected MP3(s) by email. -7. Optional stems / extras can be delivered via a share link that appears on the player page after delivery. +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. 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 (or the Hermes callback) saves the prompt to the request. +5. The operator creates two song versions in Suno, downloads them, and uploads **Version A** and **Version B** to the request page. +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. 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. | | Dashboard | `/admin` | Main queue. Filter by status and auto-refresh at a configurable interval. | -| Request detail | `/admin/request/` | 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/` | 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. | | 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. | --- @@ -110,9 +111,14 @@ The `/admin/settings` page is split into functional sections: ### Hermes API key - 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/` 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/`. +- **Send Test Request** verifies the URL, secret, and signature. ### Customer revision limit - 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`). -8. Visit `/admin/settings` and click **Regenerate API Key**. -9. Copy the key to your Hermes skill / AI assistant. -10. Print or display a QR code pointing to `https://your-domain/request`. +8. Visit `/admin/settings` and configure the **Hermes webhook** URL and secret. +9. Print or display a QR code pointing to `https://your-domain/request`. ### Updating the deployment diff --git a/VERSION b/VERSION index 844f6a9..faef31a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.3 +0.7.0 diff --git a/app.py b/app.py index 8b32217..7662a2a 100644 --- a/app.py +++ b/app.py @@ -57,6 +57,7 @@ from helpers import ( get_max_revisions, get_callback_expiry_hours, get_hermes_api_key, set_hermes_api_key, generate_hermes_api_key, mask_api_key, 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, send_email, build_signature_images, get_booth_open, @@ -905,6 +906,37 @@ def admin_request(rid): ) +@app.route('/admin/request//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//delete', methods=['POST']) def admin_delete_request(rid): """Delete a single request and remove its uploaded MP3 files.""" @@ -1247,6 +1279,42 @@ def admin_settings(): flash('ntfy notification settings saved.', 'success') 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': # Send a test push notification to the configured ntfy topic. ntfy = get_ntfy_config() @@ -1289,6 +1357,7 @@ def admin_settings(): version=current_app.config['VERSION'], current_callback_expiry_hours=get_callback_expiry_hours(), ntfy=runtime_settings, + hermes_webhook=get_hermes_webhook_config(), ) diff --git a/helpers.py b/helpers.py index d0804eb..ca1ad89 100644 --- a/helpers.py +++ b/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/. + """ + 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() diff --git a/templates/admin/request.html b/templates/admin/request.html index 9c3cc79..f64533b 100644 --- a/templates/admin/request.html +++ b/templates/admin/request.html @@ -326,6 +326,9 @@

1. Generate & Save Suno Prompt

+
+ +

Paste Hermes' Title, Style, and Lyrics directly into the fields below, then save.

diff --git a/templates/admin/settings.html b/templates/admin/settings.html index 7f94a79..2e0b1d7 100644 --- a/templates/admin/settings.html +++ b/templates/admin/settings.html @@ -339,6 +339,37 @@
+ +
+

Hermes Webhook

+

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.

+
+ + + + + + + + + + +
Webhook URL
HMAC Secret + +

From the Hermes webhook subscription. Leave blank to keep the existing stored secret.

+
+ +
+ +
+ +

Send Test Request

+
+ + +
+
+

Email (SMTP) Settings