309 lines
11 KiB
Python
309 lines
11 KiB
Python
"""Configuration loader and validation for ai-chat-exporter."""
|
|
|
|
import logging
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import jwt
|
|
from dotenv import load_dotenv
|
|
|
|
from src.utils import format_token_status
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Placeholder values from .env.example — reject if still set
|
|
_CHATGPT_PLACEHOLDER = ""
|
|
_CLAUDE_PLACEHOLDER = ""
|
|
|
|
# Valid OUTPUT_STRUCTURE values
|
|
VALID_STRUCTURES = {"provider/project/year", "provider/project", "provider/year"}
|
|
|
|
# Valid EXPORTER_HIDDEN_CONTENT values (see src/providers/base.py)
|
|
VALID_HIDDEN_CONTENT = {"full", "placeholder", "omit"}
|
|
|
|
# Valid EXPORTER_DOWNLOAD_MEDIA values (see src/media.py)
|
|
VALID_DOWNLOAD_MEDIA = {"images", "all", "off"}
|
|
|
|
|
|
class ConfigError(Exception):
|
|
"""Raised when required configuration is missing or invalid."""
|
|
|
|
|
|
@dataclass
|
|
class Config:
|
|
chatgpt_session_token: str | None
|
|
chatgpt_session_token_1: str | None
|
|
claude_session_key: str | None
|
|
export_dir: Path
|
|
output_structure: str
|
|
cache_dir: Path
|
|
log_file: str
|
|
# Decoded ChatGPT JWT expiry (None if token absent or not a JWT)
|
|
chatgpt_token_expiry: datetime | None = field(default=None, repr=False)
|
|
# ChatGPT Project gizmo IDs (g-p-xxx) — project conversations are not
|
|
# included in the default /conversations listing; they must be fetched
|
|
# separately via /backend-api/gizmos/{id}/conversations.
|
|
chatgpt_project_ids: list[str] = field(default_factory=list)
|
|
# Joplin local REST API settings (Web Clipper service)
|
|
joplin_api_token: str | None = None
|
|
joplin_api_url: str = "http://localhost:41184"
|
|
# Policy for content invisible in the provider web UI (retrieval dumps,
|
|
# hidden context): full | placeholder | omit
|
|
hidden_content: str = "placeholder"
|
|
# Session cap: max conversations downloaded per export run (None = unlimited).
|
|
# Every run is resumable, so a capped run just continues next time.
|
|
max_conversations: int | None = None
|
|
# Seconds between consecutive API requests (politeness pacing; 0 disables)
|
|
request_delay: float = 1.0
|
|
# Asset download policy: images | all | off
|
|
download_media: str = "images"
|
|
|
|
|
|
def load_config() -> Config:
|
|
"""Load configuration from environment / .env file.
|
|
|
|
Validates all values and logs a startup summary.
|
|
|
|
Raises:
|
|
ConfigError: If a critical config value is missing or invalid.
|
|
"""
|
|
load_dotenv(override=False)
|
|
|
|
chatgpt_token = os.getenv("CHATGPT_SESSION_TOKEN", "").strip() or None
|
|
chatgpt_token_1 = os.getenv("CHATGPT_SESSION_TOKEN_1", "").strip() or None
|
|
claude_key = os.getenv("CLAUDE_SESSION_KEY", "").strip() or None
|
|
export_dir = Path(os.getenv("EXPORT_DIR", "./exports")).expanduser()
|
|
output_structure = os.getenv("OUTPUT_STRUCTURE", "provider/project/year").strip()
|
|
cache_dir = Path(os.getenv("CACHE_DIR", "./cache")).expanduser()
|
|
log_file = os.getenv("LOG_FILE", "./cache/logs/exporter.log").strip()
|
|
|
|
# Joplin
|
|
joplin_token = os.getenv("JOPLIN_API_TOKEN", "").strip() or None
|
|
joplin_url = os.getenv("JOPLIN_API_URL", "http://localhost:41184").strip()
|
|
|
|
hidden_content = os.getenv("EXPORTER_HIDDEN_CONTENT", "").strip().lower() or "placeholder"
|
|
|
|
max_conversations_raw = os.getenv("MAX_CONVERSATIONS_PER_RUN", "").strip()
|
|
request_delay_raw = os.getenv("REQUEST_DELAY", "").strip()
|
|
download_media = os.getenv("EXPORTER_DOWNLOAD_MEDIA", "").strip().lower() or "images"
|
|
|
|
# Parse CHATGPT_PROJECT_IDS — comma-separated list of gizmo IDs (g-p-xxx)
|
|
_project_ids_raw = os.getenv("CHATGPT_PROJECT_IDS", "").strip()
|
|
chatgpt_project_ids = [
|
|
pid.strip()
|
|
for pid in _project_ids_raw.split(",")
|
|
if pid.strip() and pid.strip().startswith("g-p-")
|
|
] if _project_ids_raw else []
|
|
if _project_ids_raw and not chatgpt_project_ids:
|
|
logger.warning(
|
|
"CHATGPT_PROJECT_IDS is set but contains no valid project IDs. "
|
|
"Each ID should start with 'g-p-' (e.g. g-p-68c2b2b3037c8191890036fb4ae3ed9f). "
|
|
"Find your project ID in the browser URL when viewing a project."
|
|
)
|
|
|
|
errors: list[str] = []
|
|
|
|
# Validate output structure
|
|
if output_structure not in VALID_STRUCTURES:
|
|
errors.append(
|
|
f"OUTPUT_STRUCTURE '{output_structure}' is invalid. "
|
|
f"Must be one of: {', '.join(sorted(VALID_STRUCTURES))}"
|
|
)
|
|
|
|
# Validate hidden-content policy
|
|
if hidden_content not in VALID_HIDDEN_CONTENT:
|
|
errors.append(
|
|
f"EXPORTER_HIDDEN_CONTENT '{hidden_content}' is invalid. "
|
|
f"Must be one of: {', '.join(sorted(VALID_HIDDEN_CONTENT))}"
|
|
)
|
|
|
|
# Validate media download policy
|
|
if download_media not in VALID_DOWNLOAD_MEDIA:
|
|
errors.append(
|
|
f"EXPORTER_DOWNLOAD_MEDIA '{download_media}' is invalid. "
|
|
f"Must be one of: {', '.join(sorted(VALID_DOWNLOAD_MEDIA))}"
|
|
)
|
|
|
|
# Validate session cap
|
|
max_conversations: int | None = None
|
|
if max_conversations_raw:
|
|
try:
|
|
max_conversations = int(max_conversations_raw)
|
|
except ValueError:
|
|
errors.append(
|
|
f"MAX_CONVERSATIONS_PER_RUN '{max_conversations_raw}' is not an integer."
|
|
)
|
|
else:
|
|
if max_conversations < 1:
|
|
errors.append(
|
|
f"MAX_CONVERSATIONS_PER_RUN must be at least 1 (got {max_conversations})."
|
|
)
|
|
|
|
# Validate request pacing
|
|
request_delay = 1.0
|
|
if request_delay_raw:
|
|
try:
|
|
request_delay = float(request_delay_raw)
|
|
except ValueError:
|
|
errors.append(f"REQUEST_DELAY '{request_delay_raw}' is not a number.")
|
|
else:
|
|
if request_delay < 0:
|
|
errors.append(f"REQUEST_DELAY must be >= 0 (got {request_delay}).")
|
|
|
|
# Validate and decode ChatGPT JWT
|
|
chatgpt_expiry: datetime | None = None
|
|
if chatgpt_token:
|
|
chatgpt_expiry = _validate_chatgpt_token(chatgpt_token)
|
|
|
|
# Validate Claude key
|
|
if claude_key:
|
|
_validate_claude_key(claude_key)
|
|
|
|
# Ensure at least one provider is configured (warning only)
|
|
if not chatgpt_token and not claude_key:
|
|
logger.warning(
|
|
"Neither CHATGPT_SESSION_TOKEN nor CLAUDE_SESSION_KEY is set. "
|
|
"Run 'ai-chat-exporter auth' to configure credentials."
|
|
)
|
|
|
|
# Create and validate output directory
|
|
try:
|
|
export_dir.mkdir(parents=True, exist_ok=True)
|
|
_check_writable(export_dir)
|
|
except (OSError, PermissionError) as e:
|
|
errors.append(f"Cannot create/write to EXPORT_DIR '{export_dir}': {e}")
|
|
|
|
# Create and validate cache directory
|
|
try:
|
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
_check_writable(cache_dir)
|
|
except (OSError, PermissionError) as e:
|
|
errors.append(f"Cannot create/write to CACHE_DIR '{cache_dir}': {e}")
|
|
|
|
if errors:
|
|
for err in errors:
|
|
logger.critical(err)
|
|
raise ConfigError(
|
|
"Configuration errors found:\n" + "\n".join(f" - {e}" for e in errors)
|
|
)
|
|
|
|
config = Config(
|
|
chatgpt_session_token=chatgpt_token,
|
|
chatgpt_session_token_1=chatgpt_token_1,
|
|
claude_session_key=claude_key,
|
|
export_dir=export_dir,
|
|
output_structure=output_structure,
|
|
cache_dir=cache_dir,
|
|
log_file=log_file,
|
|
chatgpt_token_expiry=chatgpt_expiry,
|
|
chatgpt_project_ids=chatgpt_project_ids,
|
|
joplin_api_token=joplin_token,
|
|
joplin_api_url=joplin_url,
|
|
hidden_content=hidden_content,
|
|
max_conversations=max_conversations,
|
|
request_delay=request_delay,
|
|
download_media=download_media,
|
|
)
|
|
|
|
_log_startup_summary(config)
|
|
return config
|
|
|
|
|
|
def _validate_chatgpt_token(token: str) -> datetime | None:
|
|
"""Validate ChatGPT session token (JWT). Returns expiry or None."""
|
|
if not token.startswith("eyJ"):
|
|
logger.warning(
|
|
"CHATGPT_SESSION_TOKEN does not look like a JWT (expected 'eyJ...'). "
|
|
"It may be expired or incorrectly copied."
|
|
)
|
|
return None
|
|
|
|
try:
|
|
payload = jwt.decode(token, options={"verify_signature": False})
|
|
except jwt.DecodeError:
|
|
# JWE (encrypted JWT, alg=dir) — cannot be decoded without the server key.
|
|
# This is normal for ChatGPT's current token format. Token is valid; expiry unknown.
|
|
logger.debug(
|
|
"CHATGPT_SESSION_TOKEN is an encrypted JWE token — expiry cannot be decoded client-side."
|
|
)
|
|
return None
|
|
|
|
exp = payload.get("exp")
|
|
if exp is None:
|
|
logger.warning("CHATGPT_SESSION_TOKEN JWT has no 'exp' claim.")
|
|
return None
|
|
|
|
expiry = datetime.fromtimestamp(exp, tz=timezone.utc)
|
|
now = datetime.now(tz=timezone.utc)
|
|
delta = expiry - now
|
|
|
|
if delta.total_seconds() < 0:
|
|
logger.warning(
|
|
"CHATGPT_SESSION_TOKEN expired at %s. "
|
|
"Run 'ai-chat-exporter auth' to refresh it.",
|
|
expiry.strftime("%Y-%m-%d %H:%M UTC"),
|
|
)
|
|
elif delta.total_seconds() < 86400:
|
|
logger.warning(
|
|
"CHATGPT_SESSION_TOKEN expires in less than 24 hours (%s). "
|
|
"Consider refreshing it soon.",
|
|
expiry.strftime("%Y-%m-%d %H:%M UTC"),
|
|
)
|
|
|
|
return expiry
|
|
|
|
|
|
def _validate_claude_key(key: str) -> None:
|
|
"""Validate Claude session key (opaque string)."""
|
|
# Reject if it's the placeholder text from .env.example
|
|
if not key or key.startswith("CLAUDE_SESSION_KEY="):
|
|
logger.warning(
|
|
"CLAUDE_SESSION_KEY appears to be a placeholder. "
|
|
"Set it to the actual sessionKey cookie value from claude.ai."
|
|
)
|
|
|
|
|
|
def _check_writable(path: Path) -> None:
|
|
"""Raise PermissionError if path is not writable."""
|
|
test_file = path / ".write_test"
|
|
try:
|
|
test_file.touch()
|
|
test_file.unlink()
|
|
except OSError as e:
|
|
raise PermissionError(f"Directory '{path}' is not writable: {e}") from e
|
|
|
|
|
|
def _log_startup_summary(cfg: Config) -> None:
|
|
"""Log a single INFO line summarising the active configuration."""
|
|
chatgpt_status = format_token_status(cfg.chatgpt_session_token, cfg.chatgpt_token_expiry)
|
|
claude_status = format_token_status(cfg.claude_session_key)
|
|
joplin_status = "configured" if cfg.joplin_api_token else "not configured"
|
|
|
|
logger.info(
|
|
"Config loaded | "
|
|
"ChatGPT: %s | "
|
|
"Claude: %s | "
|
|
"chatgpt_projects: %d | "
|
|
"Joplin: %s | "
|
|
"export_dir=%s | "
|
|
"structure=%s | "
|
|
"cache_dir=%s | "
|
|
"hidden_content=%s | "
|
|
"max_conversations=%s | "
|
|
"request_delay=%.1fs | "
|
|
"download_media=%s",
|
|
chatgpt_status,
|
|
claude_status,
|
|
len(cfg.chatgpt_project_ids),
|
|
joplin_status,
|
|
cfg.export_dir,
|
|
cfg.output_structure,
|
|
cfg.cache_dir,
|
|
cfg.hidden_content,
|
|
cfg.max_conversations if cfg.max_conversations is not None else "unlimited",
|
|
cfg.request_delay,
|
|
cfg.download_media,
|
|
)
|