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,21 @@
MIT License
Copyright (c) 2026 Firefrost Gaming
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,183 @@
#!/bin/bash
# Auto-generated script to create all modpack-version-checker files
BASE_DIR="/home/claude/firefrost-operations-manual/docs/tasks/modpack-version-checker/code/modpack-version-checker"
cd "$BASE_DIR"
# Already created: src/modpack_checker/__init__.py, src/modpack_checker/config.py
# Create database.py
cat > src/modpack_checker/database.py << 'EOF'
"""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
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")
@dataclass
class 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:
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,
)
class Database:
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)
def add_modpack(self, curseforge_id: int, name: str) -> Modpack:
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:
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:
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:
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
def get_modpack(self, curseforge_id: int) -> Optional[Modpack]:
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]:
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]:
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]
EOF
echo "Created database.py"

View File

@@ -0,0 +1,228 @@
# CLI Reference
Full reference for all `modpack-checker` commands and flags.
---
## Global Flags
| Flag | Description |
|---|---|
| `--version` | Show version and exit |
| `-h`, `--help` | Show help for any command |
---
## config
Manage configuration settings stored in `~/.config/modpack-checker/config.json`.
### config set-key
```
modpack-checker config set-key API_KEY
```
Save and validate a CurseForge API key. The key is tested against the API immediately; a warning is shown if validation fails but the key is still saved.
**Get a free key at:** https://console.curseforge.com
---
### config set-webhook
```
modpack-checker config set-webhook WEBHOOK_URL
```
Save a Discord webhook URL. A test embed is sent immediately to confirm the webhook works.
---
### config set-interval
```
modpack-checker config set-interval HOURS
```
Set how many hours the scheduler waits between checks. Accepts values 1168.
**Default:** 6
---
### config show
```
modpack-checker config show
```
Display the current configuration. The API key is masked (first 4 / last 4 characters shown).
---
## add
```
modpack-checker add MODPACK_ID
```
Add a CurseForge modpack to the watch list. The modpack name is fetched from the API and stored locally. Run `check` afterward to record the initial version.
**Arguments:**
| Argument | Type | Description |
|---|---|---|
| `MODPACK_ID` | int | CurseForge project ID (from the modpack's URL) |
**Example:**
```bash
modpack-checker add 238222 # All The Mods 9
```
---
## remove
```
modpack-checker remove MODPACK_ID
```
Remove a modpack and all its check history. Prompts for confirmation.
**Arguments:**
| Argument | Type | Description |
|---|---|---|
| `MODPACK_ID` | int | CurseForge project ID |
---
## list
```
modpack-checker list
```
Display all watched modpacks in a table showing:
- CurseForge ID
- Name
- Last known version
- Last check timestamp
- Whether Discord alerts are enabled
---
## check
```
modpack-checker check [OPTIONS]
```
Check watched modpacks against the CurseForge API and report on their status.
**Options:**
| Flag | Description |
|---|---|
| `--id INTEGER`, `-m INTEGER` | Check only this modpack ID |
| `--notify` / `--no-notify` | Send Discord notifications (default: on) |
**Output:**
- `✓` — up to date
- `↑` — update available (version shown)
- `→` — initial version recorded (first check)
- `✗` — API error
---
## status
```
modpack-checker status MODPACK_ID [OPTIONS]
```
Show a detailed panel for one modpack plus its check history.
**Arguments:**
| Argument | Type | Description |
|---|---|---|
| `MODPACK_ID` | int | CurseForge project ID |
**Options:**
| Flag | Default | Description |
|---|---|---|
| `--limit INTEGER`, `-n INTEGER` | 10 | Number of history entries to display |
---
## notifications
```
modpack-checker notifications MODPACK_ID [--enable | --disable]
```
Toggle Discord alerts for a specific modpack without removing it from the watch list.
**Options:**
| Flag | Description |
|---|---|
| `--enable` | Enable notifications (default) |
| `--disable` | Suppress notifications for this modpack |
---
## schedule
```
modpack-checker schedule [OPTIONS]
```
Start a blocking background process that checks for updates on a repeating interval. Requires the `[scheduler]` extra (`pip install "modpack-version-checker[scheduler]"`).
**Options:**
| Flag | Description |
|---|---|
| `--hours INTEGER`, `-h INTEGER` | Override the configured interval |
Runs a check immediately on startup, then repeats at the configured interval. Press `Ctrl-C` to stop.
---
## Configuration File
Location: `~/.config/modpack-checker/config.json`
```json
{
"curseforge_api_key": "your-key-here",
"discord_webhook_url": "https://discord.com/api/webhooks/...",
"database_path": "/home/user/.config/modpack-checker/modpacks.db",
"check_interval_hours": 6,
"notification_on_update": true
}
```
---
## Database
Location: `~/.config/modpack-checker/modpacks.db` (SQLite)
The database is managed automatically. To reset completely:
```bash
rm ~/.config/modpack-checker/modpacks.db
```
---
## Exit Codes
| Code | Meaning |
|---|---|
| `0` | Success |
| `1` | Error (API failure, modpack not found, missing config, etc.) |

View File

@@ -0,0 +1,166 @@
# Installation Guide
## Requirements
- Python 3.9 or newer
- pip (comes with Python)
- A free CurseForge API key
---
## Step 1 — Install Python
Verify Python is installed:
```bash
python3 --version
# Should show: Python 3.9.x or newer
```
If not installed, download from [python.org](https://www.python.org/downloads/).
---
## Step 2 — Install Modpack Version Checker
### Option A: pip (recommended)
```bash
pip install modpack-version-checker
```
### Option B: Install with scheduler support
```bash
pip install "modpack-version-checker[scheduler]"
```
This adds APScheduler for the `modpack-checker schedule` background daemon command.
### Option C: Install from source
```bash
git clone https://github.com/firefrostgaming/modpack-version-checker.git
cd modpack-version-checker
pip install -e ".[scheduler]"
```
---
## Step 3 — Get a CurseForge API Key (free)
1. Go to [console.curseforge.com](https://console.curseforge.com)
2. Log in or create a free account
3. Click **Create API Key**
4. Copy the key
---
## Step 4 — Configure
```bash
modpack-checker config set-key YOUR_API_KEY_HERE
```
The key is stored in `~/.config/modpack-checker/config.json`.
---
## Step 5 (Optional) — Set Up Discord Notifications
1. In your Discord server, go to **Channel Settings → Integrations → Webhooks**
2. Click **New Webhook** and copy the URL
3. Run:
```bash
modpack-checker config set-webhook https://discord.com/api/webhooks/YOUR_WEBHOOK_URL
```
A test message will be sent to confirm it works.
---
## Step 6 — Add Your First Modpack
Find your modpack's CurseForge project ID in the URL:
`https://www.curseforge.com/minecraft/modpacks/all-the-mods-9` → go to the page, the ID is in the sidebar.
```bash
modpack-checker add 238222 # All The Mods 9
modpack-checker add 361392 # RLCraft
```
---
## Step 7 — Check for Updates
```bash
modpack-checker check
```
---
## Background Scheduler (Optional)
Run continuous checks automatically:
```bash
# Check every 6 hours (default)
modpack-checker schedule
# Check every 12 hours
modpack-checker schedule --hours 12
```
To run as a Linux systemd service, create `/etc/systemd/system/modpack-checker.service`:
```ini
[Unit]
Description=Modpack Version Checker
After=network.target
[Service]
Type=simple
User=YOUR_USERNAME
ExecStart=/usr/local/bin/modpack-checker schedule --hours 6
Restart=on-failure
[Install]
WantedBy=multi-user.target
```
Then:
```bash
sudo systemctl enable --now modpack-checker
```
---
## Uninstall
```bash
pip uninstall modpack-version-checker
# Remove config and database (optional)
rm -rf ~/.config/modpack-checker/
```
---
## Troubleshooting
**`modpack-checker: command not found`**
- Make sure pip's script directory is in your PATH
- Try: `python3 -m modpack_checker.cli`
**`Invalid API key`**
- Double-check the key at [console.curseforge.com](https://console.curseforge.com)
- Ensure there are no extra spaces when setting it
**`Connection failed`**
- Check your internet connection
- CurseForge API may be temporarily down; try again in a few minutes
**`Modpack shows Unknown`**
- Verify the project ID is correct by checking the CurseForge page
- Some older modpacks have no files listed via the API

View File

@@ -0,0 +1,91 @@
# Modpack Version Checker
**Monitor CurseForge modpack versions and get instantly notified when updates are released.**
Stop manually checking CurseForge every day. Modpack Version Checker tracks your modpacks and fires a Discord alert the moment a new version drops — saving you 20+ minutes of daily maintenance.
---
## Features
- **Multi-modpack tracking** — watch as many packs as you need in a single database
- **Discord notifications** — rich embeds with old/new version info sent automatically
- **Version history** — full log of every check and what version was found
- **Per-modpack notification control** — silence specific packs without removing them
- **Built-in scheduler** — runs in the background and checks on a configurable interval
- **Manual override** — force a check any time with `modpack-checker check`
- **Graceful error handling** — API downtime shows clear messages, never crashes
---
## Quick Start
```bash
# 1. Install
pip install modpack-version-checker
# 2. Set your CurseForge API key (free at console.curseforge.com)
modpack-checker config set-key YOUR_API_KEY
# 3. Add a modpack (use its CurseForge project ID)
modpack-checker add 238222 # All The Mods 9
# 4. Check for updates
modpack-checker check
```
---
## Installation
See [INSTALLATION.md](INSTALLATION.md) for full setup instructions including optional Discord notifications and background scheduling.
---
## Commands
| Command | Description |
|---|---|
| `modpack-checker add <id>` | Add a modpack to the watch list |
| `modpack-checker remove <id>` | Remove a modpack from the watch list |
| `modpack-checker list` | Show all watched modpacks and versions |
| `modpack-checker check` | Check all modpacks for updates now |
| `modpack-checker check --id <id>` | Check a single modpack |
| `modpack-checker status <id>` | Show detailed info + check history |
| `modpack-checker notifications <id> --enable/--disable` | Toggle alerts per modpack |
| `modpack-checker schedule` | Start background scheduler |
| `modpack-checker config set-key <key>` | Save CurseForge API key |
| `modpack-checker config set-webhook <url>` | Save Discord webhook URL |
| `modpack-checker config set-interval <hours>` | Set check interval |
| `modpack-checker config show` | Display current configuration |
See [API.md](API.md) for full command reference with all flags.
---
## Pricing
| Tier | Price | Features |
|---|---|---|
| Standard | $9.99 | All features listed above |
One-time purchase. No subscriptions. Available on [BuiltByBit](https://builtbybit.com).
---
## Requirements
- Python 3.9 or newer
- A free CurseForge API key ([get one here](https://console.curseforge.com))
- Linux, macOS, or Windows
---
## Support
- Discord: [Firefrost Gaming Support Server]
- Response time: within 48 hours
---
*Built by Firefrost Gaming*

View File

@@ -0,0 +1,16 @@
# Core runtime dependencies
requests>=2.28.0
click>=8.1.0
rich>=13.0.0
pydantic>=2.0.0
sqlalchemy>=2.0.0
# Optional: background scheduler
# apscheduler>=3.10.0
# Development / testing
# pytest>=7.0.0
# pytest-cov>=4.0.0
# pytest-mock>=3.10.0
# responses>=0.23.0
# black>=23.0.0

View File

@@ -0,0 +1,15 @@
[tool:pytest]
testpaths = tests
pythonpath = src
addopts = --tb=short -q
[coverage:run]
source = modpack_checker
[coverage:report]
show_missing = True
skip_covered = False
[flake8]
max-line-length = 100
exclude = .git,__pycache__,build,dist

View File

@@ -0,0 +1,61 @@
"""Package setup for Modpack Version Checker."""
from pathlib import Path
from setuptools import find_packages, setup
long_description = (Path(__file__).parent / "docs" / "README.md").read_text(
encoding="utf-8"
)
setup(
name="modpack-version-checker",
version="1.0.0",
author="Firefrost Gaming",
author_email="support@firefrostgaming.com",
description="Monitor CurseForge modpack versions and get notified of updates",
long_description=long_description,
long_description_content_type="text/markdown",
url="https://firefrostgaming.com",
license="MIT",
packages=find_packages(where="src"),
package_dir={"": "src"},
python_requires=">=3.9",
install_requires=[
"requests>=2.28.0",
"click>=8.1.0",
"rich>=13.0.0",
"pydantic>=2.0.0",
"sqlalchemy>=2.0.0",
],
extras_require={
"scheduler": ["apscheduler>=3.10.0"],
"dev": [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
"pytest-mock>=3.10.0",
"responses>=0.23.0",
"black>=23.0.0",
],
},
entry_points={
"console_scripts": [
"modpack-checker=modpack_checker.cli:main",
],
},
classifiers=[
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
"Intended Audience :: System Administrators",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Games/Entertainment",
"Topic :: System :: Monitoring",
],
keywords="minecraft modpack curseforge version checker monitor",
)

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

View File

@@ -0,0 +1,11 @@
"""Shared pytest fixtures."""
import pytest
from modpack_checker.database import Database
@pytest.fixture
def db(tmp_path):
"""In-memory-equivalent SQLite database backed by a temp directory."""
return Database(str(tmp_path / "test.db"))

View File

@@ -0,0 +1,339 @@
"""Tests for cli.py using Click's test runner."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
import responses as responses_lib
from click.testing import CliRunner
from modpack_checker.cli import cli
from modpack_checker.curseforge import CurseForgeNotFoundError
@pytest.fixture
def runner():
return CliRunner()
@pytest.fixture
def mock_cfg(tmp_path):
"""Return a Config-like mock backed by a real temp database."""
cfg = MagicMock()
cfg.curseforge_api_key = "test-api-key"
cfg.database_path = str(tmp_path / "test.db")
cfg.discord_webhook_url = None
cfg.notification_on_update = True
cfg.check_interval_hours = 6
return cfg
# ---------------------------------------------------------------------------
# Root / help
# ---------------------------------------------------------------------------
def test_help(runner):
result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0
assert "Modpack Version Checker" in result.output
def test_version(runner):
result = runner.invoke(cli, ["--version"])
assert result.exit_code == 0
assert "1.0.0" in result.output
# ---------------------------------------------------------------------------
# config show
# ---------------------------------------------------------------------------
def test_config_show(runner, mock_cfg):
mock_cfg.curseforge_api_key = "abcd1234efgh5678"
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["config", "show"])
assert result.exit_code == 0
assert "abcd" in result.output # first 4 chars
assert "5678" in result.output # last 4 chars
def test_config_show_no_key(runner, mock_cfg):
mock_cfg.curseforge_api_key = ""
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["config", "show"])
assert result.exit_code == 0
assert "Not configured" in result.output
# ---------------------------------------------------------------------------
# list
# ---------------------------------------------------------------------------
def test_list_empty(runner, mock_cfg):
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["list"])
assert result.exit_code == 0
assert "empty" in result.output.lower()
def test_list_with_modpacks(runner, mock_cfg, tmp_path):
from modpack_checker.database import Database
db = Database(str(tmp_path / "test.db"))
db.add_modpack(111, "Pack Alpha")
db.add_modpack(222, "Pack Beta")
mock_cfg.database_path = str(tmp_path / "test.db")
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["list"])
assert result.exit_code == 0
assert "Pack Alpha" in result.output
assert "Pack Beta" in result.output
# ---------------------------------------------------------------------------
# add
# ---------------------------------------------------------------------------
def test_add_requires_api_key(runner, mock_cfg):
mock_cfg.curseforge_api_key = ""
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["add", "12345"])
assert result.exit_code == 1
assert "API key" in result.output
@responses_lib.activate
def test_add_success(runner, mock_cfg):
responses_lib.add(
responses_lib.GET,
"https://api.curseforge.com/v1/mods/12345",
json={"data": {"id": 12345, "name": "Test Modpack", "latestFiles": []}},
status=200,
)
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["add", "12345"])
assert result.exit_code == 0
assert "Test Modpack" in result.output
assert "tracking" in result.output.lower()
@responses_lib.activate
def test_add_not_found(runner, mock_cfg):
responses_lib.add(
responses_lib.GET,
"https://api.curseforge.com/v1/mods/99999",
status=404,
)
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["add", "99999"])
assert result.exit_code == 1
assert "99999" in result.output
@responses_lib.activate
def test_add_duplicate_shows_warning(runner, mock_cfg, tmp_path):
responses_lib.add(
responses_lib.GET,
"https://api.curseforge.com/v1/mods/12345",
json={"data": {"id": 12345, "name": "Test Modpack", "latestFiles": []}},
status=200,
)
from modpack_checker.database import Database
db = Database(str(tmp_path / "test.db"))
db.add_modpack(12345, "Test Modpack")
mock_cfg.database_path = str(tmp_path / "test.db")
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["add", "12345"])
assert "already tracked" in result.output.lower()
# ---------------------------------------------------------------------------
# remove
# ---------------------------------------------------------------------------
def test_remove_not_in_list(runner, mock_cfg):
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["remove", "99999"], input="y\n")
assert result.exit_code == 1
assert "not in your watch list" in result.output
def test_remove_success(runner, mock_cfg, tmp_path):
from modpack_checker.database import Database
db = Database(str(tmp_path / "test.db"))
db.add_modpack(12345, "Test Pack")
mock_cfg.database_path = str(tmp_path / "test.db")
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["remove", "12345"], input="y\n")
assert result.exit_code == 0
assert "Removed" in result.output
def test_remove_aborted(runner, mock_cfg, tmp_path):
from modpack_checker.database import Database
db = Database(str(tmp_path / "test.db"))
db.add_modpack(12345, "Test Pack")
mock_cfg.database_path = str(tmp_path / "test.db")
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["remove", "12345"], input="n\n")
# Aborted — pack should still exist
assert db.get_modpack(12345) is not None
# ---------------------------------------------------------------------------
# check
# ---------------------------------------------------------------------------
def test_check_empty_list(runner, mock_cfg):
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["check"])
assert result.exit_code == 0
assert "No modpacks" in result.output
@responses_lib.activate
def test_check_up_to_date(runner, mock_cfg, tmp_path):
from modpack_checker.database import Database
db = Database(str(tmp_path / "test.db"))
db.add_modpack(12345, "Test Pack")
db.update_version(12345, "1.0.0")
responses_lib.add(
responses_lib.GET,
"https://api.curseforge.com/v1/mods/12345",
json={
"data": {
"id": 12345,
"name": "Test Pack",
"latestFiles": [
{
"id": 1,
"displayName": "1.0.0",
"fileName": "pack-1.0.0.zip",
"fileDate": "2026-01-01T00:00:00Z",
}
],
}
},
status=200,
)
mock_cfg.database_path = str(tmp_path / "test.db")
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["check", "--no-notify"])
assert result.exit_code == 0
assert "up to date" in result.output.lower()
@responses_lib.activate
def test_check_update_available(runner, mock_cfg, tmp_path):
from modpack_checker.database import Database
db = Database(str(tmp_path / "test.db"))
db.add_modpack(12345, "Test Pack")
db.update_version(12345, "1.0.0")
responses_lib.add(
responses_lib.GET,
"https://api.curseforge.com/v1/mods/12345",
json={
"data": {
"id": 12345,
"name": "Test Pack",
"latestFiles": [
{
"id": 2,
"displayName": "1.1.0",
"fileName": "pack-1.1.0.zip",
"fileDate": "2026-02-01T00:00:00Z",
}
],
}
},
status=200,
)
mock_cfg.database_path = str(tmp_path / "test.db")
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["check", "--no-notify"])
assert result.exit_code == 0
assert "1.1.0" in result.output
# ---------------------------------------------------------------------------
# status
# ---------------------------------------------------------------------------
def test_status_not_found(runner, mock_cfg):
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["status", "99999"])
assert result.exit_code == 1
assert "not in your watch list" in result.output
def test_status_shows_info(runner, mock_cfg, tmp_path):
from modpack_checker.database import Database
db = Database(str(tmp_path / "test.db"))
db.add_modpack(12345, "Test Pack")
db.update_version(12345, "2.0.0")
mock_cfg.database_path = str(tmp_path / "test.db")
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["status", "12345"])
assert result.exit_code == 0
assert "Test Pack" in result.output
assert "2.0.0" in result.output
# ---------------------------------------------------------------------------
# notifications command
# ---------------------------------------------------------------------------
def test_notifications_disable(runner, mock_cfg, tmp_path):
from modpack_checker.database import Database
db = Database(str(tmp_path / "test.db"))
db.add_modpack(12345, "Test Pack")
mock_cfg.database_path = str(tmp_path / "test.db")
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["notifications", "12345", "--disable"])
assert result.exit_code == 0
assert "disabled" in result.output
assert db.get_modpack(12345).notification_enabled is False
def test_notifications_enable(runner, mock_cfg, tmp_path):
from modpack_checker.database import Database
db = Database(str(tmp_path / "test.db"))
db.add_modpack(12345, "Test Pack")
db.toggle_notifications(12345, False)
mock_cfg.database_path = str(tmp_path / "test.db")
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["notifications", "12345", "--enable"])
assert result.exit_code == 0
assert "enabled" in result.output
assert db.get_modpack(12345).notification_enabled is True
def test_notifications_missing_modpack(runner, mock_cfg):
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["notifications", "99999", "--enable"])
assert result.exit_code == 1

