From a56f6e498b1e44173326dc407ab736dcf90ec81c Mon Sep 17 00:00:00 2001 From: "Troll (Hermes Agent)" Date: Fri, 7 Aug 2026 17:14:02 +0000 Subject: [PATCH 01/10] feat: add ntfy push notifications for new requests - Add ntfy server/topic settings in /admin/settings next to Hermes API key - POST a push notification to the configured topic on every new customer request - Add a Send Test ntfy button to verify configuration - Add requests dependency for ntfy HTTP calls - Bump version to 0.5.6 --- README.md | 2 +- REVIEW.md | 12 +++---- VERSION | 2 +- app.py | 68 +++++++++++++++++++++++++++++++++++ requirements.txt | 1 + templates/admin/settings.html | 28 +++++++++++++++ 6 files changed, 105 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 4b86648..4606fc5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Theme Song Booth -**Version:** `v0.5.5` +**Version:** `v0.5.6` 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. diff --git a/REVIEW.md b/REVIEW.md index 49d7185..8f975e8 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -11,7 +11,7 @@ Flask app that lets convention attendees request custom AI-generated theme songs - Python 3.12 + Flask - SQLite (file-based, request-scoped connection via `g`) - Gunicorn in Docker -- Portainer stack deployed from GitLab repo +- Portainer stack deployed from Gitea repo - SMTP (SSL port 465) for customer emails - Square Terminal/Reader for manual payment - `mutagen` for MP3 metadata tagging @@ -20,7 +20,7 @@ Flask app that lets convention attendees request custom AI-generated theme songs ## Repository -- GitLab: `https://gitlab.hallsworth.ca/yrtria/theme-song-booth` (public) +- Gitea: `https://gitlab.hallsworth.ca/yrtria/theme-song-booth` (public) - Deployed at: `https://booth.dionysismedia.ca` ## Key files and what they hold @@ -112,7 +112,7 @@ These survive redeploys because `booth_settings.json` lives in the persistent up ## How to redeploy -1. Push changes to GitLab `main`. +1. Push changes to Gitea `main`. 2. In Portainer: Stacks → `theme-song-booth` → **Pull and redeploy**. 3. If schema changed, open container console and run `python init_db.py`, or use `/admin/settings` → **Fix Database Schema**. @@ -134,17 +134,17 @@ These survive redeploys because `booth_settings.json` lives in the persistent up ## Project state notes -- No `.gitlab-ci.yml` is currently in the repo; old pipeline records from an earlier CI config are still visible in GitLab but are not actionable because no runners are attached. Add a CI skeleton (see below) if you want automated checks back. +- No `.gitlab-ci.yml` is currently in the repo; old pipeline records from an earlier CI config are still visible in Gitea but are not actionable because no runners are attached. Add a CI skeleton (see below) if you want automated checks back. - No automated tests exist yet. ## CI skeleton (optional) -A **CI skeleton** is the smallest GitLab CI config that gives you useful automated checks on every push without needing a heavy test suite. For this project it would be a `.gitlab-ci.yml` with one or two jobs: +A **CI skeleton** is the smallest Gitea CI config that gives you useful automated checks on every push without needing a heavy test suite. For this project it would be a `.gitlab-ci.yml` with one or two jobs: 1. **Syntax check job** — install Python dependencies and run `python -m py_compile app.py models.py config.py init_db.py` to catch SyntaxErrors before they reach Portainer. 2. **(Optional) Test job** — run a minimal pytest suite once tests are written. Right now this would be a placeholder that skips if no tests exist, so the pipeline stays green while you decide whether to add tests. -It needs a GitLab runner to execute. Your GitLab instance has no runners attached, which is why the old pipelines are stuck/canceled. The skeleton just defines *what* to run; a runner is still required for it to actually execute. +It needs a Gitea runner to execute. Your Gitea instance has no runners attached, which is why the old pipelines are stuck/canceled. The skeleton just defines *what* to run; a runner is still required for it to actually execute. ## Static assets to keep in the repo diff --git a/VERSION b/VERSION index d1d899f..b49b253 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.5 +0.5.6 diff --git a/app.py b/app.py index 560529b..099fc82 100644 --- a/app.py +++ b/app.py @@ -64,6 +64,9 @@ from models import SCHEMA, get_requests_by_email, log_revision, list_revision_hi from mutagen.mp3 import MP3 from mutagen.easyid3 import EasyID3 +# HTTP client for ntfy push notifications +import requests + # --------------------------------------------------------------------------- # Genre / decade helpers @@ -411,6 +414,36 @@ def mask_api_key(key): return '*' * (len(key) - 6) + key[-6:] +def get_ntfy_config(): + """Return the effective ntfy server URL and topic from runtime settings.""" + cfg = load_booth_settings() + return { + 'server': cfg.get('ntfy_server', ''), + 'topic': cfg.get('ntfy_topic', ''), + } + + +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() + server = ntfy.get('server', '').rstrip('/') + topic = ntfy.get('topic', '').strip() + if not server or not topic: + return False + + url = f"{server}/{topic}" + headers = { + 'Title': title, + 'Priority': priority, + 'Tags': tags, + } + try: + resp = requests.post(url, data=message.encode('utf-8'), headers=headers, timeout=10) + return resp.status_code in (200, 202) + except Exception: + return False + + def sign_prompt_callback(rid, expires_at=None): """ Create a signed callback token for a specific request ID. @@ -559,6 +592,18 @@ def request_form(): return render_template('request.html', form=form_data, decades=DECADES, genres=MUSIC_GENRES), 400 rid = create_request(**form_data) + # Notify operator via ntfy when a new request comes in. + try: + send_ntfy( + f"New request #{rid} from {form_data['name']} ({form_data['email']})\nStyle: {form_data['style_genre'] or '-'}", + title='New Theme Song Request', + priority='high', + tags='musical_note' + ) + except Exception: + # Push notification failure should never break the customer form. + pass + # Send confirmation email with a summary of what the customer asked for. req = get_request_by_id(rid) if req: @@ -1626,6 +1671,28 @@ def admin_settings(): flash('Hermes API key is managed via the HERMES_API_KEY environment variable.', 'info') return redirect(url_for('admin_settings')) + elif action == 'save_ntfy': + # Update ntfy push notification server/topic from the settings form. + cfg = load_booth_settings() + cfg['ntfy_server'] = request.form.get('ntfy_server', '').strip().rstrip('/') + cfg['ntfy_topic'] = request.form.get('ntfy_topic', '').strip() + save_booth_settings(cfg) + flash('ntfy notification settings saved.', 'success') + 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() + if not ntfy['server'] or not ntfy['topic']: + flash('Configure ntfy server and topic first.', 'error') + return redirect(url_for('admin_settings')) + ok = send_ntfy('This is a test push notification from the Trollgorithm Theme Song Booth.', title='Test Notification', priority='high', tags='test_tube') + if ok: + flash('Test ntfy notification sent.', 'success') + else: + flash('Failed to send ntfy test notification. Check server and topic.', 'error') + return redirect(url_for('admin_settings')) + return render_template( 'admin/settings.html', health=health, @@ -1653,6 +1720,7 @@ def admin_settings(): hermes_key_set=hermes_key_set, hermes_key_just_generated=hermes_key_just_generated, version=current_app.config['VERSION'], + ntfy=runtime_settings, ) diff --git a/requirements.txt b/requirements.txt index 16f50f1..c5b0ac1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,3 +18,4 @@ werkzeug mutagen flask-limiter cryptography +requests diff --git a/templates/admin/settings.html b/templates/admin/settings.html index 2051b4c..395da38 100644 --- a/templates/admin/settings.html +++ b/templates/admin/settings.html @@ -293,6 +293,34 @@ {% endif %} + +
+

ntfy Notifications

+

Push notifications for new customer requests. Leave either field blank to disable.

+
+ + + + + + + + + + +
ntfy Server URL
ntfy Topic
+ +
+ +
+ +

Send Test Notification

+
+ + +
+
+

Email (SMTP) Settings

