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:
21
services/modpack-version-checker/LICENSE
Normal file
21
services/modpack-version-checker/LICENSE
Normal 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.
|
||||
183
services/modpack-version-checker/create_all_files.sh
Executable file
183
services/modpack-version-checker/create_all_files.sh
Executable 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"
|
||||
|
||||
228
services/modpack-version-checker/docs/API.md
Normal file
228
services/modpack-version-checker/docs/API.md
Normal 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 1–168.
|
||||
|
||||
**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.) |
|
||||
166
services/modpack-version-checker/docs/INSTALLATION.md
Normal file
166
services/modpack-version-checker/docs/INSTALLATION.md
Normal 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
|
||||
91
services/modpack-version-checker/docs/README.md
Normal file
91
services/modpack-version-checker/docs/README.md
Normal 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*
|
||||
16
services/modpack-version-checker/requirements.txt
Normal file
16
services/modpack-version-checker/requirements.txt
Normal 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
|
||||
15
services/modpack-version-checker/setup.cfg
Normal file
15
services/modpack-version-checker/setup.cfg
Normal 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
|
||||
61
services/modpack-version-checker/setup.py
Normal file
61
services/modpack-version-checker/setup.py
Normal 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",
|
||||
)
|
||||
@@ -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
|
||||
0
services/modpack-version-checker/tests/__init__.py
Normal file
0
services/modpack-version-checker/tests/__init__.py
Normal file
11
services/modpack-version-checker/tests/conftest.py
Normal file
11
services/modpack-version-checker/tests/conftest.py
Normal 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"))
|
||||
339
services/modpack-version-checker/tests/test_cli.py
Normal file
339
services/modpack-version-checker/tests/test_cli.py
Normal 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
|
||||
72
services/modpack-version-checker/tests/test_config.py
Normal file
72
services/modpack-version-checker/tests/test_config.py
Normal 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)
|
||||
227
services/modpack-version-checker/tests/test_curseforge.py
Normal file
227
services/modpack-version-checker/tests/test_curseforge.py
Normal 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
|
||||
174
services/modpack-version-checker/tests/test_database.py
Normal file
174
services/modpack-version-checker/tests/test_database.py
Normal 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) == []
|
||||
83
services/modpack-version-checker/tests/test_notifier.py
Normal file
83
services/modpack-version-checker/tests/test_notifier.py
Normal 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
|
||||
Reference in New Issue
Block a user