diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ab3368 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd + +# Virtual environments +.venv/ +venv/ +env/ + +# Build artifacts +dist/ +build/ +*.egg-info/ + +# Environment files (may contain secrets) +.env +.env.* + +# Test / coverage +.pytest_cache/ +.coverage +coverage.xml +htmlcov/ + +# Type checker +.mypy_cache/ + +# Ruff +.ruff_cache/ + +# Editors +.vscode/ +.idea/ +*.swp +*.swo + +# macOS +.DS_Store + +# uv +uv.lock diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1b87250 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,148 @@ +# mcp-familywall + +## Kontext + +Dieses Projekt entwickelt `mcp-familywall` – einen MCP-Server für den Lesezugriff +auf Family Wall (familywall.com). Der MCP-Server läuft lokal und wird in Claude Desktop +eingebunden. + + +## Infrastruktur + +| | | +|---|---| +| **Family Wall API** | `https://api.familywall.com/api` | +| **Gitea** | `https://gitea.gecheckt.de/marcus/mcp-familywall` | +| **Lokaler Code** | `D:\Dev\Projects\mcp-familywall` | +| **Sprache** | Python 3.12+, `uv`, MCP SDK, `httpx`, `keyring`, `click`, `rich` | + + +## Deploy-Workflow (nach jeder Code-Änderung) + +1. Claude Code committet und pusht (bei Berechtigungsfehler: bis zu 2 Retries, je 1s warten) + +## Aktueller Stand + +### Implementierte Tools (v1.0) + +| Kategorie | Tools | +|---|---| +| Kreise | `get_circles` | +| Listen | `get_lists` | +| Tasks | `get_tasks` | + + +## Roadmap + +- v1.0: Lesezugriff (Kreise + Listen + Tasks) ← aktuell +- v2.0: Schreibzugriff (Tasks anlegen, abhaken, löschen) + + +## Referenzprojekt + +Im Ordner `reference/` liegen Dateien aus einem anderen MCP-Server-Projekt +als Orientierung für Struktur und Patterns: + +| Datei | Zweck | +|---|---| +| `reference/pyproject.toml` | Projektstruktur, Dependencies, Entry-Points, Build-System | +| `reference/config.py` | Config-Pattern: YAML laden, validieren, speichern | +| `reference/auth.py` | Keyring-Integration, Credential-Resolution-Reihenfolge | +| `reference/cli.py` | Setup-Wizard, check, serve – CLI-Struktur | + +**Wichtig:** Diese Dateien sind Referenz, kein Copy-Paste. Alle +Synology/DSM-spezifischen Teile werden nicht übernommen. +Family Wall nutzt ein anderes Auth-Schema – siehe SPEC.md. + +## Architektur-Entscheidungen + +### Session-Strategie +Kein Session-Caching. Jeder Tool-Call führt Login → API-Call → Logout durch. +Credentials liegen im OS Keyring (nur `email` + `password`), kein `session_id`. + +### Kreise (Scopes) +Family Wall kennt mehrere Kreise (z.B. Familie, erweiterter Familienkreis). +`get_lists` unterstützt optionalen `scope`-Parameter zur Filterung. +Ohne `scope` werden alle Kreise zurückgegeben. + +### Listen-Namen +Systembezeichnungen (z.B. `SYS-CAT-SHOPPINGLIST`) werden in deutsche +Klarnamen übersetzt. Mapping-Tabelle in `modules/lists.py`. + + +## Claude Code – Implementierungsregeln + +- Keine destruktiven Operationen in v1.0 → kein Confirmation-Pattern erforderlich +- Fehlerbehandlung: API-Fehler als verständliche Meldung zurückgeben, keine Stacktraces +- Keine Secrets in stderr-Ausgaben (Passwort bei Debug-Logging maskieren) +- Type Hints und Docstrings konsequent verwenden +- Formatter: `ruff format`, Linter: `ruff check`, Tests: `pytest` +- Alle Texte (Docstrings, Kommentare, README): Englisch +- Debug-Logging via `FW_DEBUG=1` Umgebungsvariable +- Nach jeder Aufgabe: git commit + push. Bei Berechtigungsfehler: 1s warten, bis zu 2 Retries +- .gitignore eigenständig pflegen (Credentials, __pycache__, .venv, .env, *.pyc etc.) +- README.md im Projekt-Root pflegen und bei jedem Aufruf aktualisieren + + +## Implementierungsreihenfolge + +### Gruppe 1 – Projektgerüst + Auth + CLI ✦ Prio: hoch + +- `pyproject.toml`: Package `mcp-familywall`, Entry-Point `mcp-familywall` +- `src/mcp_familywall/config.py`: `~/.config/mcp-familywall/config.yaml`, + schema_version 1 +- `src/mcp_familywall/auth.py`: Keyring-Service `mcp-familywall`, + Keys: `email`, `password`. Credential-Resolution: + 1. Umgebungsvariablen `FW_EMAIL`, `FW_PASSWORD` + 2. OS Keyring +- `src/mcp_familywall/fw_client.py`: HTTP-Client mit `httpx`. + Methoden: `login()`, `logout()`, `call(endpoint, params)`. + Debug-Logging wenn `FW_DEBUG=1` (Passwort maskieren). +- `src/mcp_familywall/cli.py`: + - `setup`: fragt E-Mail + Passwort, führt Login/Logout durch, + speichert Credentials im Keyring, gibt Claude-Desktop-Snippet aus + - `check`: testet Auth und API-Erreichbarkeit + - `serve`: startet MCP-Server + +Config-Datei enthält nur: +```yaml +schema_version: 1 +``` + +### Gruppe 2 – MCP Tools ✦ Prio: hoch + +`src/mcp_familywall/server.py` + `src/mcp_familywall/modules/lists.py` + +**`get_circles`** +- Ruft `famlistfamily` auf +- Gibt alle Kreise zurück: `id`, `name` +- Confirmation: nein + +**`get_lists`** +- Signatur: `get_lists(scope: str = None) -> str` +- Ruft `accgetallfamily` auf (Parameter: `a01call=taskcategorysync`, `a02call=tasksync`) +- Gibt zurück: `id` (metaId), `name` (übersetzt), `type`, offene Einträge, + Gesamteinträge, Kreis-Name +- Ohne `scope`: alle Kreise; mit `scope`: nur dieser Kreis +- Confirmation: nein + +**`get_tasks`** +- Signatur: `get_tasks(list_id: str, only_open: bool = True) -> str` +- `list_id`: metaId aus `get_lists` +- Gibt zurück: `id`, `text`, `description`, `completed` +- Confirmation: nein + + +## Test-Credentials (nur für Entwicklung) + +| | | +|---|---| +| E-Mail | `marcus@gecheckt.de` | +| Passwort | `Lasdas1234` | + + +## Hintergrund + +Marcus ist Senior Software Engineer (Java, Jakarta EE). +Präferenz: State-of-the-Art, Best Practices, saubere Architektur. +Automatisierung spart Zeit für die Familie. 🌱 \ No newline at end of file diff --git a/README.md b/README.md index e69de29..b0ec4df 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,75 @@ +# mcp-familywall + +MCP server for [Family Wall](https://www.familywall.com) -- read your family's circles, lists, and tasks directly from Claude. + +## Features (v1.0 -- read-only) + +- `get_circles` -- list all family circles +- `get_lists` -- list all task lists (optionally filtered by circle) +- `get_tasks` -- list tasks in a specific list + +## Requirements + +- Python 3.12+ +- [uv](https://docs.astral.sh/uv/) +- A Family Wall account + +## Installation + +```bash +uv tool install mcp-familywall +``` + +## Setup + +Run the interactive setup wizard once to store your credentials securely in the OS keyring: + +```bash +mcp-familywall setup +``` + +This will: +1. Prompt for your Family Wall email and password +2. Verify the credentials against the API +3. Store them in the OS keyring +4. Print a Claude Desktop configuration snippet + +## Claude Desktop configuration + +Add the printed snippet to your `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "familywall": { + "command": "uvx", + "args": ["mcp-familywall", "serve"] + } + } +} +``` + +## Verify + +```bash +mcp-familywall check +``` + +## Debug logging + +Set `FW_DEBUG=1` to log full request/response bodies to stderr (passwords are masked): + +```bash +FW_DEBUG=1 mcp-familywall check +``` + +## Credentials + +Credentials are resolved in this order: + +1. Environment variables `FW_EMAIL` and `FW_PASSWORD` +2. OS keyring (set by `mcp-familywall setup`) + +## License + +MIT diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..8a3a40b --- /dev/null +++ b/SPEC.md @@ -0,0 +1,137 @@ +# Family Wall API – Spezifikation + +Erarbeitet durch Browser-Traffic-Analyse (April 2026). +Es gibt keine offizielle API-Dokumentation. + +## Base URL +https://api.familywall.com/api + +## Authentifizierung + +### Login +POST https://api.familywall.com/api/log2in +Content-Type: application/x-www-form-urlencoded + +**Request-Parameter:** + +| Parameter | Wert | +|---|---| +| `identifier` | E-Mail-Adresse | +| `password` | Passwort | +| `type` | zu verifizieren beim ersten echten Login-Call (vermutlich `"email"`) | +| `clientId` | weglassen | +| `clientSecret` | weglassen | +| `generateAutologinToken` | weglassen | +| `countryCode` | weglassen | + +**Response (Erfolg):** + +```json +{ "r": { "r": } } +``` + +**Response (Fehler):** + +```json +{ "ex": { "ex": } } +{ "un": { "un": } } +``` + +Der Server setzt nach erfolgreichem Login ein Session-Cookie: +Set-Cookie: JSESSIONID= + +### Folgecalls (nach Login) + +Alle API-Calls nach dem Login benötigen: + +| | | +|---|---| +| **Cookie** | `JSESSIONID=` | +| **Header** | `Tokencsrf: ` (identisch zur JSESSIONID) | +| **Content-Type** | `application/x-www-form-urlencoded` | + +### Logout +POST https://api.familywall.com/api/log2out +Content-Type: application/x-www-form-urlencoded + +Keine Parameter. Session wird serverseitig invalidiert. + +### Session-Strategie + +Kein Session-Caching. Jeder MCP-Tool-Call führt folgende Sequenz aus: + +POST /api/log2in → Session-ID +POST /api/ → Nutzdaten +POST /api/log2out → Session invalidieren + + +Credentials (E-Mail + Passwort) werden einmalig via `mcp-familywall setup` +im OS Keyring gespeichert (Keys: `email`, `password`). Kein Keyring-Eintrag +für `session_id`. + +## Bekannte Endpoints + +### `famlistfamily` – Kreise abrufen +POST https://api.familywall.com/api/famlistfamily +Content-Type: application/x-www-form-urlencoded + +**Body-Parameter:** keine (zu verifizieren beim ersten echten Call) + +**Response-Struktur:** zu verifizieren beim ersten echten Call + +### `accgetallfamily` – Listen + Tasks abrufen +POST https://api.familywall.com/api/accgetallfamily +Content-Type: application/x-www-form-urlencoded + +**Body-Parameter:** + +| Parameter | Wert | +|---|---| +| `a01call` | `"taskcategorysync"` | +| `a02call` | `"tasksync"` | + +Hinweis: `partnerScope`, `a03call`, `a03id`, `withStateBean` werden +weggelassen. Falls der Server sie erfordert, beim ersten echten Call +nachbessern. + +**Response-Struktur (relevant für v1.0):** +a00.r.r[] → Listen (taskcategorysync) +.metaId → eindeutige Listen-ID +.name → Name (ggf. Systembezeichnung, s.u.) +.taskListType → Typ der Liste +.remainingTaskNumber → offene Einträge (String) +.totalTaskNumber → Gesamteinträge (String) +. → zu verifizieren beim ersten echten Call +a02.r.r.updatedCreated[] → Tasks (tasksync) +.metaId → eindeutige Task-ID +.text → Aufgabentext +.description → optionale Beschreibung +.taskListId → Zugehörigkeit zur Liste (= metaId der Liste) +.complete → "true" / "false" (String, nicht Boolean!) + +## Systembezeichnungen für Listen-Namen + +Bekannte Systembezeichnungen werden deutsch übersetzt: + +| Systembezeichnung | Deutsch | +|---|---| +| `SYS-CAT-SHOPPINGLIST` | `Einkaufsliste` | + +Unbekannte Bezeichnungen werden unverändert zurückgegeben. +Mapping-Tabelle bei Bedarf erweitern. + +## Debug-Logging + +Wenn die Umgebungsvariable `FW_DEBUG=1` gesetzt ist, loggt `fw_client.py` +vollständige Request-Bodies und Responses nach stderr. Dient zur Verifikation +offener Punkte (z.B. `type`-Parameter beim Login, Kreis-Felder in Response). + +**Wichtig:** Keine Secrets in Debug-Ausgaben (Passwort maskieren). + +## Noch zu verifizieren + +- Exakter Wert für `type`-Parameter beim Login +- Response-Struktur von `famlistfamily` (Kreise) +- Kreis-Zuordnung in `accgetallfamily`-Response +- Ob `partnerScope` / `withStateBean` benötigt werden +- Session-Lebensdauer (irrelevant da kein Caching) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..99806c6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "mcp-familywall" +version = "0.1.0" +description = "MCP server for Family Wall — read your family's lists and tasks via Claude" +readme = "README.md" +requires-python = ">=3.12" +authors = [ + { name = "Marcus van Elst" }, +] +keywords = ["mcp", "familywall", "family", "tasks", "lists"] +dependencies = [ + "mcp[cli]>=1.0", + "httpx>=0.27", + "keyring>=25.0", + "click>=8.0", + "rich>=13.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.24", + "ruff>=0.5", +] + +[project.scripts] +mcp-familywall = "mcp_familywall.cli:app" + +[tool.hatch.build.targets.wheel] +packages = ["src/mcp_familywall"] + +[tool.ruff] +target-version = "py312" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "TCH"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" diff --git a/src/mcp_familywall/__init__.py b/src/mcp_familywall/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/src/mcp_familywall/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/src/mcp_familywall/auth.py b/src/mcp_familywall/auth.py new file mode 100644 index 0000000..dc1667e --- /dev/null +++ b/src/mcp_familywall/auth.py @@ -0,0 +1,91 @@ +"""Credential resolution for Family Wall. + +Credential resolution order: +1. Environment variables FW_EMAIL, FW_PASSWORD +2. OS keyring (service: 'mcp-familywall', keys: 'email', 'password') +""" + +from __future__ import annotations + +import logging +import os + +logger = logging.getLogger(__name__) + +KEYRING_SERVICE = "mcp-familywall" + + +def get_credentials() -> tuple[str, str]: + """Resolve Family Wall credentials. + + Resolution order: + 1. Environment variables FW_EMAIL / FW_PASSWORD + 2. OS keyring + + Returns: + Tuple of (email, password). + + Raises: + RuntimeError: If credentials cannot be resolved from any source. + """ + email: str | None = None + password: str | None = None + + # 1. Environment variables (highest priority) + email = os.environ.get("FW_EMAIL") + if email: + logger.debug("Email from env var FW_EMAIL: %s", email) + + password = os.environ.get("FW_PASSWORD") + if password: + logger.debug("Password from env var FW_PASSWORD") + + # 2. OS keyring + if not email or not password: + try: + import keyring + + kr_email = keyring.get_password(KEYRING_SERVICE, "email") + kr_password = keyring.get_password(KEYRING_SERVICE, "password") + + if kr_email and not email: + email = kr_email + logger.debug("Email from keyring: %s", email) + if kr_password and not password: + password = kr_password + logger.debug("Password from keyring") + except Exception: + logger.debug("Keyring not available or could not be read.") + + if not email or not password: + msg = ( + "No credentials found. Run 'mcp-familywall setup' to store credentials " + "in the OS keyring, or set FW_EMAIL and FW_PASSWORD environment variables." + ) + raise RuntimeError(msg) + + logger.debug("Credentials resolved: email=%s, has_password=yes", email) + return email, password + + +def store_credentials(email: str, password: str) -> bool: + """Store credentials in the OS keyring. + + Args: + email: Family Wall email address. + password: Family Wall password. + + Returns: + True if stored successfully, False if keyring is unavailable. + """ + try: + import keyring + from keyring.errors import KeyringError + + keyring.set_password(KEYRING_SERVICE, "email", email) + keyring.set_password(KEYRING_SERVICE, "password", password) + logger.debug("Credentials stored in keyring (service=%s)", KEYRING_SERVICE) + return True + except (ImportError, KeyringError, OSError) as exc: + logger.debug("Failed to store credentials in keyring: %s", exc) + return False diff --git a/src/mcp_familywall/cli.py b/src/mcp_familywall/cli.py new file mode 100644 index 0000000..b858b0e --- /dev/null +++ b/src/mcp_familywall/cli.py @@ -0,0 +1,151 @@ +"""CLI entry point for mcp-familywall. + +Commands: +- setup : Interactive credential setup; stores email+password in OS keyring. +- check : Validate stored credentials against the Family Wall API. +- serve : Start the MCP server (launched by Claude Desktop). +""" + +from __future__ import annotations + +import json +import shutil +import sys + +import click + +from mcp_familywall import __version__ + + +@click.group(context_settings={"help_option_names": ["-h", "--help"]}, invoke_without_command=True) +@click.version_option(__version__, "-v", "--version", prog_name="mcp-familywall") +@click.pass_context +def app(ctx: click.Context) -> None: + """mcp-familywall — MCP server for Family Wall (read-only).""" + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + + +# --------------------------------------------------------------------------- +# setup +# --------------------------------------------------------------------------- + + +@app.command() +def setup() -> None: + """Interactive credential setup. + + Prompts for email and password, verifies them against the Family Wall API, + stores them in the OS keyring, and prints a Claude Desktop config snippet. + """ + from mcp_familywall.auth import store_credentials + from mcp_familywall.config import save_config + from mcp_familywall.fw_client import FamilyWallClient, FamilyWallError + + click.echo("mcp-familywall setup\n") + + email = click.prompt("Family Wall email") + password = click.prompt("Family Wall password", hide_input=True) + + # Verify credentials + click.echo("\nVerifying credentials...") + try: + with FamilyWallClient() as client: + client.login(email, password) + client.logout() + except FamilyWallError as exc: + click.echo(click.style(f"Login failed: {exc}", fg="red"), err=True) + sys.exit(1) + except Exception as exc: + click.echo(click.style(f"Connection error: {exc}", fg="red"), err=True) + sys.exit(1) + + click.echo(click.style("Login successful!", fg="green")) + + # Store credentials + ok = store_credentials(email, password) + if ok: + click.echo("Credentials stored in OS keyring.") + else: + click.echo( + click.style("Keyring not available.", fg="yellow") + + " Set environment variables instead:\n" + f" FW_EMAIL={email}\n" + " FW_PASSWORD=" + ) + + # Ensure config file exists + save_config() + + # Print Claude Desktop snippet + _emit_claude_desktop_snippet() + + +# --------------------------------------------------------------------------- +# check +# --------------------------------------------------------------------------- + + +@app.command() +def check() -> None: + """Validate stored credentials against the Family Wall API.""" + from mcp_familywall.auth import get_credentials + from mcp_familywall.fw_client import FamilyWallClient, FamilyWallError + + click.echo("Checking Family Wall credentials...") + + try: + email, password = get_credentials() + except RuntimeError as exc: + click.echo(click.style(f"Error: {exc}", fg="red"), err=True) + sys.exit(1) + + click.echo(f"Email: {email}") + + try: + with FamilyWallClient() as client: + client.login(email, password) + client.logout() + except FamilyWallError as exc: + click.echo(click.style(f"Authentication failed: {exc}", fg="red"), err=True) + sys.exit(1) + except Exception as exc: + click.echo(click.style(f"Connection error: {exc}", fg="red"), err=True) + sys.exit(1) + + click.echo(click.style("Authentication successful!", fg="green")) + + +# --------------------------------------------------------------------------- +# serve +# --------------------------------------------------------------------------- + + +@app.command() +def serve() -> None: + """Start the MCP server (launched by Claude Desktop).""" + from mcp_familywall.server import create_server + + server = create_server() + server.run(transport="stdio") + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + + +def _emit_claude_desktop_snippet() -> None: + """Print a Claude Desktop JSON config snippet.""" + uvx_path = shutil.which("uvx") or "" + + snippet = { + "mcpServers": { + "familywall": { + "command": uvx_path, + "args": ["mcp-familywall", "serve"], + } + } + } + click.echo("\nAdd this to your Claude Desktop config (claude_desktop_config.json):\n") + click.echo(json.dumps(snippet, indent=2)) diff --git a/src/mcp_familywall/config.py b/src/mcp_familywall/config.py new file mode 100644 index 0000000..9c60599 --- /dev/null +++ b/src/mcp_familywall/config.py @@ -0,0 +1,73 @@ +"""YAML config loading and validation. + +Config path: ~/.config/mcp-familywall/config.yaml +Schema: {schema_version: 1} +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +import yaml + +logger = logging.getLogger(__name__) + +CURRENT_SCHEMA_VERSION = 1 +CONFIG_DIR = Path.home() / ".config" / "mcp-familywall" +CONFIG_PATH = CONFIG_DIR / "config.yaml" + +_DEFAULT_CONFIG: dict[str, Any] = {"schema_version": CURRENT_SCHEMA_VERSION} + + +def load_config() -> dict[str, Any]: + """Load and validate the config file. + + Returns the config dict. Creates a default config if none exists. + + Raises: + ValueError: If schema_version does not match the expected version. + """ + if not CONFIG_PATH.exists(): + logger.debug("No config found at %s; using defaults", CONFIG_PATH) + return dict(_DEFAULT_CONFIG) + + logger.debug("Loading config from %s", CONFIG_PATH) + raw_text = CONFIG_PATH.read_text(encoding="utf-8") + raw: dict[str, Any] = yaml.safe_load(raw_text) or {} + + _validate(raw) + logger.debug("Config loaded: schema_version=%s", raw.get("schema_version")) + return raw + + +def save_config(config: dict[str, Any] | None = None) -> None: + """Save a config dict to disk (or write defaults if None). + + Args: + config: Config dict to save. Defaults to minimal default config. + """ + if config is None: + config = dict(_DEFAULT_CONFIG) + + _validate(config) + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + header = "# Generated by mcp-familywall setup\n" + CONFIG_PATH.write_text(header + yaml.dump(config, default_flow_style=False), encoding="utf-8") + logger.debug("Config written to %s", CONFIG_PATH) + + +def _validate(config: dict[str, Any]) -> None: + """Validate config dict. + + Raises: + ValueError: If schema_version is missing or wrong. + """ + version = config.get("schema_version") + if version != CURRENT_SCHEMA_VERSION: + msg = ( + f"Config schema_version is {version!r}, " + f"but this server expects {CURRENT_SCHEMA_VERSION}." + ) + raise ValueError(msg) diff --git a/src/mcp_familywall/fw_client.py b/src/mcp_familywall/fw_client.py new file mode 100644 index 0000000..c66e975 --- /dev/null +++ b/src/mcp_familywall/fw_client.py @@ -0,0 +1,184 @@ +"""Family Wall HTTP client. + +Session strategy: Login → API call → Logout per MCP tool call. No session caching. + +Debug logging is enabled when the environment variable FW_DEBUG=1 is set. +Passwords are always masked in debug output. +""" + +from __future__ import annotations + +import logging +import os +import sys +from typing import Any + +import httpx + +logger = logging.getLogger(__name__) + +BASE_URL = "https://api.familywall.com/api" + +_debug = os.environ.get("FW_DEBUG") == "1" + + +def _debug_log(label: str, data: Any) -> None: + """Write debug info to stderr when FW_DEBUG=1.""" + if _debug: + print(f"[FW_DEBUG] {label}: {data}", file=sys.stderr) + + +def _mask_password(data: dict[str, Any]) -> dict[str, Any]: + """Return a copy of data with the password field masked.""" + masked = dict(data) + if "password" in masked: + masked["password"] = "***" + return masked + + +class FamilyWallError(Exception): + """Raised when the Family Wall API returns an error response.""" + + def __init__(self, message: str, response_data: Any = None) -> None: + super().__init__(message) + self.response_data = response_data + + +class FamilyWallClient: + """Synchronous HTTP client for the Family Wall API. + + Usage:: + + client = FamilyWallClient() + client.login(email, password) + data = client.call("accgetallfamily", {"a01call": "taskcategorysync"}) + client.logout() + + Or use as a context manager:: + + with FamilyWallClient() as client: + client.login(email, password) + data = client.call("famlistfamily") + """ + + def __init__(self) -> None: + self._http: httpx.Client = httpx.Client(timeout=30) + self._session_id: str | None = None + + def __enter__(self) -> FamilyWallClient: + return self + + def __exit__(self, *args: object) -> None: + self._http.close() + + def login(self, email: str, password: str) -> None: + """Authenticate with Family Wall and store the session ID. + + Args: + email: Family Wall account email. + password: Family Wall account password. + + Raises: + FamilyWallError: If authentication fails. + httpx.HTTPError: On network-level errors. + """ + endpoint = f"{BASE_URL}/log2in" + payload = { + "identifier": email, + "password": password, + "type": "email", + } + _debug_log("LOGIN request", _mask_password(payload)) + + response = self._http.post( + endpoint, + data=payload, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + response.raise_for_status() + body: dict[str, Any] = response.json() + _debug_log("LOGIN response", body) + + if "ex" in body or "un" in body: + error_data = body.get("ex", body.get("un")) + msg = f"Login failed: {error_data}" + raise FamilyWallError(msg, response_data=body) + + if "r" not in body: + raise FamilyWallError("Login failed: unexpected response format", response_data=body) + + # Extract JSESSIONID from the Set-Cookie header + session_id = response.cookies.get("JSESSIONID") + if not session_id: + raise FamilyWallError("Login succeeded but no JSESSIONID cookie returned.") + + self._session_id = session_id + logger.debug("Logged in; session acquired") + + # Configure the HTTP client to send the session cookie and CSRF token on all future calls + self._http.cookies.set("JSESSIONID", session_id) + self._http.headers.update({"Tokencsrf": session_id}) + + def logout(self) -> None: + """Invalidate the current session server-side. + + Silently ignores errors — the session will eventually expire anyway. + """ + if not self._session_id: + return + + endpoint = f"{BASE_URL}/log2out" + _debug_log("LOGOUT request", {}) + try: + response = self._http.post( + endpoint, + data={}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + _debug_log("LOGOUT response", response.text) + except httpx.HTTPError as exc: + logger.debug("Logout request failed (session may already be expired): %s", exc) + finally: + self._session_id = None + self._http.cookies.clear() + if "Tokencsrf" in self._http.headers: + del self._http.headers["Tokencsrf"] + + def call(self, endpoint: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + """Call a Family Wall API endpoint. + + Must be called after a successful :meth:`login`. + + Args: + endpoint: API endpoint name (e.g. ``"famlistfamily"``). + params: Optional form parameters to send. + + Returns: + Parsed JSON response body. + + Raises: + FamilyWallError: If not logged in, or if the API returns an error. + httpx.HTTPError: On network-level errors. + """ + if not self._session_id: + raise FamilyWallError("Not logged in. Call login() first.") + + url = f"{BASE_URL}/{endpoint}" + payload = params or {} + _debug_log(f"CALL {endpoint} request", payload) + + response = self._http.post( + url, + data=payload, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + response.raise_for_status() + body: dict[str, Any] = response.json() + _debug_log(f"CALL {endpoint} response", body) + + if "ex" in body or "un" in body: + error_data = body.get("ex", body.get("un")) + msg = f"API error from {endpoint!r}: {error_data}" + raise FamilyWallError(msg, response_data=body) + + return body