From 5ba4f28faf93739cfa6870b23b39181480010adc Mon Sep 17 00:00:00 2001 From: "Troll (Hermes Agent)" Date: Fri, 7 Aug 2026 17:21:04 +0000 Subject: [PATCH 02/10] feat: add ntfy access token support - ntfy push notifications now include a Bearer access token when configured - Add Access Token field to /admin/settings next to server and topic - Token is stored encrypted like the SMTP password - Bump version to 0.5.7 --- README.md | 2 +- VERSION | 2 +- app.py | 13 ++++++++++--- templates/admin/settings.html | 7 +++++++ 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4606fc5..7410473 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Theme Song Booth -**Version:** `v0.5.6` +**Version:** `v0.5.7` 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. diff --git a/VERSION b/VERSION index b49b253..d3532a1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.6 +0.5.7 diff --git a/app.py b/app.py index 099fc82..20f9324 100644 --- a/app.py +++ b/app.py @@ -415,11 +415,12 @@ def mask_api_key(key): def get_ntfy_config(): - """Return the effective ntfy server URL and topic from runtime settings.""" + """Return the effective ntfy server URL, topic, and access token from runtime settings.""" cfg = load_booth_settings() return { 'server': cfg.get('ntfy_server', ''), 'topic': cfg.get('ntfy_topic', ''), + 'token': decrypt_value(cfg.get('ntfy_token', '')) or '', } @@ -437,6 +438,9 @@ def send_ntfy(message, title='Theme Song Booth', priority='default', tags='bell' 'Priority': priority, 'Tags': tags, } + token = ntfy.get('token', '') + if token: + headers['Authorization'] = f'Bearer {token}' try: resp = requests.post(url, data=message.encode('utf-8'), headers=headers, timeout=10) return resp.status_code in (200, 202) @@ -1672,10 +1676,13 @@ def admin_settings(): return redirect(url_for('admin_settings')) elif action == 'save_ntfy': - # Update ntfy push notification server/topic from the settings form. + # Update ntfy push notification server/topic/access token from the settings form. cfg = load_booth_settings() cfg['ntfy_server'] = request.form.get('ntfy_server', '').strip().rstrip('/') cfg['ntfy_topic'] = request.form.get('ntfy_topic', '').strip() + new_token = request.form.get('ntfy_token', '').strip() + if new_token: + cfg['ntfy_token'] = encrypt_value(new_token) save_booth_settings(cfg) flash('ntfy notification settings saved.', 'success') return redirect(url_for('admin_settings')) @@ -1690,7 +1697,7 @@ def admin_settings(): if ok: flash('Test ntfy notification sent.', 'success') else: - flash('Failed to send ntfy test notification. Check server and topic.', 'error') + flash('Failed to send ntfy test notification. Check server, topic, and access token.', 'error') return redirect(url_for('admin_settings')) return render_template( diff --git a/templates/admin/settings.html b/templates/admin/settings.html index 395da38..f94a0f2 100644 --- a/templates/admin/settings.html +++ b/templates/admin/settings.html @@ -308,6 +308,13 @@ ntfy Topic + + ntfy Access Token + + +

Required if the topic is access-controlled. Leave blank to keep the existing stored token.

+ + From 613abf8041e45ecfd689ff37762eb298d37f54a8 Mon Sep 17 00:00:00 2001 From: "Troll (Hermes Agent)" Date: Sun, 9 Aug 2026 04:40:24 +0000 Subject: [PATCH 03/10] Add FAQ entry for supported languages (v0.5.8) --- README.md | 2 +- VERSION | 2 +- templates/faq.html | 3 +++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7410473..4f6a4d7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Theme Song Booth -**Version:** `v0.5.7` +**Version:** `v0.5.8` 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. diff --git a/VERSION b/VERSION index d3532a1..659914a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.7 +0.5.8 diff --git a/templates/faq.html b/templates/faq.html index 5d44279..a98e44d 100644 --- a/templates/faq.html +++ b/templates/faq.html @@ -123,6 +123,9 @@

What if I want to sing and they just play the music?

While Trollgorithm is talented, they are also still just Trolls. You would probably get eaten if you were in the recording booth with them.

+

What languages can Trollgorithm sing in?

+

Trollgorithm knows English, Spanish, and French best. They can attempt German, Italian, Portuguese, Japanese, Mandarin Chinese, Korean, Russian, Arabic, and Hindi — but remember, they're just trolls and goblins, not terribly smart. Have a fluent speaker listen and check the result to make sure it sounds right.

+

Payment & delivery

How do I pay?

We accept payment at the booth through Square, including card, tap, and cash where available.

From 0bfe8c315c8e71b54132dfb9e2f87b050df7823e Mon Sep 17 00:00:00 2001 From: "Troll (Hermes Agent)" Date: Sun, 9 Aug 2026 04:58:19 +0000 Subject: [PATCH 04/10] Add STEMS note to FAQ file format answer (v0.5.9) --- README.md | 2 +- VERSION | 2 +- templates/faq.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4f6a4d7..880d7f1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Theme Song Booth -**Version:** `v0.5.8` +**Version:** `v0.5.9` 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. diff --git a/VERSION b/VERSION index 659914a..416bfb0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.8 +0.5.9 diff --git a/templates/faq.html b/templates/faq.html index a98e44d..8768f28 100644 --- a/templates/faq.html +++ b/templates/faq.html @@ -137,7 +137,7 @@

To the email address you gave us, so double-check it for typos before submitting.

What file format is it?

-

The delivered song is a standard MP3 file that plays on virtually any device. WAV files can be provided upon request.

+

The delivered song is a standard MP3 file that plays on virtually any device. WAV files can be provided upon request. We can also get you STEMS. If you know, you know, otherwise these aren't for everybody.

Can you send it to someone else's email?

At the booth we can update the email address if needed, but the original request needs a valid email to start.

