A FastAPI service that processes song requests for WXYC radio. It parses natural language messages using Groq AI, delegates search to library-metadata-lookup, and posts enriched results to Slack.
- Smart Song Parsing: Uses Groq AI to extract structured metadata from natural language song requests
- Library and Discogs Search: Delegates to library-metadata-lookup for library catalog search and Discogs cross-referencing
- Slack Integration: Posts enriched song data to Slack with embedded artwork
- Fast API: Built with FastAPI for high performance and automatic API documentation
- Python 3.12 or higher
- pip (Python package installer) or use the included
pyproject.tomlfor modern package management
git clone <repository-url>
cd request-o-maticpython -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activateuv users can skip this step —
uv sync(below) creates and manages.venv/automatically.
Dependencies are pinned via uv.lock (the single source of truth); requirements.txt (runtime) and requirements-dev.txt (runtime + dev tools) are generated from it. See docs/deployment.md for the policy and the regenerate/bump procedure.
Recommended — using uv (installs the exact locked versions into .venv/):
uv sync --extra dev # omit --extra dev for a runtime-only environmentUsing pip (from the pinned requirements files):
pip install -r requirements-dev.txt # or requirements.txt for runtime onlyAvoid
pip install -e ./pip install -e ".[dev]"for routine setup — they re-resolve dependencies from PyPI and ignoreuv.lock, so local versions can silently drift from what CI and Railway run. Bump deliberately via the procedure in docs/deployment.md.
Copy the example environment file and update with your values:
cp .env.example .envThen edit .env with your actual configuration:
# Required
GROQ_API_KEY=your_groq_api_key_here
LOOKUP_SERVICE_URL=https://library-metadata-lookup-staging.up.railway.app/api/v1
# Optional - Slack Integration (legacy webhook, default transport)
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL
# Optional - Slack Integration (bot-token transport, behind SLACK_USE_BOT_TOKEN)
SLACK_USE_BOT_TOKEN=false
SLACK_BOT_TOKEN=xoxb-your-bot-token
SLACK_CHANNEL_ID=C0123456789
# Optional - Slack "Ban requester" menu (see docs/admin-bans.md). Both
# fail closed when unset: no signing secret means every /slack/interactivity
# callback is rejected, and an empty allowlist contributes nobody to the
# authorized set. SLACK_BOT_TOKEN above is also required for this flow.
#
# SLACK_BAN_AUTHORIZED_USERS is the break-glass list, not the moderator
# roster: the roster lives in Backend-Service (BS_INTERNAL_MODERATORS_URL)
# and is edited from Slack with /request-mods. Who can ban is the union.
SLACK_SIGNING_SECRET=
SLACK_BAN_AUTHORIZED_USERS=U01ABC,U02DEF
BS_INTERNAL_MODERATORS_URL=https://api.wxyc.org/internal/slack-ban-moderators
# Optional - Telemetry
POSTHOG_API_KEY=your_posthog_project_api_key
POSTHOG_HOST=https://us.i.posthog.com
# Application Configuration
LOG_LEVEL=INFO
PORT=8000
# Feature Flags
ENABLE_SLACK_INTEGRATION=true- GROQ_API_KEY: Sign up at Groq (not Grok) to get an API key
- SLACK_WEBHOOK_URL: Create an incoming webhook in your Slack workspace's App Settings. Used unless
SLACK_USE_BOT_TOKEN=true. - SLACK_BOT_TOKEN / SLACK_CHANNEL_ID: Only needed when
SLACK_USE_BOT_TOKEN=true. Install a Slack app with thechat:writescope, copy its bot token (xoxb-...), and/invitethe bot into the target channel -- the app does not havechat:write.public, so an un-invited channel fails every post withnot_in_channel. - POSTHOG_API_KEY: Optional - Get your project API key from PostHog for telemetry tracking
python main.pyuvicorn main:app --reload --host 0.0.0.0 --port 8000The --reload flag enables auto-reloading during development.
The application will start on http://localhost:8000
- Interactive API Documentation: http://localhost:8000/docs (Swagger UI - Try out endpoints here!)
- Read-Only Docs: http://localhost:8000/redoc (ReDoc - Beautiful documentation)
- Health Check: http://localhost:8000/health (Detailed service status)
Note: All API endpoints (except /health) are versioned under /api/v1/ prefix.
# Build the image
docker build -t request-o-matic .
# Run the container
docker run -p 8000:8000 \
-e GROQ_API_KEY=your_groq_api_key \
-e LOOKUP_SERVICE_URL=https://library-metadata-lookup-staging.up.railway.app/api/v1 \
-e SLACK_WEBHOOK_URL=your_slack_webhook \
request-o-maticCreate a docker-compose.yml:
version: '3.8'
services:
app:
build: .
ports:
- "8000:8000"
environment:
- GROQ_API_KEY=${GROQ_API_KEY}
- DISCOGS_TOKEN=${DISCOGS_TOKEN}
- SLACK_WEBHOOK_URL=${SLACK_WEBHOOK_URL}
env_file:
- .envThen run:
docker-compose upAll endpoints except /health and /admin/* are prefixed with /api/v1:
GET /health- Health check with service status details (groq, lookup, slack)POST /api/v1/parse- Parse a natural language song request into structured metadataPOST /api/v1/request- Full request workflow: parse -> delegate search to lookup service -> post to Slack
Request-line ban management (Authorization: Bearer $ADMIN_TOKEN). All writes are proxied to Backend-Service; request-o-matic owns no ban state. Full operator runbook in docs/admin-bans.md.
POST /admin/bans- Create or update a ban for a fingerprint (idempotent)DELETE /admin/bans/{fingerprint}- Remove a ban (idempotent)GET /admin/bans- List bans (keyset-paginated)
POST /slack/interactivity- The Slack app's single interactivity Request URL. Handles the "Ban requester" overflow-menu item (opens a reason modal, then bans on submit) viaservices/ban_service.py-- the same function the admin endpoints above call -- and the/request-modsroster save, whose modal is opened byPOST /slack/commandsbut submitted here because Slack delivers every modal submission in the app to this one URL. Seedocs/admin-bans.md.POST /slack/commands- Slash-command Request URL for/request-mods, which opens the moderator-roster picker. Every refusal is a 200 with an ephemeral body rather than achat.postEphemeralcall, so a deploy withoutSLACK_BOT_TOKENrefuses visibly instead of silently.
Parse a message:
curl -X POST "http://localhost:8000/api/v1/parse" \
-H "Content-Type: application/json" \
-d '{"message": "Play la paradoja by Juana Molina"}'Full request workflow:
curl -X POST "http://localhost:8000/api/v1/request" \
-H "Content-Type: application/json" \
-d '{"message": "Play la paradoja by Juana Molina"}'Full request workflow (bypass the lookup cache):
curl -X POST "http://localhost:8000/api/v1/request" \
-H "Content-Type: application/json" \
-d '{"message": "Play la paradoja by Juana Molina", "skip_cache": true}'The skip_cache flag is forwarded as ?skip_cache=true to library-metadata-lookup, bypassing that service's caches so the lookup resolves against fresh data. Useful for benchmarking and cache A/B comparisons — see scripts/benchmark_requests.py and docs/benchmark-results.md.
Health check:
curl "http://localhost:8000/health"Run all tests (excluding integration):
pytestRun unit tests only:
pytest tests/unit/Run integration tests (real Groq API):
pytest tests/integration/ -m external_apiRun performance suite (slow, real Groq API):
pytest tests/performance/ -m "external_api and slow"Run with coverage:
pytest --cov=. --cov-report=htmlThe project is configured with modern Python tooling via pyproject.toml:
Format code:
black .Lint code:
ruff check .Fix linting issues automatically:
ruff check --fix .Type checking:
mypy .Run all quality checks:
black . && ruff check --fix . && mypy . && pytest- Create a feature branch
- Make your changes
- Run tests and linters
- Submit a pull request
The project uses:
- Pydantic Settings for type-safe configuration
- FastAPI dependency injection for clean architecture
- Async/await throughout for performance
- Comprehensive logging with structured output
- Custom exceptions for better error handling
Ensure your .env file exists in the project root and contains:
GROQ_API_KEY=your_actual_key_here
If port 8000 is already in use, specify a different port:
uvicorn main:app --port 8001If Slack integration fails:
- With the default webhook transport: verify
SLACK_WEBHOOK_URLis correct, or that the app can fetch one from Railway if it's unset. - With
SLACK_USE_BOT_TOKEN=true: verifySLACK_BOT_TOKENandSLACK_CHANNEL_IDare both set. Anot_in_channelerror means the bot hasn't been/invited intoSLACK_CHANNEL_ID-- the app haschat:writebut notchat:write.public. - Check that your Slack app has proper permissions.
- If you are looking for the "Ban requester" menu on a request post: it is not there, deliberately. Public posts carry no ban affordance (Slack renders one payload to the whole channel, so the menu was visible to every DJ and usable only by the roster); ban via the
curlpath indocs/admin-bans.md. If the menu's modal won't open once it is re-homed to a moderators channel, or a submit silently does nothing: checkSLACK_SIGNING_SECRETandSLACK_BOT_TOKENare both set, and that the Slack app's interactivity Request URL points at this deployment's/slack/interactivity. A 401 there means either the signing secret is wrong/unset or the request is stale (Slack's 5-minute replay window).
| Variable | Required | Default | Description |
|---|---|---|---|
GROQ_API_KEY |
Yes | - | API key for Groq AI service |
LOOKUP_SERVICE_URL |
Yes | - | Base URL of library-metadata-lookup service |
SLACK_WEBHOOK_URL |
No | - | Slack incoming webhook URL (fetches from Railway if not set); used unless SLACK_USE_BOT_TOKEN=true |
SLACK_WEBHOOK_KEY_URL |
No | - | Railway endpoint to fetch Slack webhook key |
SLACK_USE_BOT_TOKEN |
No | false | Post via chat.postMessage with SLACK_BOT_TOKEN instead of the incoming webhook |
SLACK_BOT_TOKEN |
No | - | Slack bot token (xoxb-...); required when SLACK_USE_BOT_TOKEN=true |
SLACK_CHANNEL_ID |
No | - | Channel ID to post to via chat.postMessage; required when SLACK_USE_BOT_TOKEN=true |
PORT |
No | 8000 | Port for the application to listen on |
HOST |
No | 0.0.0.0 | Host to bind the server to |
LOG_LEVEL |
No | INFO | Logging level (DEBUG, INFO, WARNING, ERROR) |
ENABLE_SLACK_INTEGRATION |
No | true | Enable/disable Slack notifications |
ENABLE_TELEMETRY |
No | true | Enable/disable PostHog telemetry |
POSTHOG_API_KEY |
No | - | PostHog project API key for telemetry tracking |
POSTHOG_HOST |
No | https://us.i.posthog.com | PostHog host URL |
SENTRY_DSN |
No | - | Sentry DSN for error tracking |
ADMIN_TOKEN |
No | - | Bearer token gating /admin/bans. Fail-closed when unset. |
BS_INTERNAL_BANS_URL |
No | - | Base URL of Backend-Service's /internal/banned-fingerprints CRUD (BS#1261). |
BS_INTERNAL_MODERATORS_URL |
No | - | Base URL of Backend-Service's /internal/slack-ban-moderators roster (BS#2045), backing /request-mods. Unset falls back to SLACK_BAN_AUTHORIZED_USERS alone and makes /request-mods refuse visibly — it does not 503 like the bans URL. |
BS_INTERNAL_KEY |
No | - | Shared secret forwarded as X-Internal-Key on calls to BS internal endpoints. Shared by both BS_INTERNAL_*_URL surfaces. |
SLACK_SIGNING_SECRET |
No | - | Verifies X-Slack-Signature on POST /slack/interactivity and POST /slack/commands. Fail-closed when unset. |
SLACK_BAN_AUTHORIZED_USERS |
No | - | Comma-separated Slack user IDs. The break-glass list since #240, not the roster — who can ban is the union of this and the Backend-Service roster edited via /request-mods. Contributes nobody when unset or empty (fail-closed). |
- Service Delegation: All library search and Discogs cross-referencing is delegated to library-metadata-lookup via HTTP
- Dependency Injection: FastAPI's dependency injection system manages service lifecycle and makes testing easier
- Centralized Configuration: Pydantic Settings for type-safe, validated configuration
- Async Throughout: All I/O operations use async/await for optimal performance
- Custom Exceptions: Domain-specific exceptions for better error handling and debugging
- Error Tracking: Sentry integration for production error monitoring with breadcrumbs for debugging
Services are managed through FastAPI's lifespan context manager:
- HTTP clients are reused across requests
- Resources are properly cleaned up at shutdown
- Hosted on Railway
mainbranch auto-deploys to stagingprodbranch auto-deploys to production