View File

@@ -0,0 +1,72 @@
"""Tests for config.py."""
import json
from pathlib import Path
from unittest.mock import patch
import pytest
from modpack_checker.config import Config
def test_default_values():
cfg = Config()
assert cfg.curseforge_api_key == ""
assert cfg.discord_webhook_url is None
assert cfg.check_interval_hours == 6
assert cfg.notification_on_update is True
def test_is_configured_without_key():
assert Config().is_configured() is False
def test_is_configured_with_key():
assert Config(curseforge_api_key="abc123").is_configured() is True
def test_save_and_load_round_trip(tmp_path):
config_dir = tmp_path / ".config" / "modpack-checker"
config_file = config_dir / "config.json"
with patch("modpack_checker.config.CONFIG_DIR", config_dir), \
patch("modpack_checker.config.CONFIG_FILE", config_file):
original = Config(
curseforge_api_key="test-key-xyz",
check_interval_hours=12,
notification_on_update=False,
)
original.save()
loaded = Config.load()
assert loaded.curseforge_api_key == "test-key-xyz"
assert loaded.check_interval_hours == 12
assert loaded.notification_on_update is False
def test_load_returns_defaults_when_file_missing(tmp_path):
config_file = tmp_path / "nonexistent.json"
with patch("modpack_checker.config.CONFIG_FILE", config_file):
cfg = Config.load()
assert cfg.curseforge_api_key == ""
def test_load_returns_defaults_on_corrupted_file(tmp_path):
config_dir = tmp_path / ".config" / "modpack-checker"
config_dir.mkdir(parents=True)
config_file = config_dir / "config.json"
config_file.write_text("{ this is not valid json }")
with patch("modpack_checker.config.CONFIG_DIR", config_dir), \
patch("modpack_checker.config.CONFIG_FILE", config_file):
cfg = Config.load()
assert cfg.curseforge_api_key == ""
def test_interval_bounds():
with pytest.raises(Exception):
Config(check_interval_hours=0)
with pytest.raises(Exception):
Config(check_interval_hours=169)