From 0a01381d44c49657c825cf2d753c9045d72b4b3e Mon Sep 17 00:00:00 2001 From: "Troll (Hermes Agent)" Date: Sun, 9 Aug 2026 05:18:40 +0000 Subject: [PATCH 05/10] v0.6.0: form limits, callback expiry, dashboard filter, payment ref, STEMS interest, helper refactor --- README.md | 2 +- VERSION | 2 +- app.py | 525 +++------------------------------ helpers.py | 502 +++++++++++++++++++++++++++++++ models.py | 13 +- templates/admin/dashboard.html | 2 +- templates/admin/request.html | 32 +- templates/admin/settings.html | 11 + templates/request.html | 11 +- 9 files changed, 603 insertions(+), 497 deletions(-) create mode 100644 helpers.py diff --git a/README.md b/README.md index 880d7f1..bfa8d5c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Theme Song Booth -**Version:** `v0.5.9` +**Version:** `v0.6.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. diff --git a/VERSION b/VERSION index 416bfb0..a918a2a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.9 +0.6.0 diff --git a/app.py b/app.py index 20f9324..a3d5e4f 100644 --- a/app.py +++ b/app.py @@ -30,95 +30,36 @@ Admin routes: # Standard library imports import os -import re import shutil -import smtplib -import ssl import time -from email.message import EmailMessage from pathlib import Path -import json - -from cryptography.fernet import Fernet -from cryptography.hazmat.primitives import hashes -from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC -import base64 - -import hmac -import hashlib -import secrets - # Flask and related imports -from flask import Flask, request, render_template, redirect, url_for, flash, session, send_from_directory, abort, current_app, send_file, jsonify +from flask import Flask, request, render_template, redirect, url_for, flash, session, abort, current_app, send_file, jsonify from flask_limiter import Limiter from flask_limiter.util import get_remote_address -from werkzeug.utils import secure_filename # Project imports from config import Config from models import init_db, close_db, create_request, get_request_by_id, get_request_by_token, list_requests, update_request, now_utc, delete_request, reset_all_requests, get_db from models import SCHEMA, get_requests_by_email, log_revision, list_revision_history -# Audio metadata import -from mutagen.mp3 import MP3 -from mutagen.easyid3 import EasyID3 - -# HTTP client for ntfy push notifications -import requests - - -# --------------------------------------------------------------------------- -# Genre / decade helpers -# --------------------------------------------------------------------------- - -# Load external lists once at import time. These are shared by the customer -# request form and the admin request editor so the dropdowns stay in sync. -# In production the canonical files live under /mnt/Storage. We also bundle -# copies in the repo so the container still works if that mount is missing. -_GENRES_PATH = Path('/mnt/Storage/Music Genres.txt') -_DECADES_PATH = Path('/mnt/Storage/Decades.txt') -_FALLBACK_GENRES_PATH = Path(__file__).parent / 'lists' / 'music_genres.txt' -_FALLBACK_DECADES_PATH = Path(__file__).parent / 'lists' / 'decades.txt' - - -def _load_lines(path: Path) -> list[str]: - """Load a text file and return non-empty stripped lines.""" - if not path.exists(): - return [] - lines = path.read_text(encoding='utf-8').splitlines() - return [line.strip() for line in lines if line.strip()] - - -def _load_list(primary: Path, fallback: Path) -> list[str]: - """Load from the primary path, falling back to the bundled copy.""" - lines = _load_lines(primary) - if lines: - return lines - return _load_lines(fallback) - - -MUSIC_GENRES = _load_list(_GENRES_PATH, _FALLBACK_GENRES_PATH) -DECADES = _load_list(_DECADES_PATH, _FALLBACK_DECADES_PATH) - - -def parse_style_genre(style_genre: str | None) -> dict: - """ - Split a stored combined style string into decade, basic, and additional. - The stored format is 'Decade, Basic, Additional' (additional may be empty). - """ - parts = [p.strip() for p in (style_genre or '').split(',') if p.strip()] - return { - 'decade': parts[0] if len(parts) > 0 else '', - 'basic_style': parts[1] if len(parts) > 1 else '', - 'additional_style': ', '.join(parts[2:]) if len(parts) > 2 else '', - } - - -def build_style_genre(decade: str, basic_style: str, additional_style: str) -> str: - """Build the combined style_genre string stored in the database.""" - parts = [p.strip() for p in [decade, basic_style, additional_style] if p.strip()] - return ', '.join(parts) +# Helpers (extracted from app.py to keep the route file manageable) +from helpers import ( + MUSIC_GENRES, DECADES, parse_style_genre, build_style_genre, + is_admin, require_admin, admin_password_ok, is_valid_email, + allowed_file, upload_path, save_upload, + apply_mp3_tags, + encrypt_value, decrypt_value, decrypt_value_legacy, + settings_file_path, load_booth_settings, save_booth_settings, + get_email_config, get_refresh_seconds, get_kiosk_cycle_seconds, get_kiosk_mode, + 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, + sign_prompt_callback, verify_prompt_callback, build_prompt_callback_url, + send_email, build_signature_images, + get_booth_open, +) # --------------------------------------------------------------------------- @@ -147,403 +88,6 @@ STATUS_LABELS = { 'cancelled': 'Cancelled', } -# --------------------------------------------------------------------------- -# Helper functions -# --------------------------------------------------------------------------- - -def is_admin(): - """Return True if the current browser session is logged in as admin.""" - return session.get('admin') is True - - -def require_admin(): - """Redirect to the admin login page if the user is not logged in.""" - if not is_admin(): - return redirect(url_for('admin_login')) - - -def admin_password_ok(pw): - """Check the submitted admin password against the configured one.""" - return pw and pw == current_app.config['ADMIN_PASSWORD'] - - -def allowed_file(filename): - """Return True if the uploaded filename has an allowed extension (mp3).""" - return '.' in filename and filename.rsplit('.', 1)[1].lower() in current_app.config['ALLOWED_EXTENSIONS'] - - -def upload_path(request_id): - """Return the per-request upload directory path, creating it if necessary.""" - p = Path(current_app.config['UPLOAD_FOLDER']) / str(request_id) - p.mkdir(parents=True, exist_ok=True) - return p - - -def save_upload(request_id, file_obj, version, song_title=None): - """ - Save an uploaded MP3 file for a request, preserving the original filename - with a version prefix (e.g. A - MySong.mp3 / B - MySong.mp3). - Applies the configured metadata tags (title, artist, album, year, comment). - """ - if not file_obj or file_obj.filename == '': - return None - if not allowed_file(file_obj.filename): - flash('Only MP3 files are allowed.', 'error') - return None - original = secure_filename(file_obj.filename) - filename = f"{version.upper()} - {original}" - p = upload_path(request_id) - dest = p / filename - file_obj.save(dest) - apply_mp3_tags(str(dest), song_title) - return str(dest) - - -def apply_mp3_tags(path, title=None): - """ - Write common ID3 tags on an uploaded MP3 using the runtime metadata defaults. - Writes title, artist, album, and date via EasyID3, plus a comment using both - a COMM frame and a TXXX:Comment frame for broad reader compatibility. - Failures are logged as a warning and do not block the upload. - """ - cfg = load_booth_settings() - try: - audio = MP3(path) - if audio.tags is None: - audio.add_tags() - if not isinstance(audio.tags, EasyID3): - audio.tags = EasyID3() - tags = audio.tags - if title: - tags['title'] = title - if cfg.get('artist'): - tags['artist'] = cfg['artist'] - if cfg.get('album'): - tags['album'] = cfg['album'] - if cfg.get('year'): - tags['date'] = str(cfg['year']) - audio.save() - if cfg.get('comment'): - from mutagen.id3 import COMM, TXXX - audio2 = MP3(path) - if audio2.tags is None: - audio2.add_tags() - audio2.tags["COMM"] = COMM(encoding=3, lang='eng', desc='Comment', text=cfg['comment']) - audio2.tags["TXXX:Comment"] = TXXX(encoding=3, desc='Comment', text=cfg['comment']) - audio2.save() - except Exception as e: - try: - flash(f'Warning: could not tag MP3: {e}', 'error') - except RuntimeError: - import logging - logging.getLogger('app').warning('Could not tag MP3 %s: %s', path, e) - - -def _get_fernet(): - """Derive a Fernet key from the Flask SECRET_KEY so stored values are encrypted.""" - secret = current_app.config['SECRET_KEY'].encode() - kdf = PBKDF2HMAC( - algorithm=hashes.SHA256(), - length=32, - salt=b'theme-song-booth-v1', - iterations=480000, - ) - key = base64.urlsafe_b64encode(kdf.derive(secret)) - return Fernet(key) - - -def encrypt_value(value): - """Encrypt a string using the Flask SECRET_KEY. Returns base64 ciphertext.""" - if not value: - return '' - return _get_fernet().encrypt(value.encode()).decode() - - -def decrypt_value(ciphertext): - """Decrypt a string previously encrypted by encrypt_value.""" - if not ciphertext: - return '' - try: - return _get_fernet().decrypt(ciphertext.encode()).decode() - except Exception: - return '' - - -def decrypt_value_legacy(ciphertext): - """Decrypt or return plaintext. Tolerates unencrypted legacy values.""" - if not ciphertext: - return '' - plaintext = decrypt_value(ciphertext) - if plaintext: - return plaintext - # If decryption failed, the value might already be plaintext. - # A Fernet token is base64 and ends with '='; a plain API key does not. - if not ciphertext.endswith('='): - return ciphertext - return '' - - -def settings_file_path(): - """Return the path to the persistent runtime settings JSON file.""" - return Path(current_app.config['DATABASE']).parent / current_app.config['SETTINGS_FILE'] - - -def load_booth_settings(): - """Load persistent runtime settings from JSON file inside the upload parent.""" - cfg_path = settings_file_path() - if cfg_path.exists(): - try: - cfg = json.loads(cfg_path.read_text()) - # Normalize any legacy None metadata/email values to empty strings - # so form fields repopulate correctly after reload. - for key in ('artist', 'album', 'year', 'comment', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_from'): - if cfg.get(key) is None: - cfg[key] = '' - return cfg - except (json.JSONDecodeError, OSError): - pass - return {} - - -def save_booth_settings(settings): - """Persist runtime settings to JSON file.""" - cfg_path = settings_file_path() - try: - cfg_path.write_text(json.dumps(settings, indent=2)) - except OSError as e: - flash(f'Warning: could not save settings: {e}', 'error') - - -def get_email_config(): - """ - Return the effective SMTP configuration. - Runtime settings in booth_settings.json override environment defaults. - The SMTP password is decrypted from the encrypted value stored on disk. - """ - cfg = load_booth_settings() - return { - 'SMTP_HOST': cfg.get('smtp_host', current_app.config['SMTP_HOST']), - 'SMTP_PORT': int(cfg.get('smtp_port') or current_app.config['SMTP_PORT']), - 'SMTP_USER': cfg.get('smtp_user', current_app.config['SMTP_USER']), - 'SMTP_PASS': decrypt_value(cfg.get('smtp_pass', '')) or current_app.config['SMTP_PASS'], - 'SMTP_FROM': cfg.get('smtp_from', current_app.config['SMTP_FROM']), - } - - -def get_refresh_seconds(): - """Return the dashboard auto-refresh interval in seconds (10, 20, or 30).""" - cfg = load_booth_settings() - try: - val = int(cfg.get('refresh_seconds', 10)) - except (ValueError, TypeError): - val = 10 - return val if val in (10, 20, 30) else 10 - - -def get_kiosk_cycle_seconds(): - """Return the kiosk slide cycle interval in seconds. 0 = static price list; 5+ cycles QR and pricing.""" - cfg = load_booth_settings() - try: - val = int(cfg.get('kiosk_cycle_seconds', 10)) - except (ValueError, TypeError): - val = 10 - if val == 0: - return 0 - return max(5, val) - - -def get_kiosk_mode(): - """ - Return 'qr', 'prices', 'queue', or 'cycle' based on kiosk_cycle_seconds setting. - -1 = QR only, 0 = prices only, 1 = queue only, 5+ = cycle through all three. - """ - cfg = load_booth_settings() - try: - val = int(cfg.get('kiosk_cycle_seconds', 10)) - except (ValueError, TypeError): - val = 10 - if val == -1: - return 'qr' - if val == 0: - return 'prices' - if val == 1: - return 'queue' - return 'cycle' - - -def get_max_revisions(): - """Return the effective max revisions as an integer.""" - cfg = load_booth_settings() - try: - val = int(cfg.get('max_revisions', current_app.config.get('MAX_REVISIONS', 2))) - except (ValueError, TypeError): - val = current_app.config.get('MAX_REVISIONS', 2) - return max(0, val) - - -def get_hermes_api_key(): - """ - Return the effective Hermes API key. - Environment variable HERMES_API_KEY overrides any runtime setting. - """ - env_key = current_app.config.get('HERMES_API_KEY', '') - if env_key: - return env_key - cfg = load_booth_settings() - return decrypt_value_legacy(cfg.get('hermes_api_key', '')) - - -def set_hermes_api_key(key): - """Persist a new Hermes API key (encrypted) to runtime settings.""" - cfg = load_booth_settings() - cfg['hermes_api_key'] = encrypt_value(key) - save_booth_settings(cfg) - - -def generate_hermes_api_key(): - """Generate a new random API key for Hermes callback authentication.""" - return secrets.token_urlsafe(32) - - -def mask_api_key(key): - """Return a masked version of the API key showing only the last 6 characters.""" - if not key: - return 'Not set' - if len(key) <= 6: - return '*' * len(key) - return '*' * (len(key) - 6) + key[-6:] - - -def get_ntfy_config(): - """Return the effective ntfy server URL, topic, and access token from runtime settings.""" - cfg = load_booth_settings() - return { - 'server': cfg.get('ntfy_server', ''), - 'topic': cfg.get('ntfy_topic', ''), - 'token': decrypt_value(cfg.get('ntfy_token', '')) or '', - } - - -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() - server = ntfy.get('server', '').rstrip('/') - topic = ntfy.get('topic', '').strip() - if not server or not topic: - return False - - url = f"{server}/{topic}" - headers = { - 'Title': title, - 'Priority': priority, - 'Tags': tags, - } - token = ntfy.get('token', '') - if token: - headers['Authorization'] = f'Bearer {token}' - try: - resp = requests.post(url, data=message.encode('utf-8'), headers=headers, timeout=10) - return resp.status_code in (200, 202) - except Exception: - return False - - -def sign_prompt_callback(rid, expires_at=None): - """ - Create a signed callback token for a specific request ID. - The signature is HMAC-SHA256 over "rid:expires_at" using APP_SECRET_KEY. - Returns a URL-safe token string. - """ - secret = current_app.config['SECRET_KEY'].encode() - if expires_at is None: - # Default expiry: 7 days, so operators have plenty of time to copy the - # callback URL into Hermes and for Hermes to POST back. - expires_at = int(time.time()) + 7 * 24 * 3600 - payload = f"{rid}:{expires_at}" - sig = hmac.new(secret, payload.encode(), hashlib.sha256).hexdigest()[:16] - return f"{rid}:{expires_at}:{sig}" - - -def verify_prompt_callback(token): - """ - Verify a signed callback token. Returns (rid, ok) tuple. - The token must match the HMAC signature and not be expired. - """ - if not token: - return None, False - parts = token.split(':') - if len(parts) != 3: - return None, False - try: - rid = int(parts[0]) - expires_at = int(parts[1]) - except ValueError: - return None, False - if int(time.time()) > expires_at: - return None, False - expected = sign_prompt_callback(rid, expires_at) - if not hmac.compare_digest(expected, token): - return None, False - return rid, True - - -def build_prompt_callback_url(rid): - """Build the full callback URL an operator pastes into Hermes for a request.""" - token = sign_prompt_callback(rid) - return f"{current_app.config['PUBLIC_BASE_URL']}/api/prompt/{rid}?token={token}" - - -def send_email(to, subject, body, attachments=None, inline_images=None): - """Send an email using the configured or runtime SMTP settings.""" - cfg = get_email_config() - if not cfg['SMTP_PASS']: - raise RuntimeError('SMTP password is not configured') - - msg = EmailMessage() - msg['From'] = cfg['SMTP_FROM'] - msg['To'] = to - msg['Subject'] = subject - msg.set_content(body) - - html_body = body.replace('\n', '
\n') - if inline_images: - for _, cid in inline_images: - html_body += f'
Dionysis Media' - html_body += f'


Dionysis Media: stories, sound, and a little divine chaos — https://dionysismedia.ca/

' - msg.add_alternative(html_body, subtype='html') - - if inline_images: - for path, cid in inline_images: - with open(path, 'rb') as f: - data = f.read() - ext = Path(path).suffix.lower().lstrip('.') - subtype = ext if ext in ('png', 'jpg', 'jpeg', 'gif', 'webp') else 'png' - msg.get_payload()[1].add_related(data, maintype='image', subtype=subtype, cid=f'<{cid}>') - - if attachments: - for path, name in attachments: - with open(path, 'rb') as f: - data = f.read() - msg.add_attachment(data, maintype='audio', subtype='mpeg', filename=name) - - with smtplib.SMTP_SSL(cfg['SMTP_HOST'], cfg['SMTP_PORT'], context=ssl.create_default_context()) as server: - server.login(cfg['SMTP_USER'], cfg['SMTP_PASS']) - server.send_message(msg) - -def build_signature_images(): - """Return inline image tuple list for static/DM-Logo_email.png (Dionysis Media logo).""" - logo_path = Path(current_app.root_path) / 'static' / 'DM-Logo_email.png' - if not logo_path.exists(): - return [] - return [(str(logo_path), 'dm-logo')] - - -def get_booth_open(): - """Return True if the booth is currently marked as open in runtime settings.""" - cfg = load_booth_settings() - return cfg.get('booth_open', True) - - # --------------------------------------------------------------------------- # Public customer routes # --------------------------------------------------------------------------- @@ -585,11 +129,12 @@ def request_form(): 'name': request.form.get('name', '').strip(), 'email': request.form.get('email', '').strip().lower(), 'pronouns': pronouns, - 'hobbies': request.form.get('hobbies', '').strip(), - 'notable_facts': request.form.get('notable_facts', '').strip(), + 'hobbies': request.form.get('hobbies', '').strip()[:2000], + 'notable_facts': request.form.get('notable_facts', '').strip()[:2000], 'style_genre': build_style_genre(decade, basic_style, additional_style), - 'extra_requests': request.form.get('extra_requests', '').strip(), + 'extra_requests': request.form.get('extra_requests', '').strip()[:2000], 'vocal_gender': request.form.get('vocal_gender', '').strip(), + 'stems_interest': bool(request.form.get('stems_interest')), } if not is_valid_email(form_data['email']): flash('Please enter a valid email address.', 'error') @@ -626,6 +171,7 @@ def request_form(): f"Hobbies: {req['hobbies'] or '-'}", f"Notable facts: {req['notable_facts'] or '-'}", f"Extra requests: {req['extra_requests'] or '-'}", + f"Interested in STEMS: {'Yes' if req.get('stems_interest') else 'No'}", "", "You'll get another email with a private link to preview two versions of your song when they're ready.", "", @@ -1174,7 +720,8 @@ def admin_request(rid): notable_facts=request.form.get('notable_facts', '').strip(), style_genre=style_genre, vocal_gender=request.form.get('vocal_gender', '').strip(), - extra_requests=request.form.get('extra_requests', '').strip() + extra_requests=request.form.get('extra_requests', '').strip(), + stems_interest=bool(request.form.get('stems_interest')) ) flash('Customer info updated.', 'success') return redirect(url_for('admin_request', rid=rid)) @@ -1270,6 +817,13 @@ def admin_request(rid): except Exception as e: flash(f'Failed to send preview email: {e}', 'error') + elif action == 'update_payment_ref': + # Update the Square payment reference without sending email or changing status. + payment_ref = request.form.get('square_payment_ref', '').strip() + update_request(rid, square_payment_ref=payment_ref) + flash('Payment reference updated.', 'success') + return redirect(url_for('admin_request', rid=rid)) + elif action == 'mark_paid_deliver': # Finalize: record Square payment ref, attach approved MP3s, email customer. if req.get('customer_approved', 'none') == 'none': @@ -1464,7 +1018,7 @@ def admin_settings(): 'style_genre', 'extra_requests', 'vocal_gender', 'status', 'suno_title', 'suno_style', 'suno_lyrics', 'song_a_path', 'song_b_path', 'customer_approved', 'approval_notified_at', 'preview_sent_at', 'delivery_sent_at', - 'square_payment_ref', 'admin_alert_email', 'player_token', 'revision_note', 'revision_count', 'operator_notes', 'stems_link' + 'square_payment_ref', 'admin_alert_email', 'player_token', 'revision_note', 'revision_count', 'operator_notes', 'stems_link', 'stems_interest' } expected_tables = {'requests', 'revision_history'} health = {'ok': True, 'missing_columns': [], 'missing_tables': [], 'message': 'Database schema looks good.'} @@ -1670,6 +1224,20 @@ def admin_settings(): flash(f'Database restore failed: {e}', 'error') return redirect(url_for('admin_settings')) + elif action == 'save_callback_expiry': + # Update the Hermes callback URL expiry lifetime in hours. + try: + val = int(request.form.get('callback_expiry_hours', '168').strip()) + if val < 1: + raise ValueError + cfg = load_booth_settings() + cfg['callback_expiry_hours'] = val + save_booth_settings(cfg) + flash(f'Callback link expiry set to {val} hour(s).', 'success') + except ValueError: + flash('Invalid callback expiry. Please enter a positive number of hours.', 'error') + return redirect(url_for('admin_settings')) + elif action == 'regenerate_hermes_key': # Legacy action: no longer exposed in UI. Key is managed via HERMES_API_KEY env var. flash('Hermes API key is managed via the HERMES_API_KEY environment variable.', 'info') @@ -1727,6 +1295,7 @@ def admin_settings(): hermes_key_set=hermes_key_set, hermes_key_just_generated=hermes_key_just_generated, version=current_app.config['VERSION'], + current_callback_expiry_hours=get_callback_expiry_hours(), ntfy=runtime_settings, ) diff --git a/helpers.py b/helpers.py new file mode 100644 index 0000000..d0804eb --- /dev/null +++ b/helpers.py @@ -0,0 +1,502 @@ +""" +helpers.py +========== +Utility and configuration helpers for the Theme Song Booth Flask app. + +These functions are stateless (or use Flask's current_app / session context) +and are imported by app.py. Keeping them here reduces the size of the route file. +""" + +import os +import re +import shutil +import smtplib +import ssl +import time +import base64 +import hmac +import hashlib +import secrets +import json +from email.message import EmailMessage +from pathlib import Path + +from flask import session, current_app, flash +from werkzeug.utils import secure_filename + +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + +from mutagen.mp3 import MP3 +from mutagen.easyid3 import EasyID3 + +import requests + + +# --------------------------------------------------------------------------- +# Genre / decade helpers +# --------------------------------------------------------------------------- + +_GENRES_PATH = Path('/mnt/Storage/Music Genres.txt') +_DECADES_PATH = Path('/mnt/Storage/Decades.txt') +_FALLBACK_GENRES_PATH = Path(__file__).parent / 'lists' / 'music_genres.txt' +_FALLBACK_DECADES_PATH = Path(__file__).parent / 'lists' / 'decades.txt' + + +def _load_lines(path: Path) -> list[str]: + """Load a text file and return non-empty stripped lines.""" + if not path.exists(): + return [] + lines = path.read_text(encoding='utf-8').splitlines() + return [line.strip() for line in lines if line.strip()] + + +def _load_list(primary: Path, fallback: Path) -> list[str]: + """Load from the primary path, falling back to the bundled copy.""" + lines = _load_lines(primary) + if lines: + return lines + return _load_lines(fallback) + + +MUSIC_GENRES = _load_list(_GENRES_PATH, _FALLBACK_GENRES_PATH) +DECADES = _load_list(_DECADES_PATH, _FALLBACK_DECADES_PATH) + + +def parse_style_genre(style_genre: str | None) -> dict: + """ + Split a stored combined style string into decade, basic, and additional. + The stored format is 'Decade, Basic, Additional' (additional may be empty). + """ + parts = [p.strip() for p in (style_genre or '').split(',') if p.strip()] + return { + 'decade': parts[0] if len(parts) > 0 else '', + 'basic_style': parts[1] if len(parts) > 1 else '', + 'additional_style': ', '.join(parts[2:]) if len(parts) > 2 else '', + } + + +def build_style_genre(decade: str, basic_style: str, additional_style: str) -> str: + """Build the combined style_genre string stored in the database.""" + parts = [p.strip() for p in [decade, basic_style, additional_style] if p.strip()] + return ', '.join(parts) + + +# --------------------------------------------------------------------------- +# Auth / validation helpers +# --------------------------------------------------------------------------- + +def is_admin(): + """Return True if the current browser session is logged in as admin.""" + return session.get('admin') is True + + +def require_admin(): + """Redirect to the admin login page if the user is not logged in.""" + from flask import redirect, url_for + if not is_admin(): + return redirect(url_for('admin_login')) + + +def admin_password_ok(pw): + """Check the submitted admin password against the configured one.""" + return pw and pw == current_app.config['ADMIN_PASSWORD'] + + +def is_valid_email(email): + """Return True if the given string looks like a valid email address.""" + if not email: + return False + pattern = r"^[^\s@]+@[^\s@]+\.[^\s@]+$" + return re.match(pattern, email) is not None + + +# --------------------------------------------------------------------------- +# File upload helpers +# --------------------------------------------------------------------------- + +def allowed_file(filename): + """Return True if the uploaded filename has an allowed extension (mp3).""" + return '.' in filename and filename.rsplit('.', 1)[1].lower() in current_app.config['ALLOWED_EXTENSIONS'] + + +def upload_path(request_id): + """Return the per-request upload directory path, creating it if necessary.""" + p = Path(current_app.config['UPLOAD_FOLDER']) / str(request_id) + p.mkdir(parents=True, exist_ok=True) + return p + + +def save_upload(request_id, file_obj, version, song_title=None): + """ + Save an uploaded MP3 file for a request, preserving the original filename + with a version prefix. Applies the configured metadata tags. + """ + if not file_obj or file_obj.filename == '': + return None + if not allowed_file(file_obj.filename): + flash('Only MP3 files are allowed.', 'error') + return None + original = secure_filename(file_obj.filename) + filename = f"{version.upper()} - {original}" + p = upload_path(request_id) + dest = p / filename + file_obj.save(dest) + apply_mp3_tags(str(dest), song_title) + return str(dest) + + +def apply_mp3_tags(path, title=None): + """ + Write common ID3 tags on an uploaded MP3 using the runtime metadata defaults. + Failures are logged as a warning and do not block the upload. + """ + cfg = load_booth_settings() + try: + audio = MP3(path) + if audio.tags is None: + audio.add_tags() + if not isinstance(audio.tags, EasyID3): + audio.tags = EasyID3() + tags = audio.tags + if title: + tags['title'] = title + if cfg.get('artist'): + tags['artist'] = cfg['artist'] + if cfg.get('album'): + tags['album'] = cfg['album'] + if cfg.get('year'): + tags['date'] = str(cfg['year']) + audio.save() + if cfg.get('comment'): + from mutagen.id3 import COMM, TXXX + audio2 = MP3(path) + if audio2.tags is None: + audio2.add_tags() + audio2.tags["COMM"] = COMM(encoding=3, lang='eng', desc='Comment', text=cfg['comment']) + audio2.tags["TXXX:Comment"] = TXXX(encoding=3, desc='Comment', text=cfg['comment']) + audio2.save() + except Exception as e: + try: + flash(f'Warning: could not tag MP3: {e}', 'error') + except RuntimeError: + import logging + logging.getLogger('app').warning('Could not tag MP3 %s: %s', path, e) + + +# --------------------------------------------------------------------------- +# Encryption / settings helpers +# --------------------------------------------------------------------------- + +def _get_fernet(): + """Derive a Fernet key from the Flask SECRET_KEY so stored values are encrypted.""" + secret = current_app.config['SECRET_KEY'].encode() + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=b'theme-song-booth-v1', + iterations=480000, + ) + key = base64.urlsafe_b64encode(kdf.derive(secret)) + return Fernet(key) + + +def encrypt_value(value): + """Encrypt a string using the Flask SECRET_KEY. Returns base64 ciphertext.""" + if not value: + return '' + return _get_fernet().encrypt(value.encode()).decode() + + +def decrypt_value(ciphertext): + """Decrypt a string previously encrypted by encrypt_value.""" + if not ciphertext: + return '' + try: + return _get_fernet().decrypt(ciphertext.encode()).decode() + except Exception: + return '' + + +def decrypt_value_legacy(ciphertext): + """Decrypt or return plaintext. Tolerates unencrypted legacy values.""" + if not ciphertext: + return '' + plaintext = decrypt_value(ciphertext) + if plaintext: + return plaintext + if not ciphertext.endswith('='): + return ciphertext + return '' + + +def settings_file_path(): + """Return the path to the persistent runtime settings JSON file.""" + return Path(current_app.config['DATABASE']).parent / current_app.config['SETTINGS_FILE'] + + +def load_booth_settings(): + """Load persistent runtime settings from JSON file inside the upload parent.""" + cfg_path = settings_file_path() + if cfg_path.exists(): + try: + cfg = json.loads(cfg_path.read_text()) + for key in ('artist', 'album', 'year', 'comment', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_from'): + if cfg.get(key) is None: + cfg[key] = '' + return cfg + except (json.JSONDecodeError, OSError): + pass + return {} + + +def save_booth_settings(settings): + """Persist runtime settings to JSON file.""" + cfg_path = settings_file_path() + try: + cfg_path.write_text(json.dumps(settings, indent=2)) + except OSError as e: + flash(f'Warning: could not save settings: {e}', 'error') + + +# --------------------------------------------------------------------------- +# Config getters +# --------------------------------------------------------------------------- + +def get_email_config(): + """Return the effective SMTP configuration.""" + cfg = load_booth_settings() + return { + 'SMTP_HOST': cfg.get('smtp_host', current_app.config['SMTP_HOST']), + 'SMTP_PORT': int(cfg.get('smtp_port') or current_app.config['SMTP_PORT']), + 'SMTP_USER': cfg.get('smtp_user', current_app.config['SMTP_USER']), + 'SMTP_PASS': decrypt_value(cfg.get('smtp_pass', '')) or current_app.config['SMTP_PASS'], + 'SMTP_FROM': cfg.get('smtp_from', current_app.config['SMTP_FROM']), + } + + +def get_refresh_seconds(): + """Return the dashboard auto-refresh interval in seconds (10, 20, or 30).""" + cfg = load_booth_settings() + try: + val = int(cfg.get('refresh_seconds', 10)) + except (ValueError, TypeError): + val = 10 + return val if val in (10, 20, 30) else 10 + + +def get_kiosk_cycle_seconds(): + """Return the kiosk slide cycle interval in seconds.""" + cfg = load_booth_settings() + try: + val = int(cfg.get('kiosk_cycle_seconds', 10)) + except (ValueError, TypeError): + val = 10 + if val == 0: + return 0 + return max(5, val) + + +def get_kiosk_mode(): + """Return 'qr', 'prices', 'queue', or 'cycle' based on kiosk_cycle_seconds setting.""" + cfg = load_booth_settings() + try: + val = int(cfg.get('kiosk_cycle_seconds', 10)) + except (ValueError, TypeError): + val = 10 + if val == -1: + return 'qr' + if val == 0: + return 'prices' + if val == 1: + return 'queue' + return 'cycle' + + +def get_max_revisions(): + """Return the effective max revisions as an integer.""" + cfg = load_booth_settings() + try: + val = int(cfg.get('max_revisions', current_app.config.get('MAX_REVISIONS', 2))) + except (ValueError, TypeError): + val = current_app.config.get('MAX_REVISIONS', 2) + return max(0, val) + + +def get_callback_expiry_hours(): + """Return the Hermes signed callback token lifetime in hours (default 168 = 7 days).""" + cfg = load_booth_settings() + try: + val = int(cfg.get('callback_expiry_hours', 168)) + except (ValueError, TypeError): + val = 168 + return max(1, val) + + +def get_hermes_api_key(): + """Return the effective Hermes API key.""" + env_key = current_app.config.get('HERMES_API_KEY', '') + if env_key: + return env_key + cfg = load_booth_settings() + return decrypt_value_legacy(cfg.get('hermes_api_key', '')) + + +def set_hermes_api_key(key): + """Persist a new Hermes API key (encrypted) to runtime settings.""" + cfg = load_booth_settings() + cfg['hermes_api_key'] = encrypt_value(key) + save_booth_settings(cfg) + + +def generate_hermes_api_key(): + """Generate a new random API key for Hermes callback authentication.""" + return secrets.token_urlsafe(32) + + +def mask_api_key(key): + """Return a masked version of the API key showing only the last 6 characters.""" + if not key: + return 'Not set' + if len(key) <= 6: + return '*' * len(key) + return '*' * (len(key) - 6) + key[-6:] + + +def get_ntfy_config(): + """Return the effective ntfy server URL, topic, and access token from runtime settings.""" + cfg = load_booth_settings() + return { + 'server': cfg.get('ntfy_server', ''), + 'topic': cfg.get('ntfy_topic', ''), + 'token': decrypt_value(cfg.get('ntfy_token', '')) or '', + } + + +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() + server = ntfy.get('server', '').rstrip('/') + topic = ntfy.get('topic', '').strip() + if not server or not topic: + return False + + url = f"{server}/{topic}" + headers = { + 'Title': title, + 'Priority': priority, + 'Tags': tags, + } + token = ntfy.get('token', '') + if token: + headers['Authorization'] = f'Bearer {token}' + try: + resp = requests.post(url, data=message.encode('utf-8'), headers=headers, timeout=10) + return resp.status_code in (200, 202) + except Exception: + return False + + +# --------------------------------------------------------------------------- +# Signed callback helpers +# --------------------------------------------------------------------------- + +def sign_prompt_callback(rid, expires_at=None): + """ + Create a signed callback token for a specific request ID. + The signature is HMAC-SHA256 over "rid:expires_at" using APP_SECRET_KEY. + """ + secret = current_app.config['SECRET_KEY'].encode() + if expires_at is None: + expires_at = int(time.time()) + get_callback_expiry_hours() * 3600 + payload = f"{rid}:{expires_at}" + sig = hmac.new(secret, payload.encode(), hashlib.sha256).hexdigest()[:16] + return f"{rid}:{expires_at}:{sig}" + + +def verify_prompt_callback(token): + """Verify a signed callback token. Returns (rid, ok) tuple.""" + if not token: + return None, False + parts = token.split(':') + if len(parts) != 3: + return None, False + try: + rid = int(parts[0]) + expires_at = int(parts[1]) + except ValueError: + return None, False + if int(time.time()) > expires_at: + return None, False + expected = sign_prompt_callback(rid, expires_at) + if not hmac.compare_digest(expected, token): + return None, False + return rid, True + + +def build_prompt_callback_url(rid): + """Build the full callback URL an operator pastes into Hermes for a request.""" + from flask import url_for + token = sign_prompt_callback(rid) + return f"{current_app.config['PUBLIC_BASE_URL']}/api/prompt/{rid}?token={token}" + + +# --------------------------------------------------------------------------- +# Email helpers +# --------------------------------------------------------------------------- + +def send_email(to, subject, body, attachments=None, inline_images=None): + """Send an email using the configured or runtime SMTP settings.""" + cfg = get_email_config() + if not cfg['SMTP_PASS']: + raise RuntimeError('SMTP password is not configured') + + msg = EmailMessage() + msg['From'] = cfg['SMTP_FROM'] + msg['To'] = to + msg['Subject'] = subject + msg.set_content(body) + + html_body = body.replace('\n', '
\n') + if inline_images: + for _, cid in inline_images: + html_body += f'
Dionysis Media' + html_body += f'


Dionysis Media: stories, sound, and a little divine chaos — https://dionysismedia.ca/

' + msg.add_alternative(html_body, subtype='html') + + if inline_images: + for path, cid in inline_images: + with open(path, 'rb') as f: + data = f.read() + ext = Path(path).suffix.lower().lstrip('.') + subtype = ext if ext in ('png', 'jpg', 'jpeg', 'gif', 'webp') else 'png' + msg.get_payload()[1].add_related(data, maintype='image', subtype=subtype, cid=f'<{cid}>') + + if attachments: + for path, name in attachments: + with open(path, 'rb') as f: + data = f.read() + msg.add_attachment(data, maintype='audio', subtype='mpeg', filename=name) + + with smtplib.SMTP_SSL(cfg['SMTP_HOST'], cfg['SMTP_PORT'], context=ssl.create_default_context()) as server: + server.login(cfg['SMTP_USER'], cfg['SMTP_PASS']) + server.send_message(msg) + + +def build_signature_images(): + """Return inline image tuple list for static/DM-Logo_email.png.""" + logo_path = Path(current_app.root_path) / 'static' / 'DM-Logo_email.png' + if not logo_path.exists(): + return [] + return [(str(logo_path), 'dm-logo')] + + +# --------------------------------------------------------------------------- +# Booth state +# --------------------------------------------------------------------------- + +def get_booth_open(): + """Return True if the booth is currently marked as open in runtime settings.""" + cfg = load_booth_settings() + return cfg.get('booth_open', True) diff --git a/models.py b/models.py index f56f30d..0baa0b6 100644 --- a/models.py +++ b/models.py @@ -52,7 +52,8 @@ CREATE TABLE IF NOT EXISTS requests ( revision_count INTEGER DEFAULT 0, revision_note TEXT, operator_notes TEXT, - stems_link TEXT + stems_link TEXT, + stems_interest INTEGER DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status); @@ -107,7 +108,7 @@ def init_db(): 'customer_approved', 'approval_notified_at', 'preview_sent_at', 'delivery_sent_at', 'square_payment_ref', 'admin_alert_email', 'player_token', 'revision_count', 'revision_note', 'operator_notes', - 'stems_link' + 'stems_link', 'stems_interest' ], 'revision_history': [ 'id', 'created_at', 'request_id', 'revision_count', 'note', @@ -135,7 +136,7 @@ def now_utc(): return datetime.now(timezone.utc).isoformat() -def create_request(name, email, hobbies, notable_facts, style_genre, extra_requests, vocal_gender=None, pronouns=None): +def create_request(name, email, hobbies, notable_facts, style_genre, extra_requests, vocal_gender=None, pronouns=None, stems_interest=0): """ Insert a new customer request. Returns the auto-generated request id. @@ -143,9 +144,9 @@ def create_request(name, email, hobbies, notable_facts, style_genre, extra_reque db = get_db() cur = db.execute( """INSERT INTO requests - (name, email, hobbies, notable_facts, style_genre, pronouns, extra_requests, vocal_gender, player_token) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", - (name, email, hobbies, notable_facts, style_genre, pronouns, extra_requests, vocal_gender, new_token()) + (name, email, hobbies, notable_facts, style_genre, pronouns, extra_requests, vocal_gender, player_token, stems_interest) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (name, email, hobbies, notable_facts, style_genre, pronouns, extra_requests, vocal_gender, new_token(), 1 if stems_interest else 0) ) db.commit() return cur.lastrowid diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html index 5dbaf78..c769d4a 100644 --- a/templates/admin/dashboard.html +++ b/templates/admin/dashboard.html @@ -131,7 +131,7 @@
All Pending - Needs Upload + Needs Upload Awaiting Payment Delivered diff --git a/templates/admin/request.html b/templates/admin/request.html index 5d50cf7..75a4628 100644 --- a/templates/admin/request.html +++ b/templates/admin/request.html @@ -245,7 +245,12 @@ - + + +
@@ -433,6 +438,17 @@ {% if req.square_payment_ref %}

Square Payment Reference: {{ req.square_payment_ref }}

{% endif %} +
+ + + +
+ +
+
+ +
+
@@ -460,8 +476,8 @@

No files available. Upload songs first.

{% endfor %} - - Square Payment Reference (required to deliver) +
@@ -492,9 +508,10 @@ notable_facts: {{ req.notable_facts | tojson }}, style_genre: styleGenre, vocal_gender: {{ req.vocal_gender | tojson }}, - extra_requests: {{ req.extra_requests | tojson }} + extra_requests: {{ req.extra_requests | tojson }}, + stems_interest: {{ (req.stems_interest or 0) | tojson }} }; - const text = `Booth request: ${data.request_id}\\nCustomer email: ${data.email}\\nCallback URL: ${callbackUrl}\\n\\nGenerate a Suno Custom Mode prompt for this customer and POST it back to the Callback URL as JSON.\\n\\nCustomer data:\\nName: ${data.name}\\nPronouns: ${data.pronouns || '-'}\\nHobbies: ${data.hobbies || '-'}\\nNotable facts: ${data.notable_facts || '-'}\\nStyle / genre: ${styleSentence || styleGenre || '-'}\\nPreferred singer voice / gender: ${data.vocal_gender || 'No preference'}\\nExtra requests: ${data.extra_requests || '-'}\\n\\nExpected JSON response format:\\n{\\n \\"request_id\\": ${data.request_id},\\n \\"email\\": \\"${data.email}\\",\\n \\"suno_title\\": \\"...\\",\\n \\"suno_style\\": \\"...\\",\\n \\"suno_lyrics\\": \\"...\\"\\n}`; + const text = `Booth request: ${data.request_id}\\nCustomer email: ${data.email}\\nCallback URL: ${callbackUrl}\\n\\nGenerate a Suno Custom Mode prompt for this customer and POST it back to the Callback URL as JSON.\\n\\nCustomer data:\\nName: ${data.name}\\nPronouns: ${data.pronouns || '-'}\\nHobbies: ${data.hobbies || '-'}\\nNotable facts: ${data.notable_facts || '-'}\\nStyle / genre: ${styleSentence || styleGenre || '-'}\\nPreferred singer voice / gender: ${data.vocal_gender || 'No preference'}\\nExtra requests: ${data.extra_requests || '-'}\\nInterested in STEMS: ${data.stems_interest ? 'Yes' : 'No'}\\n\\nExpected JSON response format:\\n{\\n \\"request_id\\": ${data.request_id},\\n \\"email\\": \\"${data.email}\\",\\n \\"suno_title\\": \\"...\\",\\n \\"suno_style\\": \\"...\\",\\n \\"suno_lyrics\\": \\"...\\"\\n}`; navigator.clipboard.writeText(text).then(() => alert("Copied! Paste into Hermes. Hermes will POST the generated prompt back to the Callback URL.")); } @@ -527,9 +544,10 @@ notable_facts: {{ req.notable_facts | tojson }}, style_genre: styleGenre, vocal_gender: {{ req.vocal_gender | tojson }}, - extra_requests: {{ req.extra_requests | tojson }} + extra_requests: {{ req.extra_requests | tojson }}, + stems_interest: {{ (req.stems_interest or 0) | tojson }} }; - const text = `Booth request: ${data.request_id}\\nCustomer email: ${data.email}\\nCallback URL: ${callbackUrl}\\n\\nThis is REVISION #${revisionCount || 1} for this customer.\\n\\nGenerate a NEW Suno Custom Mode prompt that addresses the following revision request, while keeping the same overall theme/persona and matching the customer's original brief as closely as possible.\\n\\nOriginal brief:\\nName: ${data.name}\\nPronouns: ${data.pronouns || '-'}\\nHobbies: ${data.hobbies || '-'}\\nNotable facts: ${data.notable_facts || '-'}\\nStyle / genre: ${styleSentence || styleGenre || '-'}\\nPreferred singer voice / gender: ${data.vocal_gender || 'No preference'}\\nExtra requests: ${data.extra_requests || '-'}\\n\\nPreviously generated prompt (do not copy verbatim; adapt and improve):\\nTitle: ${currentTitle || '-'}\\nStyle: ${currentStyle || '-'}\\nLyrics:\\n${currentLyrics || '-'}\\n\\nRevision request from customer:\\n${revisionNote || '-'}\\n\\nPOST the new prompt back to the Callback URL as JSON with this shape:\\n{\\n \\"request_id\\": ${data.request_id},\\n \\"email\\": \\"${data.email}\\",\\n \\"suno_title\\": \\"...\\",\\n \\"suno_style\\": \\"...\\",\\n \\"suno_lyrics\\": \\"...\\"\\n}`; + const text = `Booth request: ${data.request_id}\\nCustomer email: ${data.email}\\nCallback URL: ${callbackUrl}\\n\\nThis is REVISION #${revisionCount || 1} for this customer.\\n\\nGenerate a NEW Suno Custom Mode prompt that addresses the following revision request, while keeping the same overall theme/persona and matching the customer's original brief as closely as possible.\\n\\nOriginal brief:\\nName: ${data.name}\\nPronouns: ${data.pronouns || '-'}\\nHobbies: ${data.hobbies || '-'}\\nNotable facts: ${data.notable_facts || '-'}\\nStyle / genre: ${styleSentence || styleGenre || '-'}\\nPreferred singer voice / gender: ${data.vocal_gender || 'No preference'}\\nExtra requests: ${data.extra_requests || '-'}\\nInterested in STEMS: ${data.stems_interest ? 'Yes' : 'No'}\\n\\nPreviously generated prompt (do not copy verbatim; adapt and improve):\\nTitle: ${currentTitle || '-'}\\nStyle: ${currentStyle || '-'}\\nLyrics:\\n${currentLyrics || '-'}\\n\\nRevision request from customer:\\n${revisionNote || '-'}\\n\\nPOST the new prompt back to the Callback URL as JSON with this shape:\\n{\\n \\"request_id\\": ${data.request_id},\\n \\"email\\": \\"${data.email}\\",\\n \\"suno_title\\": \\"...\\",\\n \\"suno_style\\": \\"...\\",\\n \\"suno_lyrics\\": \\"...\\"\\n}`; navigator.clipboard.writeText(text).then(() => alert("Revision prompt copied! Paste into Hermes. Hermes will POST the revised prompt back to the Callback URL.")); } diff --git a/templates/admin/settings.html b/templates/admin/settings.html index f94a0f2..7f94a79 100644 --- a/templates/admin/settings.html +++ b/templates/admin/settings.html @@ -291,6 +291,17 @@ {% if not hermes_key_set %}

