feat: Migrate Arbiter and Modpack Version Checker to monorepo

WHAT WAS DONE:
- Migrated Arbiter (discord-oauth-arbiter) code to services/arbiter/
- Migrated Modpack Version Checker code to services/modpack-version-checker/
- Created .env.example for Arbiter with all required environment variables
- Moved systemd service file to services/arbiter/deploy/
- Organized directory structure per Gemini monorepo recommendations

WHY:
- Consolidate all service code in one repository
- Prepare for Gemini code review (Panel v1.12 compatibility check)
- Enable service-prefixed Git tagging (arbiter-v2.1.0, modpack-v1.0.0)
- Support npm workspaces for shared dependencies

SERVICES MIGRATED:
1. Arbiter (Discord OAuth bot) - Originally written by Gemini + Claude
   - Full source code from ops-manual docs/implementation/
   - Created comprehensive .env.example
   - Ready for Panel v1.12 compatibility verification

2. Modpack Version Checker (Python CLI tool)
   - Full source code from ops-manual docs/tasks/
   - Written for Panel v1.11, needs Gemini review for v1.12
   - Never had code review before

STILL TODO:
- Whitelist Manager - Pull from Billing VPS (38.68.14.188)
  - Currently deployed and running
  - Needs Panel v1.12 API compatibility fix (Task #86)
  - Requires SSH access to pull code

NEXT STEPS:
- Gemini code review for Panel v1.12 API compatibility
- Create package.json for each service
- Test npm workspaces integration
- Deploy after verification

FILES:
- services/arbiter/ (25 new files, full application)
- services/modpack-version-checker/ (21 new files, full application)

Signed-off-by: The Golden Chronicler <claude@firefrostgaming.com>
This commit is contained in:
Claude (The Golden Chronicler #50)
2026-03-31 21:52:42 +00:00
parent 4efdd44691
commit 04e9b407d5
47 changed files with 6366 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
"""Modpack Version Checker - Monitor CurseForge modpack updates."""
__version__ = "1.0.0"
__author__ = "Firefrost Gaming"

View File

@@ -0,0 +1,565 @@
"""Click-based CLI — all user-facing commands."""
from __future__ import annotations
import sys
from typing import Optional
import click
from rich import box
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from . import __version__
from .config import Config
from .curseforge import (
CurseForgeAuthError,
CurseForgeClient,
CurseForgeError,
CurseForgeNotFoundError,
)
from .database import Database
from .notifier import DiscordNotifier, NotificationError
console = Console()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _load_db(cfg: Config) -> Database:
return Database(cfg.database_path)
def _require_client(cfg: Config) -> CurseForgeClient:
"""Return a configured API client, or exit with a helpful message."""
if not cfg.curseforge_api_key:
console.print("[red]Error:[/red] No CurseForge API key configured.")
console.print(
"Set one with: [bold]modpack-checker config set-key YOUR_KEY[/bold]"
)
console.print(
"Get a free key at: [dim]https://console.curseforge.com[/dim]"
)
sys.exit(1)
return CurseForgeClient(cfg.curseforge_api_key)
# ---------------------------------------------------------------------------
# Root group
# ---------------------------------------------------------------------------
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
@click.version_option(version=__version__, prog_name="modpack-checker")
def cli() -> None:
"""Modpack Version Checker — monitor CurseForge modpacks for updates.
\b
Quick start:
modpack-checker config set-key YOUR_CURSEFORGE_KEY
modpack-checker add 238222
modpack-checker check
"""
# ---------------------------------------------------------------------------
# config sub-group
# ---------------------------------------------------------------------------
@cli.group("config")
def config_group() -> None:
"""Manage configuration (API key, webhook URL, etc.)."""
@config_group.command("set-key")
@click.argument("api_key")
def config_set_key(api_key: str) -> None:
"""Save your CurseForge API key and validate it."""
cfg = Config.load()
cfg.curseforge_api_key = api_key.strip()
cfg.save()
client = CurseForgeClient(api_key)
with console.status("Validating API key with CurseForge…"):
valid = client.validate_api_key()
if valid:
console.print("[green]✓[/green] API key saved and validated.")
else:
console.print(
"[yellow]⚠[/yellow] API key saved, but validation failed. "
"Double-check at [dim]https://console.curseforge.com[/dim]"
)
@config_group.command("set-webhook")
@click.argument("webhook_url")
def config_set_webhook(webhook_url: str) -> None:
"""Save a Discord webhook URL and send a test message."""
cfg = Config.load()
cfg.discord_webhook_url = webhook_url.strip()
cfg.save()
notifier = DiscordNotifier(webhook_url)
try:
with console.status("Testing webhook…"):
notifier.test()
console.print("[green]✓[/green] Webhook saved and test message sent.")
except NotificationError as exc:
console.print(f"[yellow]⚠[/yellow] Webhook saved, but test failed: {exc}")
@config_group.command("set-interval")
@click.argument("hours", type=int)
def config_set_interval(hours: int) -> None:
"""Set how often the scheduler checks for updates (in hours, 1168)."""
if not 1 <= hours <= 168:
console.print("[red]Error:[/red] Interval must be between 1 and 168 hours.")
sys.exit(1)
cfg = Config.load()
cfg.check_interval_hours = hours
cfg.save()
console.print(f"[green]✓[/green] Check interval set to {hours} hour(s).")
@config_group.command("show")
def config_show() -> None:
"""Display the current configuration."""
cfg = Config.load()
key = cfg.curseforge_api_key
if key and len(key) > 8:
masked = f"{key[:4]}{'*' * (len(key) - 8)}{key[-4:]}"
elif key:
masked = "****"
else:
masked = "[red]Not configured[/red]"
table = Table(title="Configuration", box=box.ROUNDED, show_header=False)
table.add_column("Setting", style="cyan", min_width=22)
table.add_column("Value")
table.add_row("CurseForge API Key", masked)
table.add_row(
"Discord Webhook",
cfg.discord_webhook_url or "[dim]Not configured[/dim]",
)
table.add_row("Database", cfg.database_path)
table.add_row("Check Interval", f"{cfg.check_interval_hours} hour(s)")
table.add_row(
"Notifications",
"[green]On[/green]" if cfg.notification_on_update else "[red]Off[/red]",
)
console.print(table)
# ---------------------------------------------------------------------------
# add
# ---------------------------------------------------------------------------
@cli.command()
@click.argument("modpack_id", type=int)
def add(modpack_id: int) -> None:
"""Add a modpack to the watch list by its CurseForge project ID.
\b
Example:
modpack-checker add 238222 # All The Mods 9
"""
cfg = Config.load()
client = _require_client(cfg)
db = _load_db(cfg)
with console.status(f"Looking up modpack {modpack_id} on CurseForge…"):
try:
name = client.get_mod_name(modpack_id)
except CurseForgeNotFoundError:
console.print(
f"[red]Error:[/red] No modpack found with ID {modpack_id} on CurseForge."
)
sys.exit(1)
except CurseForgeAuthError as exc:
console.print(f"[red]Auth error:[/red] {exc}")
sys.exit(1)
except CurseForgeError as exc:
console.print(f"[red]API error:[/red] {exc}")
sys.exit(1)
try:
db.add_modpack(modpack_id, name)
except ValueError:
console.print(
f"[yellow]Already tracked:[/yellow] [bold]{name}[/bold] (ID: {modpack_id})"
)
return
console.print(
f"[green]✓[/green] Now tracking [bold]{name}[/bold] (ID: {modpack_id})"
)
console.print("Run [bold]modpack-checker check[/bold] to fetch the current version.")
# ---------------------------------------------------------------------------
# remove
# ---------------------------------------------------------------------------
@cli.command()
@click.argument("modpack_id", type=int)
@click.confirmation_option(prompt="Remove this modpack and all its history?")
def remove(modpack_id: int) -> None:
"""Remove a modpack from the watch list."""
cfg = Config.load()
db = _load_db(cfg)
modpack = db.get_modpack(modpack_id)
if modpack is None:
console.print(
f"[red]Error:[/red] Modpack ID {modpack_id} is not in your watch list."
)
sys.exit(1)
db.remove_modpack(modpack_id)
console.print(f"[green]✓[/green] Removed [bold]{modpack.name}[/bold].")
# ---------------------------------------------------------------------------
# list
# ---------------------------------------------------------------------------
@cli.command(name="list")
def list_modpacks() -> None:
"""Show all watched modpacks and their last known versions."""
cfg = Config.load()
db = _load_db(cfg)
modpacks = db.get_all_modpacks()
if not modpacks:
console.print("[dim]Watch list is empty.[/dim]")
console.print(
"Add a modpack with: [bold]modpack-checker add <curseforge-id>[/bold]"
)
return
table = Table(
title=f"Watched Modpacks ({len(modpacks)})",
box=box.ROUNDED,
)
table.add_column("CF ID", style="cyan", justify="right", no_wrap=True)
table.add_column("Name", style="bold white")
table.add_column("Current Version", style="green")
table.add_column("Last Checked", style="dim")
table.add_column("Alerts", justify="center")
for mp in modpacks:
last_checked = (
mp.last_checked.strftime("%Y-%m-%d %H:%M")
if mp.last_checked
else "[dim]Never[/dim]"
)
alerts = "[green]✓[/green]" if mp.notification_enabled else "[red]✗[/red]"
version = mp.current_version or "[dim]—[/dim]"
table.add_row(str(mp.curseforge_id), mp.name, version, last_checked, alerts)
console.print(table)
# ---------------------------------------------------------------------------
# check
# ---------------------------------------------------------------------------
@cli.command()
@click.option(
"--id", "-m", "modpack_id", type=int, default=None,
help="Check a single modpack by CurseForge ID.",
)
@click.option(
"--notify/--no-notify", default=True,
help="Send Discord notifications for updates found (default: on).",
)
def check(modpack_id: Optional[int], notify: bool) -> None:
"""Check all (or one) watched modpack(s) for updates."""
cfg = Config.load()
client = _require_client(cfg)
db = _load_db(cfg)
if modpack_id is not None:
target = db.get_modpack(modpack_id)
if target is None:
console.print(
f"[red]Error:[/red] Modpack ID {modpack_id} is not in your watch list."
)
sys.exit(1)
modpacks = [target]
else:
modpacks = db.get_all_modpacks()
if not modpacks:
console.print("[dim]No modpacks to check.[/dim]")
console.print(
"Add one with: [bold]modpack-checker add <curseforge-id>[/bold]"
)
return
notifier: Optional[DiscordNotifier] = None
if notify and cfg.discord_webhook_url and cfg.notification_on_update:
notifier = DiscordNotifier(cfg.discord_webhook_url)
updates = 0
errors = 0
for mp in modpacks:
with console.status(f"Checking [bold]{mp.name}[/bold]…"):
try:
file_obj = client.get_latest_file(mp.curseforge_id)
except CurseForgeNotFoundError:
console.print(
f" [red]✗[/red] {mp.name}: not found on CurseForge "
f"(ID: {mp.curseforge_id})"
)
errors += 1
continue
except CurseForgeError as exc:
console.print(f" [red]✗[/red] {mp.name}: API error — {exc}")
errors += 1
continue
if file_obj is None:
console.print(f" [yellow]⚠[/yellow] {mp.name}: no files found on CurseForge.")
errors += 1
continue
new_version = client.extract_version(file_obj)
notification_sent = False
if mp.current_version == new_version:
line = f"[green]✓[/green] {mp.name}: up to date ([bold]{new_version}[/bold])"
elif mp.current_version is None:
line = (
f"[cyan]→[/cyan] {mp.name}: "
f"initial version recorded as [bold]{new_version}[/bold]"
)
else:
updates += 1
line = (
f"[yellow]↑[/yellow] {mp.name}: "
f"[dim]{mp.current_version}[/dim] → [bold green]{new_version}[/bold green]"
)
if notifier and mp.notification_enabled:
try:
notifier.send_update(
mp.name, mp.curseforge_id, mp.current_version, new_version
)
notification_sent = True
line += " [dim](notified)[/dim]"
except NotificationError as exc:
line += f" [red](notification failed: {exc})[/red]"
db.update_version(mp.curseforge_id, new_version, notification_sent)
console.print(f" {line}")
# Summary line
console.print()
parts = []
if updates:
parts.append(f"[yellow]{updates} update(s) found[/yellow]")
if errors:
parts.append(f"[red]{errors} error(s)[/red]")
if not updates and not errors:
parts.append("[green]All modpacks are up to date.[/green]")
console.print(" ".join(parts))
# ---------------------------------------------------------------------------
# status
# ---------------------------------------------------------------------------
@cli.command()
@click.argument("modpack_id", type=int)
@click.option("--limit", "-n", default=10, show_default=True, help="History entries to show.")
def status(modpack_id: int, limit: int) -> None:
"""Show detailed status and check history for a modpack."""
cfg = Config.load()
db = _load_db(cfg)
mp = db.get_modpack(modpack_id)
if mp is None:
console.print(
f"[red]Error:[/red] Modpack ID {modpack_id} is not in your watch list."
)
sys.exit(1)
last_checked = (
mp.last_checked.strftime("%Y-%m-%d %H:%M UTC") if mp.last_checked else "Never"
)
notif_str = "[green]Enabled[/green]" if mp.notification_enabled else "[red]Disabled[/red]"
console.print(
Panel(
f"[bold white]{mp.name}[/bold white]\n"
f"CurseForge ID : [cyan]{mp.curseforge_id}[/cyan]\n"
f"Version : [green]{mp.current_version or 'Not checked yet'}[/green]\n"
f"Last Checked : [dim]{last_checked}[/dim]\n"
f"Notifications : {notif_str}",
title="Modpack Status",
border_style="cyan",
)
)
history = db.get_check_history(modpack_id, limit=limit)
if not history:
console.print("[dim]No check history yet.[/dim]")
return
table = Table(title=f"Check History (last {limit})", box=box.SIMPLE)
table.add_column("Timestamp", style="dim")
table.add_column("Version", style="green")
table.add_column("Notified", justify="center")
for entry in history:
notified = "[green]✓[/green]" if entry.notification_sent else "[dim]—[/dim]"
table.add_row(
entry.checked_at.strftime("%Y-%m-%d %H:%M"),
entry.version_found or "[dim]Unknown[/dim]",
notified,
)
console.print(table)
# ---------------------------------------------------------------------------
# notifications (toggle per-modpack alerts)
# ---------------------------------------------------------------------------
@cli.command()
@click.argument("modpack_id", type=int)
@click.option(
"--enable/--disable",
default=True,
help="Enable or disable Discord alerts for this modpack.",
)
def notifications(modpack_id: int, enable: bool) -> None:
"""Enable or disable Discord notifications for a specific modpack."""
cfg = Config.load()
db = _load_db(cfg)
mp = db.get_modpack(modpack_id)
if mp is None:
console.print(
f"[red]Error:[/red] Modpack ID {modpack_id} is not in your watch list."
)
sys.exit(1)
db.toggle_notifications(modpack_id, enable)
state = "[green]enabled[/green]" if enable else "[red]disabled[/red]"
console.print(
f"[green]✓[/green] Notifications {state} for [bold]{mp.name}[/bold]."
)
# ---------------------------------------------------------------------------
# schedule (background daemon)
# ---------------------------------------------------------------------------
@cli.command()
@click.option(
"--hours", "-h", "hours", type=int, default=None,
help="Override the configured check interval (hours).",
)
def schedule(hours: Optional[int]) -> None:
"""Run continuous background checks on a configurable interval.
Requires the [scheduler] extra: pip install modpack-version-checker[scheduler]
"""
try:
from apscheduler.schedulers.blocking import BlockingScheduler
except ImportError:
console.print("[red]Error:[/red] APScheduler is not installed.")
console.print(
"Install it with: [bold]pip install modpack-version-checker[scheduler][/bold]"
)
sys.exit(1)
cfg = Config.load()
interval = hours or cfg.check_interval_hours
def _run_check() -> None:
"""Inner function executed by the scheduler."""
client = _require_client(cfg)
db = _load_db(cfg)
modpacks = db.get_all_modpacks()
notifier: Optional[DiscordNotifier] = None
if cfg.discord_webhook_url and cfg.notification_on_update:
notifier = DiscordNotifier(cfg.discord_webhook_url)
for mp in modpacks:
try:
file_obj = client.get_latest_file(mp.curseforge_id)
if file_obj is None:
continue
new_version = client.extract_version(file_obj)
notification_sent = False
if (
mp.current_version is not None
and mp.current_version != new_version
and notifier
and mp.notification_enabled
):
try:
notifier.send_update(
mp.name, mp.curseforge_id, mp.current_version, new_version
)
notification_sent = True
except NotificationError:
pass
db.update_version(mp.curseforge_id, new_version, notification_sent)
except CurseForgeError:
pass # Log silently in daemon mode; don't crash the scheduler
scheduler = BlockingScheduler()
scheduler.add_job(_run_check, "interval", hours=interval)
console.print(
Panel(
f"Checking every [bold]{interval}[/bold] hour(s).\n"
"Press [bold]Ctrl-C[/bold] to stop.",
title="Modpack Checker — Scheduler Running",
border_style="green",
)
)
# Run immediately so the user gets instant feedback
_run_check()
try:
scheduler.start()
except KeyboardInterrupt:
console.print("\n[dim]Scheduler stopped.[/dim]")
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
cli()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,46 @@
"""Configuration management for Modpack Version Checker."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Optional
from pydantic import BaseModel, Field
CONFIG_DIR = Path.home() / ".config" / "modpack-checker"
CONFIG_FILE = CONFIG_DIR / "config.json"
DEFAULT_DB_PATH = str(CONFIG_DIR / "modpacks.db")
class Config(BaseModel):
"""Application configuration, persisted to ~/.config/modpack-checker/config.json."""
curseforge_api_key: str = ""
discord_webhook_url: Optional[str] = None
database_path: str = DEFAULT_DB_PATH
check_interval_hours: int = Field(default=6, ge=1, le=168)
notification_on_update: bool = True
@classmethod
def load(cls) -> "Config":
"""Load configuration from disk, returning defaults if not present."""
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE) as f:
data = json.load(f)
return cls(**data)
except (json.JSONDecodeError, ValueError):
# Corrupted config — fall back to defaults silently
return cls()
return cls()
def save(self) -> None:
"""Persist configuration to disk."""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
with open(CONFIG_FILE, "w") as f:
json.dump(self.model_dump(), f, indent=2)
def is_configured(self) -> bool:
"""Return True if the minimum required config (API key) is present."""
return bool(self.curseforge_api_key)

View File

@@ -0,0 +1,192 @@
"""CurseForge API v1 client with error handling and retry logic."""
from __future__ import annotations
import time
from typing import Any, Dict, Optional
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
# ---------------------------------------------------------------------------
# Custom exceptions
# ---------------------------------------------------------------------------
class CurseForgeError(Exception):
"""Base exception for all CurseForge API errors."""
class CurseForgeAuthError(CurseForgeError):
"""API key is missing, invalid, or lacks required permissions."""
class CurseForgeNotFoundError(CurseForgeError):
"""The requested modpack or file does not exist."""
class CurseForgeRateLimitError(CurseForgeError):
"""The API rate limit has been exceeded (429)."""
# ---------------------------------------------------------------------------
# Client
# ---------------------------------------------------------------------------
class CurseForgeClient:
"""Thin wrapper around the CurseForge REST API v1.
Handles authentication, retries on transient errors, and translates
HTTP status codes into typed exceptions so callers never see raw
requests exceptions.
"""
BASE_URL = "https://api.curseforge.com"
_MINECRAFT_GAME_ID = 432 # used for API key validation
def __init__(self, api_key: str, timeout: int = 15) -> None:
self.api_key = api_key
self.timeout = timeout
self._session = requests.Session()
self._session.headers.update(
{
"x-api-key": api_key,
"Accept": "application/json",
"User-Agent": "ModpackVersionChecker/1.0 (Firefrost Gaming)",
}
)
# Retry on connection errors and server-side 5xx — never on 4xx
retry = Retry(
total=3,
backoff_factor=1.0,
status_forcelist=[500, 502, 503, 504],
allowed_methods=["GET"],
raise_on_status=False,
)
self._session.mount("https://", HTTPAdapter(max_retries=retry))
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
"""Perform a GET request and return parsed JSON.
Raises:
CurseForgeAuthError: on 401 / 403
CurseForgeNotFoundError: on 404
CurseForgeRateLimitError: on 429
CurseForgeError: on connection failure, timeout, or other HTTP error
"""
url = f"{self.BASE_URL}{path}"
try:
response = self._session.get(url, params=params, timeout=self.timeout)
except requests.ConnectionError as exc:
raise CurseForgeError(
f"Could not connect to CurseForge API. Check your internet connection."
) from exc
except requests.Timeout:
raise CurseForgeError(
f"CurseForge API timed out after {self.timeout}s. Try again later."
)
if response.status_code == 401:
raise CurseForgeAuthError(
"Invalid API key. Verify your key at https://console.curseforge.com"
)
if response.status_code == 403:
raise CurseForgeAuthError(
"API key lacks permission for this resource. "
"Ensure your key has 'Mods' read access."
)
if response.status_code == 404:
raise CurseForgeNotFoundError(f"Resource not found: {path}")
if response.status_code == 429:
raise CurseForgeRateLimitError(
"CurseForge rate limit exceeded (100 req/min). "
"Wait a moment and try again."
)
try:
response.raise_for_status()
except requests.HTTPError as exc:
raise CurseForgeError(
f"Unexpected API error ({response.status_code}): {response.text[:200]}"
) from exc
return response.json()
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def validate_api_key(self) -> bool:
"""Return True if the current API key is valid."""
try:
self._get(f"/v1/games/{self._MINECRAFT_GAME_ID}")
return True
except (CurseForgeAuthError, CurseForgeError):
return False
def get_mod(self, mod_id: int) -> Dict[str, Any]:
"""Fetch full modpack metadata by CurseForge project ID.
Raises:
CurseForgeNotFoundError: if the project ID does not exist.
"""
data = self._get(f"/v1/mods/{mod_id}")
return data["data"]
def get_mod_name(self, mod_id: int) -> str:
"""Return the display name for a modpack."""
mod = self.get_mod(mod_id)
return mod.get("name", f"Modpack {mod_id}")
def get_latest_file(self, mod_id: int) -> Optional[Dict[str, Any]]:
"""Return the most recent file object for a modpack.
Prefers the ``latestFiles`` field on the mod object (single request).
Falls back to querying the files endpoint if that is empty.
Returns None if no files are found.
"""
mod = self.get_mod(mod_id)
# Fast path: latestFiles is populated by CurseForge automatically
latest_files = mod.get("latestFiles", [])
if latest_files:
# Sort by fileDate descending to get the newest release
latest_files_sorted = sorted(
latest_files,
key=lambda f: f.get("fileDate", ""),
reverse=True,
)
return latest_files_sorted[0]
# Slow path: query the files endpoint
data = self._get(
f"/v1/mods/{mod_id}/files",
params={"pageSize": 1, "sortOrder": "desc"},
)
files = data.get("data", [])
return files[0] if files else None
def extract_version(self, file_obj: Dict[str, Any]) -> str:
"""Extract a human-readable version string from a CurseForge file object.
Priority: displayName → fileName → file ID fallback.
"""
display_name = (file_obj.get("displayName") or "").strip()
if display_name:
return display_name
file_name = (file_obj.get("fileName") or "").strip()
if file_name:
return file_name
return f"File ID {file_obj.get('id', 'unknown')}"

View File

@@ -0,0 +1,225 @@
"""SQLite database layer using SQLAlchemy 2.0 ORM."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import List, Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, create_engine, select
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationship
# ---------------------------------------------------------------------------
# ORM models (internal — not exposed outside this module)
# ---------------------------------------------------------------------------
class _Base(DeclarativeBase):
pass
class _ModpackRow(_Base):
__tablename__ = "modpacks"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
curseforge_id: Mapped[int] = mapped_column(Integer, unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
current_version: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
last_checked: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
notification_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
history: Mapped[List["_CheckHistoryRow"]] = relationship(
back_populates="modpack", cascade="all, delete-orphan"
)
class _CheckHistoryRow(_Base):
__tablename__ = "check_history"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
modpack_id: Mapped[int] = mapped_column(ForeignKey("modpacks.id"), nullable=False)
checked_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
version_found: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
notification_sent: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
modpack: Mapped["_ModpackRow"] = relationship(back_populates="history")
# ---------------------------------------------------------------------------
# Public dataclasses (detached, safe to use outside a session)
# ---------------------------------------------------------------------------
@dataclass
class Modpack:
"""Read-only snapshot of a tracked modpack."""
id: int
curseforge_id: int
name: str
current_version: Optional[str]
last_checked: Optional[datetime]
notification_enabled: bool
@classmethod
def _from_row(cls, row: _ModpackRow) -> "Modpack":
return cls(
id=row.id,
curseforge_id=row.curseforge_id,
name=row.name,
current_version=row.current_version,
last_checked=row.last_checked,
notification_enabled=row.notification_enabled,
)
@dataclass
class CheckHistory:
"""Read-only snapshot of a single version-check record."""
id: int
modpack_id: int
checked_at: datetime
version_found: Optional[str]
notification_sent: bool
@classmethod
def _from_row(cls, row: _CheckHistoryRow) -> "CheckHistory":
return cls(
id=row.id,
modpack_id=row.modpack_id,
checked_at=row.checked_at,
version_found=row.version_found,
notification_sent=row.notification_sent,
)
# ---------------------------------------------------------------------------
# Database façade
# ---------------------------------------------------------------------------
class Database:
"""All database operations for the modpack tracker."""
def __init__(self, db_path: str) -> None:
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
self.engine = create_engine(f"sqlite:///{db_path}", echo=False)
_Base.metadata.create_all(self.engine)
# --- write operations ---------------------------------------------------
def add_modpack(self, curseforge_id: int, name: str) -> Modpack:
"""Add a new modpack to the watch list.
Raises:
ValueError: if the modpack is already being tracked.
"""
with Session(self.engine) as session:
existing = session.scalar(
select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id)
)
if existing is not None:
raise ValueError(
f"Modpack ID {curseforge_id} is already being tracked."
)
row = _ModpackRow(
curseforge_id=curseforge_id,
name=name,
notification_enabled=True,
)
session.add(row)
session.commit()
return Modpack._from_row(row)
def remove_modpack(self, curseforge_id: int) -> bool:
"""Remove a modpack and its history. Returns False if not found."""
with Session(self.engine) as session:
row = session.scalar(
select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id)
)
if row is None:
return False
session.delete(row)
session.commit()
return True
def update_version(
self,
curseforge_id: int,
version: str,
notification_sent: bool = False,
) -> None:
"""Record a new version check result, updating the cached version.
Raises:
ValueError: if the modpack is not in the database.
"""
with Session(self.engine) as session:
row = session.scalar(
select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id)
)
if row is None:
raise ValueError(f"Modpack {curseforge_id} not found in database.")
row.current_version = version
row.last_checked = datetime.utcnow()
session.add(
_CheckHistoryRow(
modpack_id=row.id,
checked_at=datetime.utcnow(),
version_found=version,
notification_sent=notification_sent,
)
)
session.commit()
def toggle_notifications(self, curseforge_id: int, enabled: bool) -> bool:
"""Enable or disable Discord notifications for a modpack.
Returns False if modpack not found.
"""
with Session(self.engine) as session:
row = session.scalar(
select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id)
)
if row is None:
return False
row.notification_enabled = enabled
session.commit()
return True
# --- read operations ----------------------------------------------------
def get_modpack(self, curseforge_id: int) -> Optional[Modpack]:
"""Return a single modpack, or None if not tracked."""
with Session(self.engine) as session:
row = session.scalar(
select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id)
)
return Modpack._from_row(row) if row else None
def get_all_modpacks(self) -> List[Modpack]:
"""Return all tracked modpacks."""
with Session(self.engine) as session:
rows = session.scalars(select(_ModpackRow)).all()
return [Modpack._from_row(r) for r in rows]
def get_check_history(
self, curseforge_id: int, limit: int = 10
) -> List[CheckHistory]:
"""Return recent check history for a modpack, newest first."""
with Session(self.engine) as session:
modpack_row = session.scalar(
select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id)
)
if modpack_row is None:
return []
rows = session.scalars(
select(_CheckHistoryRow)
.where(_CheckHistoryRow.modpack_id == modpack_row.id)
.order_by(_CheckHistoryRow.checked_at.desc())
.limit(limit)
).all()
return [CheckHistory._from_row(r) for r in rows]

