"""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, )