⚠️ No Hermes API key is configured. Set the HERMES_API_KEY environment variable in Portainer before using the callback workflow.

{% endif %} + +
+ +

Callback Link Expiry

+

How long the signed Hermes callback URL stays valid (in hours). Default is 168 hours (7 days).

+ + + + + +
diff --git a/templates/request.html b/templates/request.html index 3030627..2e6e78a 100644 --- a/templates/request.html +++ b/templates/request.html @@ -149,10 +149,10 @@ - + - + - + + + From 0e50908747cc3ee8b7cfb2802d31553e6f0feccf Mon Sep 17 00:00:00 2001 From: "Troll (Hermes Agent)" Date: Sun, 9 Aug 2026 16:18:00 +0000 Subject: [PATCH 06/10] v0.6.1: remove duplicate is_valid_email from app.py (re now lives in helpers) --- README.md | 2 +- VERSION | 2 +- app.py | 9 --------- 3 files changed, 2 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index bfa8d5c..a7e8bb8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Theme Song Booth -**Version:** `v0.6.0` +**Version:** `v0.6.1` 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. diff --git a/VERSION b/VERSION index a918a2a..ee6cdce 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.0 +0.6.1 diff --git a/app.py b/app.py index a3d5e4f..5aa1029 100644 --- a/app.py +++ b/app.py @@ -494,15 +494,6 @@ def admin_login(): return render_template('admin/login.html') -def is_valid_email(email): - """Return True if the given string looks like a valid email address.""" - if not email: - return False - # Very loose regex: local@domain.tld, no spaces, with a real TLD part. - pattern = r"^[^\s@]+@[^\s@]+\.[^\s@]+$" - return re.match(pattern, email) is not None - - @app.route('/admin/logout') def admin_logout(): """Clear the admin session.""" From 55de2ab649eba2ade70c0549dea2ad21ef99ae7b Mon Sep 17 00:00:00 2001 From: "Troll (Hermes Agent)" Date: Sun, 9 Aug 2026 16:21:47 +0000 Subject: [PATCH 07/10] v0.6.2: restore hmac import in app.py for callback/API key auth --- README.md | 2 +- VERSION | 2 +- app.py | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a7e8bb8..1d0fb4d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Theme Song Booth -**Version:** `v0.6.1` +**Version:** `v0.6.2` 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. diff --git a/VERSION b/VERSION index ee6cdce..b616048 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.1 +0.6.2 diff --git a/app.py b/app.py index 5aa1029..8b32217 100644 --- a/app.py +++ b/app.py @@ -30,6 +30,7 @@ Admin routes: # Standard library imports import os +import hmac import shutil import time from pathlib import Path From d058d8db1d6134f9788311d3795a174db3fcb616 Mon Sep 17 00:00:00 2001 From: "Troll (Hermes Agent)" Date: Sun, 9 Aug 2026 16:42:06 +0000 Subject: [PATCH 08/10] v0.6.3: single Square payment ref field with update/deliver buttons --- README.md | 2 +- VERSION | 2 +- templates/admin/request.html | 20 +++++++------------- 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 1d0fb4d..147bbf8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Theme Song Booth -**Version:** `v0.6.2` +**Version:** `v0.6.3` 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. diff --git a/VERSION b/VERSION index b616048..844f6a9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.2 +0.6.3 diff --git a/templates/admin/request.html b/templates/admin/request.html index 75a4628..9c3cc79 100644 --- a/templates/admin/request.html +++ b/templates/admin/request.html @@ -439,18 +439,15 @@