View File

@@ -0,0 +1,227 @@
"""Tests for curseforge.py."""
import pytest
import responses as responses_lib
from modpack_checker.curseforge import (
CurseForgeAuthError,
CurseForgeClient,
CurseForgeError,
CurseForgeNotFoundError,
CurseForgeRateLimitError,
)
BASE = "https://api.curseforge.com"
@pytest.fixture
def client():
return CurseForgeClient("test-api-key", timeout=5)
# ---------------------------------------------------------------------------
# get_mod
# ---------------------------------------------------------------------------
@responses_lib.activate
def test_get_mod_success(client):
responses_lib.add(
responses_lib.GET,
f"{BASE}/v1/mods/123456",
json={"data": {"id": 123456, "name": "Test Pack", "latestFiles": []}},
status=200,
)
mod = client.get_mod(123456)
assert mod["name"] == "Test Pack"
@responses_lib.activate
def test_get_mod_not_found(client):
responses_lib.add(responses_lib.GET, f"{BASE}/v1/mods/999", status=404)
with pytest.raises(CurseForgeNotFoundError):
client.get_mod(999)
@responses_lib.activate
def test_get_mod_invalid_key_401(client):
responses_lib.add(responses_lib.GET, f"{BASE}/v1/mods/123", status=401)
with pytest.raises(CurseForgeAuthError, match="Invalid API key"):
client.get_mod(123)
@responses_lib.activate
def test_get_mod_forbidden_403(client):
responses_lib.add(responses_lib.GET, f"{BASE}/v1/mods/123", status=403)
with pytest.raises(CurseForgeAuthError, match="permission"):
client.get_mod(123)
@responses_lib.activate
def test_get_mod_rate_limit_429(client):
responses_lib.add(responses_lib.GET, f"{BASE}/v1/mods/123", status=429)
with pytest.raises(CurseForgeRateLimitError):
client.get_mod(123)
@responses_lib.activate
def test_get_mod_server_error(client):
# responses library doesn't retry by default in tests; just test the exception
responses_lib.add(responses_lib.GET, f"{BASE}/v1/mods/123", status=500)
responses_lib.add(responses_lib.GET, f"{BASE}/v1/mods/123", status=500)
responses_lib.add(responses_lib.GET, f"{BASE}/v1/mods/123", status=500)
responses_lib.add(responses_lib.GET, f"{BASE}/v1/mods/123", status=500)
with pytest.raises(CurseForgeError):
client.get_mod(123)
# ---------------------------------------------------------------------------
# get_mod_name
# ---------------------------------------------------------------------------
@responses_lib.activate
def test_get_mod_name(client):
responses_lib.add(
responses_lib.GET,
f"{BASE}/v1/mods/100",
json={"data": {"id": 100, "name": "All The Mods 9", "latestFiles": []}},
status=200,
)
assert client.get_mod_name(100) == "All The Mods 9"
@responses_lib.activate
def test_get_mod_name_fallback(client):
"""If 'name' key is missing, return generic fallback."""
responses_lib.add(
responses_lib.GET,
f"{BASE}/v1/mods/100",
json={"data": {"id": 100, "latestFiles": []}},
status=200,
)
assert client.get_mod_name(100) == "Modpack 100"
# ---------------------------------------------------------------------------
# get_latest_file
# ---------------------------------------------------------------------------
@responses_lib.activate
def test_get_latest_file_uses_latest_files(client):
"""get_latest_file should prefer the latestFiles field (fast path)."""
responses_lib.add(
responses_lib.GET,
f"{BASE}/v1/mods/200",
json={
"data": {
"id": 200,
"name": "Pack",
"latestFiles": [
{
"id": 9001,
"displayName": "Pack 2.0.0",
"fileName": "pack-2.0.0.zip",
"fileDate": "2026-01-15T00:00:00Z",
},
{
"id": 9000,
"displayName": "Pack 1.0.0",
"fileName": "pack-1.0.0.zip",
"fileDate": "2025-12-01T00:00:00Z",
},
],
}
},
status=200,
)
file_obj = client.get_latest_file(200)
assert file_obj["displayName"] == "Pack 2.0.0"
@responses_lib.activate
def test_get_latest_file_fallback_files_endpoint(client):
"""Falls back to the /files endpoint when latestFiles is empty."""
responses_lib.add(
responses_lib.GET,
f"{BASE}/v1/mods/300",
json={"data": {"id": 300, "name": "Pack", "latestFiles": []}},
status=200,
)
responses_lib.add(
responses_lib.GET,
f"{BASE}/v1/mods/300/files",
json={
"data": [
{"id": 8000, "displayName": "Pack 3.0.0", "fileName": "pack-3.0.0.zip"}
]
},
status=200,
)
file_obj = client.get_latest_file(300)
assert file_obj["displayName"] == "Pack 3.0.0"
@responses_lib.activate
def test_get_latest_file_no_files_returns_none(client):
responses_lib.add(
responses_lib.GET,
f"{BASE}/v1/mods/400",
json={"data": {"id": 400, "name": "Pack", "latestFiles": []}},
status=200,
)
responses_lib.add(
responses_lib.GET,
f"{BASE}/v1/mods/400/files",
json={"data": []},
status=200,
)
assert client.get_latest_file(400) is None
# ---------------------------------------------------------------------------
# extract_version
# ---------------------------------------------------------------------------
def test_extract_version_prefers_display_name(client):
file_obj = {"displayName": "ATM9 1.2.3", "fileName": "atm9-1.2.3.zip", "id": 1}
assert client.extract_version(file_obj) == "ATM9 1.2.3"
def test_extract_version_falls_back_to_filename(client):
file_obj = {"displayName": "", "fileName": "pack-1.0.0.zip", "id": 1}
assert client.extract_version(file_obj) == "pack-1.0.0.zip"
def test_extract_version_last_resort_file_id(client):
file_obj = {"displayName": "", "fileName": "", "id": 9999}
assert client.extract_version(file_obj) == "File ID 9999"
def test_extract_version_strips_whitespace(client):
file_obj = {"displayName": " Pack 1.0 ", "fileName": "pack.zip", "id": 1}
assert client.extract_version(file_obj) == "Pack 1.0"
# ---------------------------------------------------------------------------
# validate_api_key
# ---------------------------------------------------------------------------
@responses_lib.activate
def test_validate_api_key_success(client):
responses_lib.add(
responses_lib.GET,
f"{BASE}/v1/games/432",
json={"data": {"id": 432, "name": "Minecraft"}},
status=200,
)
assert client.validate_api_key() is True
@responses_lib.activate
def test_validate_api_key_failure(client):
responses_lib.add(responses_lib.GET, f"{BASE}/v1/games/432", status=401)
assert client.validate_api_key() is False