View File

@@ -0,0 +1,122 @@
"""Notification delivery — Discord webhooks and generic HTTP webhooks."""
from __future__ import annotations
from typing import Optional
import requests
class NotificationError(Exception):
"""Raised when a notification cannot be delivered."""
class DiscordNotifier:
"""Send modpack update alerts to a Discord channel via webhook.
Embeds are used so the messages look polished without additional
configuration on the user's side.
"""
_EMBED_COLOR_UPDATE = 0xF5A623 # amber — update available
_EMBED_COLOR_TEST = 0x43B581 # green — test / OK
def __init__(self, webhook_url: str, timeout: int = 10) -> None:
self.webhook_url = webhook_url
self.timeout = timeout
# ------------------------------------------------------------------
# Public helpers
# ------------------------------------------------------------------
def send_update(
self,
modpack_name: str,
curseforge_id: int,
old_version: Optional[str],
new_version: str,
) -> None:
"""Post an update-available embed to Discord.
Raises:
NotificationError: if the webhook request fails.
"""
embed = {
"title": "Modpack Update Available",
"color": self._EMBED_COLOR_UPDATE,
"url": f"https://www.curseforge.com/minecraft/modpacks/{curseforge_id}",
"fields": [
{
"name": "Modpack",
"value": modpack_name,
"inline": True,
},
{
"name": "CurseForge ID",
"value": str(curseforge_id),
"inline": True,
},
{
"name": "Previous Version",
"value": old_version or "Unknown",
"inline": False,
},
{
"name": "New Version",
"value": new_version,
"inline": False,
},
],
"footer": {
"text": "Modpack Version Checker \u2022 Firefrost Gaming",
},
}
self._post({"embeds": [embed]})
def test(self) -> None:
"""Send a simple test message to verify the webhook URL works.
Raises:
NotificationError: if the request fails.
"""
self._post(
{
"embeds": [
{
"title": "Webhook Connected",
"description": (
"Modpack Version Checker notifications are working correctly."
),
"color": self._EMBED_COLOR_TEST,
"footer": {"text": "Modpack Version Checker \u2022 Firefrost Gaming"},
}
]
}
)
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
def _post(self, payload: dict) -> None:
try:
response = requests.post(
self.webhook_url,
json=payload,
timeout=self.timeout,
)
# Discord returns 204 No Content on success
if response.status_code not in (200, 204):
raise NotificationError(
f"Discord returned HTTP {response.status_code}: {response.text[:200]}"
)
except requests.ConnectionError as exc:
raise NotificationError(
"Could not reach Discord. Check your internet connection."
) from exc
except requests.Timeout:
raise NotificationError(
f"Discord webhook timed out after {self.timeout}s."
)
except requests.RequestException as exc:
raise NotificationError(f"Webhook request failed: {exc}") from exc