diff --git a/.dockerignore b/.dockerignore index d7600d5..8ab33c7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,4 @@ .git -__pycache__ -*.pyc -.env -bellhop.db +data/ +config.yaml +bellhop diff --git a/.env.example b/.env.example deleted file mode 100644 index c75778a..0000000 --- a/.env.example +++ /dev/null @@ -1,30 +0,0 @@ -# Matrix homeserver -MATRIX_HOMESERVER_URL=https://matrix.example.com - -# Matrix audit bot (pre-authenticated) -MATRIX_AUDIT_ROOM_ID=!roomid:example.com -MATRIX_BOT_USER_ID=@bot:example.com -MATRIX_BOT_ACCESS_TOKEN=syt_bot_token_here - -# Radarr -RADARR_URL=https://radarr.example.com -RADARR_API_KEY=your_radarr_api_key -RADARR_QUALITY_PROFILE_ID=1 -RADARR_ROOT_FOLDER=/movies - -# Sonarr -SONARR_URL=https://sonarr.example.com -SONARR_API_KEY=your_sonarr_api_key -SONARR_QUALITY_PROFILE_ID=1 -SONARR_ROOT_FOLDER=/tv - -# Lidarr -LIDARR_URL=https://lidarr.example.com -LIDARR_API_KEY=your_lidarr_api_key -LIDARR_QUALITY_PROFILE_ID=1 -LIDARR_ROOT_FOLDER=/music - -# App -# SESSION_SECRET_KEY is auto-generated if omitted. A new key on every restart invalidates all sessions. -# SESSION_SECRET_KEY= -DATABASE_PATH=bellhop.db diff --git a/.gitignore b/.gitignore index c52f830..7e43fd4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,3 @@ -__pycache__/ -*.pyc -.env -bellhop.db -*.sqlite -*.sqlite3 +/bellhop +/data/ +config.yaml diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 385ccd3..0000000 --- a/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -FROM python:3.12-slim - -WORKDIR /app - -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -COPY . . - -EXPOSE 8000 - -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/SESSION_PLAN.md b/SESSION_PLAN.md new file mode 100644 index 0000000..15f6528 --- /dev/null +++ b/SESSION_PLAN.md @@ -0,0 +1,57 @@ +# Bellhop Go Rewrite — Session Plan + +Rewriting Bellhop from a Python FastAPI web portal into a Go-based Matrix command bot, modeled after [Pete](../pete). Users issue commands like `!movie dune` in allowlisted Matrix rooms; the bot performs the top-hit search against Radarr/Sonarr/Lidarr and adds the result. + +## Design decisions (locked in) + +- **Command UX:** top-hit auto-add (no numbered picker, no reaction selector). +- **Authz:** any member of an allowlisted room ID can issue commands. No per-user list. +- **Old Python code:** deleted, clean-slate Go rewrite. +- **Module layout:** mirrors Pete (`main.go` + `internal//`). +- **Config:** YAML with `${ENV_VAR}` expansion (Pete-style). +- **Matrix client:** mautrix-go with cryptohelper for E2EE support. + +## Session breakdown + +- [x] **Session 1 — Foundation** + - Delete Python (`app/`, `requirements.txt`, `Dockerfile`, `.env.example`) + - Reset `.gitignore` / `.dockerignore` for Go + - `go.mod` (mautrix + yaml) + - `internal/config/config.go` — YAML loader: matrix creds, `allowed_rooms`, `services.{radarr,sonarr,lidarr}` + +- [ ] **Session 2 — Matrix client** (`internal/matrix/`) + - Adapt Pete's `client.go`: login + device persistence + cryptohelper + - Sync loop, auto-join on invite + - Message handler scoped to `allowed_rooms` (not channel-name map like Pete) + - Threaded reply helper for command responses + - Drop Pete-specific bits: image upload, reaction handler, story formatting + +- [ ] **Session 3 — *arr clients** (`internal/arr/`) + - One file per service or a single generic client (TBD in session) + - `Search(term) → []Result` and `Add(result) → error` + - Radarr: `/api/v3/movie/lookup` + POST `/api/v3/movie` + - Sonarr: `/api/v3/series/lookup` + POST `/api/v3/series` + - Lidarr: `/api/v1/artist/lookup` + POST `/api/v1/artist` + - All adds: monitored=true, search-on-add=true, quality profile + root folder from config + - Unit tests with httptest + +- [ ] **Session 4 — Command dispatch + main** (`internal/bot/`, `main.go`) + - Parse ` ` (default prefix `!`) + - Commands: `movie`, `tv`, `music`, `help` + - On match: search → take `[0]` → add → reply with title/year (or error) + - "Service not configured" reply if the corresponding *arr block is absent + - `main.go`: load config, init matrix, wire handler, SIGINT/SIGTERM shutdown + +- [ ] **Session 5 — Ship polish** + - Multi-stage Dockerfile (`golang:1.25-alpine` → `alpine:3.21`, build with `-tags goolm`) + - `config.example.yaml` + - Rewrite `README.md`: command UX, install, config reference, Docker + - `go build ./...` + `go vet ./...` clean + +## Out of scope (for now) + +- Reaction-based result picker +- Per-user allowlist or power-level gating +- Persistent request audit log (the Matrix room itself is the audit trail since requests are in-channel) +- Removal/cancellation commands +- Search-only mode (browse without adding) diff --git a/app/__init__.py b/app/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/arr.py b/app/arr.py deleted file mode 100644 index 2f11806..0000000 --- a/app/arr.py +++ /dev/null @@ -1,234 +0,0 @@ -"""Proxy layer for Radarr, Sonarr, and Lidarr APIs.""" - -import httpx -from fastapi import APIRouter, Request -from fastapi.responses import JSONResponse - -from app.auth import require_session -from app.audit import send_audit_message -from app.config import ( - LIDARR_API_KEY, - LIDARR_QUALITY_PROFILE_ID, - LIDARR_ROOT_FOLDER, - LIDARR_URL, - RADARR_API_KEY, - RADARR_QUALITY_PROFILE_ID, - RADARR_ROOT_FOLDER, - RADARR_URL, - SONARR_API_KEY, - SONARR_QUALITY_PROFILE_ID, - SONARR_ROOT_FOLDER, - SONARR_URL, -) - -router = APIRouter(tags=["arr"]) - -SERVICE_CONFIG = { - "movie": { - "url": RADARR_URL, - "api_key": RADARR_API_KEY, - "quality_profile_id": RADARR_QUALITY_PROFILE_ID, - "root_folder": RADARR_ROOT_FOLDER, - "lookup_path": "/api/v3/movie/lookup", - "add_path": "/api/v3/movie", - "label": "Movie", - }, - "tv": { - "url": SONARR_URL, - "api_key": SONARR_API_KEY, - "quality_profile_id": SONARR_QUALITY_PROFILE_ID, - "root_folder": SONARR_ROOT_FOLDER, - "lookup_path": "/api/v3/series/lookup", - "add_path": "/api/v3/series", - "label": "Show", - }, - "music": { - "url": LIDARR_URL, - "api_key": LIDARR_API_KEY, - "quality_profile_id": LIDARR_QUALITY_PROFILE_ID, - "root_folder": LIDARR_ROOT_FOLDER, - "lookup_path": "/api/v1/artist/lookup", - "add_path": "/api/v1/artist", - "label": "Artist", - }, -} - -VALID_TYPES = set(SERVICE_CONFIG.keys()) - - -def _headers(api_key: str) -> dict[str, str]: - return {"X-Api-Key": api_key} - - -def _safe_result_movie(item: dict) -> dict: - return { - "title": item.get("title", ""), - "year": item.get("year"), - "tmdbId": item.get("tmdbId"), - "overview": item.get("overview", ""), - "remotePoster": item.get("remotePoster", ""), - "hasFile": item.get("hasFile", False), - } - - -def _safe_result_tv(item: dict) -> dict: - return { - "title": item.get("title", ""), - "year": item.get("year"), - "tvdbId": item.get("tvdbId"), - "overview": item.get("overview", ""), - "remotePoster": item.get("remotePoster", ""), - "statistics": item.get("statistics", {}), - } - - -def _safe_result_music(item: dict) -> dict: - return { - "artistName": item.get("artistName", ""), - "foreignArtistId": item.get("foreignArtistId", ""), - "overview": item.get("overview", ""), - "remotePoster": (item.get("images") or [{}])[0].get("remoteUrl", "") if item.get("images") else "", - } - - -SAFE_MAPPERS = { - "movie": _safe_result_movie, - "tv": _safe_result_tv, - "music": _safe_result_music, -} - - -@router.get("/search/{media_type}") -async def search(media_type: str, term: str, request: Request) -> JSONResponse: - session = await require_session(request) - if not session: - return JSONResponse({"error": "Not authenticated"}, status_code=401) - - if media_type not in VALID_TYPES: - return JSONResponse({"error": f"Invalid type. Must be one of: {', '.join(VALID_TYPES)}"}, status_code=400) - - if not term or not term.strip(): - return JSONResponse({"error": "Search term is required"}, status_code=400) - - cfg = SERVICE_CONFIG[media_type] - if not cfg["url"] or not cfg["api_key"]: - return JSONResponse({"error": f"{cfg['label']} service is not configured"}, status_code=503) - - lookup_url = f"{cfg['url'].rstrip('/')}{cfg['lookup_path']}" - params = {"term": term.strip()} - - async with httpx.AsyncClient() as client: - try: - resp = await client.get( - lookup_url, - params=params, - headers=_headers(cfg["api_key"]), - timeout=15.0, - ) - except httpx.RequestError: - return JSONResponse({"error": f"Could not reach {cfg['label']} service"}, status_code=502) - - if resp.status_code != 200: - return JSONResponse({"error": f"{cfg['label']} lookup failed"}, status_code=resp.status_code) - - results = resp.json() - mapper = SAFE_MAPPERS[media_type] - safe_results = [mapper(item) for item in results[:25]] - - return JSONResponse(safe_results) - - -@router.post("/request/{media_type}") -async def add_request(media_type: str, request: Request) -> JSONResponse: - session = await require_session(request) - if not session: - return JSONResponse({"error": "Not authenticated"}, status_code=401) - - if media_type not in VALID_TYPES: - return JSONResponse({"error": f"Invalid type. Must be one of: {', '.join(VALID_TYPES)}"}, status_code=400) - - body = await request.json() - cfg = SERVICE_CONFIG[media_type] - - if not cfg["url"] or not cfg["api_key"]: - return JSONResponse({"error": f"{cfg['label']} service is not configured"}, status_code=503) - - add_url = f"{cfg['url'].rstrip('/')}{cfg['add_path']}" - - if media_type == "movie": - payload = _build_movie_payload(body, cfg) - title_display = f"\"{body.get('title', 'Unknown')}\" ({body.get('year', '?')})" - elif media_type == "tv": - payload = _build_tv_payload(body, cfg) - title_display = f"\"{body.get('title', 'Unknown')}\" ({body.get('year', '?')})" - else: - payload = _build_music_payload(body, cfg) - title_display = f"\"{body.get('artistName', 'Unknown')}\" ({body.get('foreignArtistId', '?')})" - - async with httpx.AsyncClient() as client: - try: - resp = await client.post( - add_url, - json=payload, - headers=_headers(cfg["api_key"]), - timeout=15.0, - ) - except httpx.RequestError: - return JSONResponse({"error": f"Could not reach {cfg['label']} service"}, status_code=502) - - if resp.status_code not in (200, 201): - error_detail = "" - try: - error_body = resp.json() - if isinstance(error_body, list): - error_detail = "; ".join(e.get("errorMessage", "") for e in error_body if e.get("errorMessage")) - elif isinstance(error_body, dict): - error_detail = error_body.get("message", "") or error_body.get("errorMessage", "") - except Exception: - pass - msg = f"Failed to add to {cfg['label']}" - if error_detail: - msg += f": {error_detail}" - return JSONResponse({"error": msg}, status_code=resp.status_code) - - # Fire-and-forget audit message - user_id = session["matrix_user_id"] - audit_msg = f"[REQUEST] {user_id} → [{cfg['label']}] {title_display}" - send_audit_message(audit_msg) - - return JSONResponse({"ok": True, "message": f"{cfg['label']} added successfully"}) - - -def _build_movie_payload(body: dict, cfg: dict) -> dict: - return { - "title": body.get("title", ""), - "tmdbId": body.get("tmdbId"), - "year": body.get("year"), - "qualityProfileId": cfg["quality_profile_id"], - "rootFolderPath": cfg["root_folder"], - "monitored": True, - "addOptions": {"searchForMovie": True}, - } - - -def _build_tv_payload(body: dict, cfg: dict) -> dict: - return { - "title": body.get("title", ""), - "tvdbId": body.get("tvdbId"), - "year": body.get("year"), - "qualityProfileId": cfg["quality_profile_id"], - "rootFolderPath": cfg["root_folder"], - "monitored": True, - "addOptions": {"searchForMissingEpisodes": True}, - } - - -def _build_music_payload(body: dict, cfg: dict) -> dict: - return { - "artistName": body.get("artistName", ""), - "foreignArtistId": body.get("foreignArtistId", ""), - "qualityProfileId": cfg["quality_profile_id"], - "rootFolderPath": cfg["root_folder"], - "monitored": True, - "addOptions": {"searchForMissingAlbums": True}, - } diff --git a/app/audit.py b/app/audit.py deleted file mode 100644 index 76c8b52..0000000 --- a/app/audit.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Matrix audit log — fire-and-forget messages to an unencrypted room.""" - -import asyncio -import logging - -import httpx - -from app.config import ( - MATRIX_AUDIT_ROOM_ID, - MATRIX_BOT_ACCESS_TOKEN, - MATRIX_HOMESERVER_URL, -) - -logger = logging.getLogger(__name__) - - -def send_audit_message(message: str) -> None: - """Schedule an audit message to be sent without blocking the caller.""" - if not MATRIX_AUDIT_ROOM_ID or not MATRIX_BOT_ACCESS_TOKEN: - logger.debug("Audit log not configured, skipping") - return - - try: - loop = asyncio.get_running_loop() - except RuntimeError: - return - - loop.create_task(_send(message)) - - -async def _send(message: str) -> None: - import time - - room_id = MATRIX_AUDIT_ROOM_ID - txn_id = str(int(time.time() * 1000)) - - url = ( - f"{MATRIX_HOMESERVER_URL.rstrip('/')}/_matrix/client/v3/rooms/" - f"{room_id}/send/m.room.message/{txn_id}" - ) - body = { - "msgtype": "m.text", - "body": message, - } - headers = {"Authorization": f"Bearer {MATRIX_BOT_ACCESS_TOKEN}"} - - try: - async with httpx.AsyncClient() as client: - await client.put(url, json=body, headers=headers, timeout=10.0) - except Exception: - logger.warning("Failed to send audit message: %s", message, exc_info=True) diff --git a/app/auth.py b/app/auth.py deleted file mode 100644 index 30300f3..0000000 --- a/app/auth.py +++ /dev/null @@ -1,131 +0,0 @@ -import httpx -from fastapi import APIRouter, Request, Response -from fastapi.responses import JSONResponse - -from app.config import MATRIX_HOMESERVER_URL -from app.database import create_session, delete_session, get_session - -router = APIRouter(prefix="/auth", tags=["auth"]) - -SESSION_COOKIE = "bellhop_session" -MAX_SESSION_AGE = 60 * 60 * 24 * 7 # 7 days - - -@router.post("/login") -async def login(request: Request) -> Response: - body = await request.json() - username: str = body.get("username", "").strip() - password: str = body.get("password", "") - - if not username or not password: - return JSONResponse({"error": "Username and password are required"}, status_code=400) - - # Build the Matrix user identifier - if username.startswith("@"): - user_identifier = username - else: - user_identifier = username # let the homeserver resolve it - - login_url = f"{MATRIX_HOMESERVER_URL.rstrip('/')}/_matrix/client/v3/login" - payload = { - "type": "m.login.password", - "identifier": {"type": "m.id.user", "user": user_identifier}, - "password": password, - } - - async with httpx.AsyncClient() as client: - try: - resp = await client.post(login_url, json=payload, timeout=15.0) - except httpx.RequestError: - return JSONResponse({"error": "Could not reach Matrix homeserver"}, status_code=502) - - if resp.status_code != 200: - error_msg = resp.json().get("error", "Authentication failed") - return JSONResponse({"error": error_msg}, status_code=401) - - data = resp.json() - matrix_user_id: str = data["user_id"] - matrix_access_token: str = data["access_token"] - - session_id = await create_session(matrix_user_id, matrix_access_token) - - response = JSONResponse({"user_id": matrix_user_id}) - response.set_cookie( - key=SESSION_COOKIE, - value=session_id, - httponly=True, - samesite="strict", - secure=True, - max_age=MAX_SESSION_AGE, - ) - return response - - -@router.post("/logout") -async def logout(request: Request) -> Response: - session_id = request.cookies.get(SESSION_COOKIE) - if session_id: - await delete_session(session_id) - response = JSONResponse({"ok": True}) - response.delete_cookie(key=SESSION_COOKIE) - return response - - -@router.get("/me") -async def me(request: Request) -> Response: - session_id = request.cookies.get(SESSION_COOKIE) - if not session_id: - return JSONResponse({"error": "Not authenticated"}, status_code=401) - - session = await get_session(session_id) - if not session: - return JSONResponse({"error": "Not authenticated"}, status_code=401) - - # Verify token is still valid with the homeserver - whoami_url = f"{MATRIX_HOMESERVER_URL.rstrip('/')}/_matrix/client/v3/account/whoami" - async with httpx.AsyncClient() as client: - try: - resp = await client.get( - whoami_url, - headers={"Authorization": f"Bearer {session['matrix_access_token']}"}, - timeout=10.0, - ) - except httpx.RequestError: - # If homeserver is unreachable, trust the local session - return JSONResponse({"user_id": session["matrix_user_id"]}) - - if resp.status_code != 200: - await delete_session(session_id) - response = JSONResponse({"error": "Session expired"}, status_code=401) - response.delete_cookie(key=SESSION_COOKIE) - return response - - return JSONResponse({"user_id": session["matrix_user_id"]}) - - -async def require_session(request: Request) -> dict | None: - """Utility: extract and validate session from a request. Returns session dict or None.""" - session_id = request.cookies.get(SESSION_COOKIE) - if not session_id: - return None - session = await get_session(session_id) - if not session: - return None - - # Verify token is still valid - whoami_url = f"{MATRIX_HOMESERVER_URL.rstrip('/')}/_matrix/client/v3/account/whoami" - async with httpx.AsyncClient() as client: - try: - resp = await client.get( - whoami_url, - headers={"Authorization": f"Bearer {session['matrix_access_token']}"}, - timeout=10.0, - ) - except httpx.RequestError: - return session # trust local session if homeserver unreachable - - if resp.status_code != 200: - await delete_session(session_id) - return None - - return session diff --git a/app/config.py b/app/config.py deleted file mode 100644 index 29de51a..0000000 --- a/app/config.py +++ /dev/null @@ -1,43 +0,0 @@ -import os -import secrets -from pathlib import Path - -from dotenv import load_dotenv - -load_dotenv() - - -def _require(name: str) -> str: - val = os.getenv(name) - if not val: - raise RuntimeError(f"Missing required environment variable: {name}") - return val - - -# Matrix -MATRIX_HOMESERVER_URL: str = _require("MATRIX_HOMESERVER_URL") -MATRIX_AUDIT_ROOM_ID: str = os.getenv("MATRIX_AUDIT_ROOM_ID", "") -MATRIX_BOT_USER_ID: str = os.getenv("MATRIX_BOT_USER_ID", "") -MATRIX_BOT_ACCESS_TOKEN: str = os.getenv("MATRIX_BOT_ACCESS_TOKEN", "") - -# Radarr -RADARR_URL: str = os.getenv("RADARR_URL", "") -RADARR_API_KEY: str = os.getenv("RADARR_API_KEY", "") -RADARR_QUALITY_PROFILE_ID: int = int(os.getenv("RADARR_QUALITY_PROFILE_ID", "1")) -RADARR_ROOT_FOLDER: str = os.getenv("RADARR_ROOT_FOLDER", "/movies") - -# Sonarr -SONARR_URL: str = os.getenv("SONARR_URL", "") -SONARR_API_KEY: str = os.getenv("SONARR_API_KEY", "") -SONARR_QUALITY_PROFILE_ID: int = int(os.getenv("SONARR_QUALITY_PROFILE_ID", "1")) -SONARR_ROOT_FOLDER: str = os.getenv("SONARR_ROOT_FOLDER", "/tv") - -# Lidarr -LIDARR_URL: str = os.getenv("LIDARR_URL", "") -LIDARR_API_KEY: str = os.getenv("LIDARR_API_KEY", "") -LIDARR_QUALITY_PROFILE_ID: int = int(os.getenv("LIDARR_QUALITY_PROFILE_ID", "1")) -LIDARR_ROOT_FOLDER: str = os.getenv("LIDARR_ROOT_FOLDER", "/music") - -# App -SESSION_SECRET_KEY: str = os.getenv("SESSION_SECRET_KEY") or secrets.token_urlsafe(32) -DATABASE_PATH: str = os.getenv("DATABASE_PATH", "bellhop.db") diff --git a/app/database.py b/app/database.py deleted file mode 100644 index 674c66c..0000000 --- a/app/database.py +++ /dev/null @@ -1,73 +0,0 @@ -import secrets -import time - -import aiosqlite - -from app.config import DATABASE_PATH - -_db: aiosqlite.Connection | None = None - - -async def get_db() -> aiosqlite.Connection: - global _db - if _db is None: - _db = await aiosqlite.connect(DATABASE_PATH) - _db.row_factory = aiosqlite.Row - await _db.execute("PRAGMA journal_mode=WAL") - await _db.execute( - """ - CREATE TABLE IF NOT EXISTS sessions ( - session_id TEXT PRIMARY KEY, - matrix_user_id TEXT NOT NULL, - matrix_access_token TEXT NOT NULL, - created_at REAL NOT NULL, - last_seen REAL NOT NULL - ) - """ - ) - await _db.commit() - return _db - - -async def close_db() -> None: - global _db - if _db is not None: - await _db.close() - _db = None - - -async def create_session(matrix_user_id: str, matrix_access_token: str) -> str: - db = await get_db() - session_id = secrets.token_urlsafe(32) - now = time.time() - await db.execute( - "INSERT INTO sessions (session_id, matrix_user_id, matrix_access_token, created_at, last_seen) " - "VALUES (?, ?, ?, ?, ?)", - (session_id, matrix_user_id, matrix_access_token, now, now), - ) - await db.commit() - return session_id - - -async def get_session(session_id: str) -> dict | None: - db = await get_db() - cursor = await db.execute( - "SELECT session_id, matrix_user_id, matrix_access_token, created_at, last_seen " - "FROM sessions WHERE session_id = ?", - (session_id,), - ) - row = await cursor.fetchone() - if row is None: - return None - await db.execute( - "UPDATE sessions SET last_seen = ? WHERE session_id = ?", - (time.time(), session_id), - ) - await db.commit() - return dict(row) - - -async def delete_session(session_id: str) -> None: - db = await get_db() - await db.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,)) - await db.commit() diff --git a/app/main.py b/app/main.py deleted file mode 100644 index ff8bd50..0000000 --- a/app/main.py +++ /dev/null @@ -1,48 +0,0 @@ -from contextlib import asynccontextmanager -from pathlib import Path - -from fastapi import FastAPI, Request -from fastapi.responses import HTMLResponse -from fastapi.staticfiles import StaticFiles -from slowapi import Limiter, _rate_limit_exceeded_handler -from slowapi.errors import RateLimitExceeded -from slowapi.util import get_remote_address - -from app.auth import router as auth_router -from app.arr import router as arr_router -from app.database import close_db, get_db - -BASE_DIR = Path(__file__).resolve().parent - -limiter = Limiter(key_func=get_remote_address) - - -@asynccontextmanager -async def lifespan(app: FastAPI): - await get_db() - yield - await close_db() - - -app = FastAPI(title="Bellhop", lifespan=lifespan) -app.state.limiter = limiter -app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) - -# Mount static files -app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static") - -# Apply rate limit to login specifically -original_login = auth_router.routes -for route in auth_router.routes: - if hasattr(route, "path") and route.path == "/login" and hasattr(route, "endpoint"): - route.endpoint = limiter.limit("5/minute")(route.endpoint) - break - -app.include_router(auth_router) -app.include_router(arr_router) - - -@app.get("/", response_class=HTMLResponse) -async def index(): - html_path = BASE_DIR / "templates" / "index.html" - return HTMLResponse(html_path.read_text()) diff --git a/app/static/.gitkeep b/app/static/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/templates/index.html b/app/templates/index.html deleted file mode 100644 index 72d3d0e..0000000 --- a/app/templates/index.html +++ /dev/null @@ -1,355 +0,0 @@ - - - - - - Bellhop - - - - -
- - - - - - - - - -
- - - - diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7cebd18 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module bellhop + +go 1.25.0 + +require ( + gopkg.in/yaml.v3 v3.0.1 + maunium.net/go/mautrix v0.28.0 +) diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..fb90fc1 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,127 @@ +package config + +import ( + "fmt" + "log/slog" + "os" + "regexp" + + "gopkg.in/yaml.v3" +) + +var envBracketRe = regexp.MustCompile(`\$\{([^}]+)\}`) + +type Config struct { + Matrix MatrixConfig `yaml:"matrix"` + Services ServicesConfig `yaml:"services"` +} + +type MatrixConfig struct { + Homeserver string `yaml:"homeserver"` + UserID string `yaml:"user_id"` + Password string `yaml:"password"` + PickleKey string `yaml:"pickle_key"` + DisplayName string `yaml:"display_name"` + DataDir string `yaml:"data_dir"` + CommandPrefix string `yaml:"command_prefix"` + // AllowedRooms is the set of room IDs the bot listens for commands in. + // Messages in any other room are ignored. + AllowedRooms []string `yaml:"allowed_rooms"` +} + +type ServicesConfig struct { + Radarr *ArrConfig `yaml:"radarr"` + Sonarr *ArrConfig `yaml:"sonarr"` + Lidarr *ArrConfig `yaml:"lidarr"` +} + +type ArrConfig struct { + URL string `yaml:"url"` + APIKey string `yaml:"api_key"` + QualityProfileID int `yaml:"quality_profile_id"` + RootFolder string `yaml:"root_folder"` +} + +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + + expanded := envBracketRe.ReplaceAllStringFunc(string(data), func(match string) string { + varName := match[2 : len(match)-1] + val := os.Getenv(varName) + if val == "" { + slog.Warn("config: env var referenced but not set", "var", varName) + } + return val + }) + + var cfg Config + if err := yaml.Unmarshal([]byte(expanded), &cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + + cfg.applyDefaults() + + if err := cfg.validate(); err != nil { + return nil, fmt.Errorf("validate config: %w", err) + } + + return &cfg, nil +} + +func (c *Config) validate() error { + if c.Matrix.Homeserver == "" { + return fmt.Errorf("matrix.homeserver is required") + } + if c.Matrix.UserID == "" { + return fmt.Errorf("matrix.user_id is required") + } + if c.Matrix.Password == "" { + return fmt.Errorf("matrix.password is required") + } + if len(c.Matrix.AllowedRooms) == 0 { + return fmt.Errorf("matrix.allowed_rooms must have at least one entry") + } + if c.Services.Radarr == nil && c.Services.Sonarr == nil && c.Services.Lidarr == nil { + return fmt.Errorf("at least one of services.radarr/sonarr/lidarr must be configured") + } + for name, svc := range map[string]*ArrConfig{ + "radarr": c.Services.Radarr, + "sonarr": c.Services.Sonarr, + "lidarr": c.Services.Lidarr, + } { + if svc == nil { + continue + } + if svc.URL == "" { + return fmt.Errorf("services.%s.url is required when service is set", name) + } + if svc.APIKey == "" { + return fmt.Errorf("services.%s.api_key is required when service is set", name) + } + if svc.RootFolder == "" { + return fmt.Errorf("services.%s.root_folder is required when service is set", name) + } + if svc.QualityProfileID == 0 { + return fmt.Errorf("services.%s.quality_profile_id is required when service is set", name) + } + } + return nil +} + +func (c *Config) applyDefaults() { + if c.Matrix.DataDir == "" { + c.Matrix.DataDir = "./data" + } + if c.Matrix.DisplayName == "" { + c.Matrix.DisplayName = "Bellhop" + } + if c.Matrix.PickleKey == "" { + c.Matrix.PickleKey = "bellhop_pickle_key" + } + if c.Matrix.CommandPrefix == "" { + c.Matrix.CommandPrefix = "!" + } +} diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 46ca906..0000000 --- a/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ -fastapi==0.115.6 -uvicorn[standard]==0.34.0 -httpx==0.28.1 -aiosqlite==0.20.0 -python-dotenv==1.0.1 -matrix-nio==0.24.0 -slowapi==0.1.9 -jinja2==3.1.5 -itsdangerous==2.2.0