View File

@@ -0,0 +1,174 @@
"""Tests for database.py."""
from datetime import datetime
import pytest
from modpack_checker.database import Database
# ---------------------------------------------------------------------------
# add_modpack
# ---------------------------------------------------------------------------
def test_add_modpack_returns_correct_fields(db):
mp = db.add_modpack(12345, "Test Pack")
assert mp.curseforge_id == 12345
assert mp.name == "Test Pack"
assert mp.current_version is None
assert mp.last_checked is None
assert mp.notification_enabled is True
def test_add_modpack_duplicate_raises(db):
db.add_modpack(12345, "Test Pack")
with pytest.raises(ValueError, match="already being tracked"):
db.add_modpack(12345, "Test Pack Again")
# ---------------------------------------------------------------------------
# remove_modpack
# ---------------------------------------------------------------------------
def test_remove_modpack_returns_true(db):
db.add_modpack(12345, "Test Pack")
assert db.remove_modpack(12345) is True
def test_remove_modpack_missing_returns_false(db):
assert db.remove_modpack(99999) is False
def test_remove_modpack_deletes_record(db):
db.add_modpack(12345, "Test Pack")
db.remove_modpack(12345)
assert db.get_modpack(12345) is None
# ---------------------------------------------------------------------------
# get_modpack
# ---------------------------------------------------------------------------
def test_get_modpack_found(db):
db.add_modpack(111, "Pack A")
mp = db.get_modpack(111)
assert mp is not None
assert mp.name == "Pack A"
def test_get_modpack_not_found(db):
assert db.get_modpack(999) is None
# ---------------------------------------------------------------------------
# get_all_modpacks
# ---------------------------------------------------------------------------
def test_get_all_modpacks_empty(db):
assert db.get_all_modpacks() == []
def test_get_all_modpacks_multiple(db):
db.add_modpack(1, "Pack A")
db.add_modpack(2, "Pack B")
db.add_modpack(3, "Pack C")
modpacks = db.get_all_modpacks()
assert len(modpacks) == 3
ids = {mp.curseforge_id for mp in modpacks}
assert ids == {1, 2, 3}
# ---------------------------------------------------------------------------
# update_version
# ---------------------------------------------------------------------------
def test_update_version_sets_fields(db):
db.add_modpack(12345, "Test Pack")
db.update_version(12345, "1.2.3", notification_sent=True)
mp = db.get_modpack(12345)
assert mp.current_version == "1.2.3"
assert mp.last_checked is not None
def test_update_version_nonexistent_raises(db):
with pytest.raises(ValueError, match="not found"):
db.update_version(99999, "1.0.0")
def test_update_version_multiple_times(db):
db.add_modpack(12345, "Test Pack")
db.update_version(12345, "1.0.0")
db.update_version(12345, "1.1.0")
mp = db.get_modpack(12345)
assert mp.current_version == "1.1.0"
# ---------------------------------------------------------------------------
# get_check_history
# ---------------------------------------------------------------------------
def test_check_history_newest_first(db):
db.add_modpack(12345, "Test Pack")
db.update_version(12345, "1.0.0", notification_sent=False)
db.update_version(12345, "1.1.0", notification_sent=True)
history = db.get_check_history(12345, limit=10)
assert len(history) == 2
assert history[0].version_found == "1.1.0"
assert history[0].notification_sent is True
assert history[1].version_found == "1.0.0"
def test_check_history_limit(db):
db.add_modpack(12345, "Test Pack")
for i in range(5):
db.update_version(12345, f"1.{i}.0")
history = db.get_check_history(12345, limit=3)
assert len(history) == 3
def test_check_history_missing_modpack(db):
assert db.get_check_history(99999) == []
# ---------------------------------------------------------------------------
# toggle_notifications
# ---------------------------------------------------------------------------
def test_toggle_notifications_disable(db):
db.add_modpack(12345, "Test Pack")
result = db.toggle_notifications(12345, False)
assert result is True
mp = db.get_modpack(12345)
assert mp.notification_enabled is False
def test_toggle_notifications_re_enable(db):
db.add_modpack(12345, "Test Pack")
db.toggle_notifications(12345, False)
db.toggle_notifications(12345, True)
assert db.get_modpack(12345).notification_enabled is True
def test_toggle_notifications_missing(db):
assert db.toggle_notifications(99999, True) is False
# ---------------------------------------------------------------------------
# cascade delete
# ---------------------------------------------------------------------------
def test_remove_modpack_also_removes_history(db):
db.add_modpack(12345, "Test Pack")
db.update_version(12345, "1.0.0")
db.update_version(12345, "1.1.0")
db.remove_modpack(12345)
# History should be gone (cascade delete)
assert db.get_check_history(12345) == []