Square Payment Reference: {{ req.square_payment_ref }}

{% endif %}
- - - + + + +
- +
-
-
- -
- +

Select files to deliver:

{% set all_files = [] %} @@ -476,11 +473,8 @@

No files available. Upload songs first.

{% endfor %} - -
- +
{% if req.customer_approved == 'none' %}

⚠️ Customer must approve a version before you can mark paid or deliver.

From d7ed56f34e6034a726d8102576b65d6cd9cb9852 Mon Sep 17 00:00:00 2001 From: "Troll (Hermes Agent)" Date: Mon, 24 Aug 2026 22:49:08 +0000 Subject: [PATCH 09/10] 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//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/ - Add test button, update README, bump version to 0.7.0 --- README.md | 33 +++++++------ VERSION | 2 +- app.py | 69 +++++++++++++++++++++++++++ helpers.py | 90 +++++++++++++++++++++++++++++++++++ templates/admin/request.html | 3 ++ templates/admin/settings.html | 31 ++++++++++++ 6 files changed, 213 insertions(+), 15 deletions(-) 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

From 6919ac816e95ab294837db488af5c330be6f0819 Mon Sep 17 00:00:00 2001 From: "Troll (Hermes Agent)" Date: Mon, 24 Aug 2026 22:57:05 +0000 Subject: [PATCH 10/10] Revert "feat: send song requests to Hermes via webhook" This reverts commit d7ed56f34e6034a726d8102576b65d6cd9cb9852. --- README.md | 33 ++++++------- VERSION | 2 +- app.py | 69 --------------------------- helpers.py | 90 ----------------------------------- templates/admin/request.html | 3 -- templates/admin/settings.html | 31 ------------ 6 files changed, 15 insertions(+), 213 deletions(-) diff --git a/README.md b/README.md index db2b28d..147bbf8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Theme Song Booth -**Version:** `v0.7.0` +**Version:** `v0.6.3` 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,13 +25,12 @@ 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 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. +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. --- @@ -75,10 +74,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, **Send to Hermes** webhook, 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, 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, Hermes webhook configuration, 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 management, and system reset. | | Reset | `/admin/reset` | Clears all requests and uploaded files. Requires admin password confirmation. | --- @@ -111,14 +110,9 @@ 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 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. +- Copy this key into your Hermes skill or AI assistant config. ### Customer revision limit - Maximum number of times a customer can click **Request Changes** on the player page. @@ -180,8 +174,9 @@ 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 configure the **Hermes webhook** URL and secret. -9. Print or display a QR code pointing to `https://your-domain/request`. +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`. ### Updating the deployment diff --git a/VERSION b/VERSION index faef31a..844f6a9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.0 +0.6.3 diff --git a/app.py b/app.py index 7662a2a..8b32217 100644 --- a/app.py +++ b/app.py @@ -57,7 +57,6 @@ 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, @@ -906,37 +905,6 @@ 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.""" @@ -1279,42 +1247,6 @@ 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() @@ -1357,7 +1289,6 @@ 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 ca1ad89..d0804eb 100644 --- a/helpers.py +++ b/helpers.py @@ -374,96 +374,6 @@ 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 f64533b..9c3cc79 100644 --- a/templates/admin/request.html +++ b/templates/admin/request.html @@ -326,9 +326,6 @@

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 2e0b1d7..7f94a79 100644 --- a/templates/admin/settings.html +++ b/templates/admin/settings.html @@ -339,37 +339,6 @@
- -
-

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