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:
@@ -0,0 +1,4 @@
|
||||
"""Modpack Version Checker - Monitor CurseForge modpack updates."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "Firefrost Gaming"
|
||||
565
services/modpack-version-checker/src/modpack_checker/cli.py
Normal file
565
services/modpack-version-checker/src/modpack_checker/cli.py
Normal 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, 1–168)."""
|
||||
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()
|
||||
@@ -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)
|
||||
@@ -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')}"
|
||||
225
services/modpack-version-checker/src/modpack_checker/database.py
Normal file
225
services/modpack-version-checker/src/modpack_checker/database.py
Normal 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]
|
||||
122
services/modpack-version-checker/src/modpack_checker/notifier.py
Normal file
122
services/modpack-version-checker/src/modpack_checker/notifier.py
Normal 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
|
||||
Reference in New Issue
Block a user