View File

@@ -0,0 +1,83 @@
"""Tests for notifier.py."""
import pytest
import responses as responses_lib
from modpack_checker.notifier import DiscordNotifier, NotificationError
WEBHOOK_URL = "https://discord.com/api/webhooks/123456/abcdef"
@pytest.fixture
def notifier():
return DiscordNotifier(WEBHOOK_URL, timeout=5)
# ---------------------------------------------------------------------------
# send_update
# ---------------------------------------------------------------------------
@responses_lib.activate
def test_send_update_success(notifier):
responses_lib.add(responses_lib.POST, WEBHOOK_URL, status=204)
# Should not raise
notifier.send_update("Test Pack", 12345, "1.0.0", "1.1.0")
@responses_lib.activate
def test_send_update_initial_version(notifier):
"""old_version=None should be handled gracefully."""
responses_lib.add(responses_lib.POST, WEBHOOK_URL, status=204)
notifier.send_update("Test Pack", 12345, None, "1.0.0")
@responses_lib.activate
def test_send_update_bad_response_raises(notifier):
responses_lib.add(responses_lib.POST, WEBHOOK_URL, status=400, body="Bad Request")
with pytest.raises(NotificationError, match="HTTP 400"):
notifier.send_update("Test Pack", 12345, "1.0.0", "1.1.0")
@responses_lib.activate
def test_send_update_unauthorized_raises(notifier):
responses_lib.add(responses_lib.POST, WEBHOOK_URL, status=401)
with pytest.raises(NotificationError):
notifier.send_update("Test Pack", 12345, "1.0.0", "1.1.0")
# ---------------------------------------------------------------------------
# test
# ---------------------------------------------------------------------------
@responses_lib.activate
def test_test_webhook_success(notifier):
responses_lib.add(responses_lib.POST, WEBHOOK_URL, status=204)
notifier.test() # Should not raise
@responses_lib.activate
def test_test_webhook_failure_raises(notifier):
responses_lib.add(responses_lib.POST, WEBHOOK_URL, status=404)
with pytest.raises(NotificationError):
notifier.test()
# ---------------------------------------------------------------------------
# embed structure
# ---------------------------------------------------------------------------
@responses_lib.activate
def test_send_update_embed_contains_modpack_name(notifier):
"""Verify the correct embed payload is sent to Discord."""
responses_lib.add(responses_lib.POST, WEBHOOK_URL, status=204)
notifier.send_update("All The Mods 9", 238222, "0.2.0", "0.3.0")
assert len(responses_lib.calls) == 1
raw_body = responses_lib.calls[0].request.body
payload = raw_body.decode("utf-8") if isinstance(raw_body, bytes) else raw_body
assert "All The Mods 9" in payload
assert "238222" in payload
assert "0.3.0" in payload