diff --git a/aggregators/kagi-news/.env.example b/aggregators/kagi-news/.env.example index 2d7ae76..4af316d 100644 --- a/aggregators/kagi-news/.env.example +++ b/aggregators/kagi-news/.env.example @@ -1,6 +1,5 @@ -# Aggregator Identity (pre-created account credentials) -AGGREGATOR_HANDLE=kagi-news.local.coves.dev -AGGREGATOR_PASSWORD=your-secure-password-here +# Coves API Key (get from https://coves.social after OAuth login) +COVES_API_KEY=ckapi_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Optional: Override Coves API URL (defaults to config.yaml) # COVES_API_URL=http://localhost:3001 diff --git a/aggregators/kagi-news/config.example.yaml b/aggregators/kagi-news/config.example.yaml index 0b25e0b..a0415fb 100644 --- a/aggregators/kagi-news/config.example.yaml +++ b/aggregators/kagi-news/config.example.yaml @@ -2,6 +2,8 @@ # Coves API endpoint coves_api_url: "https://coves.social" +# API key is loaded from COVES_API_KEY environment variable +# Get your API key from https://coves.social after OAuth login # Feed-to-community mappings # Handle format: c-{name}.{instance} (e.g., c-worldnews.coves.social) diff --git a/aggregators/kagi-news/requirements.txt b/aggregators/kagi-news/requirements.txt index 48d625a..562a50d 100644 --- a/aggregators/kagi-news/requirements.txt +++ b/aggregators/kagi-news/requirements.txt @@ -2,7 +2,6 @@ feedparser==6.0.11 beautifulsoup4==4.12.3 requests==2.31.0 -atproto==0.0.55 pyyaml==6.0.1 # Testing diff --git a/aggregators/kagi-news/src/coves_client.py b/aggregators/kagi-news/src/coves_client.py index 1aa0c71..5dcbcee 100644 --- a/aggregators/kagi-news/src/coves_client.py +++ b/aggregators/kagi-news/src/coves_client.py @@ -1,70 +1,95 @@ """ Coves API Client for posting to communities. -Handles authentication and posting via XRPC. +Handles API key authentication and posting via XRPC. """ import logging import requests from typing import Dict, List, Optional -from atproto import Client logger = logging.getLogger(__name__) +class CovesAPIError(Exception): + """Base exception for Coves API errors.""" + + def __init__(self, message: str, status_code: int = None, response_body: str = None): + super().__init__(message) + self.status_code = status_code + self.response_body = response_body + + +class CovesAuthenticationError(CovesAPIError): + """Raised when authentication fails (401 Unauthorized).""" + pass + + +class CovesNotFoundError(CovesAPIError): + """Raised when a resource is not found (404 Not Found).""" + pass + + +class CovesRateLimitError(CovesAPIError): + """Raised when rate limit is exceeded (429 Too Many Requests).""" + pass + + +class CovesForbiddenError(CovesAPIError): + """Raised when access is forbidden (403 Forbidden).""" + pass + + class CovesClient: """ Client for posting to Coves communities via XRPC. Handles: - - Authentication with aggregator credentials + - API key authentication - Creating posts in communities (social.coves.community.post.create) - External embed formatting """ - def __init__(self, api_url: str, handle: str, password: str, pds_url: Optional[str] = None): - """ - Initialize Coves client. - - Args: - api_url: Coves AppView URL for posting (e.g., "http://localhost:8081") - handle: Aggregator handle (e.g., "kagi-news.coves.social") - password: Aggregator password/app password - pds_url: Optional PDS URL for authentication (defaults to api_url) - """ - self.api_url = api_url - self.pds_url = pds_url or api_url # Auth through PDS, post through AppView - self.handle = handle - self.password = password - self.client = Client(base_url=self.pds_url) # Use PDS for auth - self._authenticated = False + # API key format constants (must match Go constants in apikey_service.go) + API_KEY_PREFIX = "ckapi_" + API_KEY_TOTAL_LENGTH = 70 # 6 (prefix) + 64 (32 bytes hex-encoded) - def authenticate(self): + def __init__(self, api_url: str, api_key: str): """ - Authenticate with Coves API. + Initialize Coves client with API key authentication. - Uses com.atproto.server.createSession directly to avoid - Bluesky-specific endpoints that don't exist on Coves PDS. + Args: + api_url: Coves API URL for posting (e.g., "https://coves.social") + api_key: Coves API key (e.g., "ckapi_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx") Raises: - Exception: If authentication fails + ValueError: If api_key format is invalid """ - try: - logger.info(f"Authenticating as {self.handle}") - - # Use createSession directly (avoid app.bsky.actor.getProfile) - session = self.client.com.atproto.server.create_session( - {"identifier": self.handle, "password": self.password} + # Validate API key format for early failure with clear error + if not api_key: + raise ValueError("API key cannot be empty") + if not api_key.startswith(self.API_KEY_PREFIX): + raise ValueError(f"API key must start with '{self.API_KEY_PREFIX}'") + if len(api_key) != self.API_KEY_TOTAL_LENGTH: + raise ValueError( + f"API key must be {self.API_KEY_TOTAL_LENGTH} characters " + f"(got {len(api_key)})" ) - # Manually set session (skip profile fetch) - self.client._session = session - self._authenticated = True - self.did = session.did + self.api_url = api_url.rstrip('/') + self.api_key = api_key + self.session = requests.Session() + self.session.headers['Authorization'] = f'Bearer {api_key}' + self.session.headers['Content-Type'] = 'application/json' - logger.info(f"Authentication successful (DID: {self.did})") - except Exception as e: - logger.error(f"Authentication failed: {e}") - raise + def authenticate(self): + """ + No-op for API key authentication. + + API key is set in the session headers during initialization. + This method is kept for backward compatibility with existing code + that calls authenticate() before making requests. + """ + logger.info("Using API key authentication (no session creation needed)") def create_post( self, @@ -90,11 +115,8 @@ class CovesClient: AT Proto URI of created post (e.g., "at://did:plc:.../social.coves.post/...") Raises: - Exception: If post creation fails + requests.HTTPError: If post creation fails """ - if not self._authenticated: - self.authenticate() - try: # Prepare post data for social.coves.community.post.create endpoint post_data = { @@ -119,28 +141,37 @@ class CovesClient: # This provides validation, authorization, and business logic logger.info(f"Creating post in community: {community_handle}") - # Make direct HTTP request to XRPC endpoint + # Make HTTP request to XRPC endpoint using session with API key url = f"{self.api_url}/xrpc/social.coves.community.post.create" - headers = { - "Authorization": f"Bearer {self.client._session.access_jwt}", - "Content-Type": "application/json" - } - - response = requests.post(url, json=post_data, headers=headers, timeout=30) + response = self.session.post(url, json=post_data, timeout=30) - # Log detailed error if request fails + # Handle specific error cases if not response.ok: error_body = response.text logger.error(f"Post creation failed ({response.status_code}): {error_body}") - response.raise_for_status() + self._raise_for_status(response) + + try: + result = response.json() + post_uri = result["uri"] + except (ValueError, KeyError) as e: + # ValueError for invalid JSON, KeyError for missing 'uri' field + logger.error(f"Failed to parse post creation response: {e}") + raise CovesAPIError( + f"Invalid response from server: {e}", + status_code=response.status_code, + response_body=response.text + ) - result = response.json() - post_uri = result["uri"] logger.info(f"Post created: {post_uri}") return post_uri - except Exception as e: - logger.error(f"Failed to create post: {e}") + except requests.RequestException as e: + # Network errors, timeouts, etc. + logger.error(f"Network error creating post: {e}") + raise + except CovesAPIError: + # Re-raise our custom exceptions as-is raise def create_external_embed( @@ -176,6 +207,53 @@ class CovesClient: "external": external } + def _raise_for_status(self, response: requests.Response) -> None: + """ + Raise specific exceptions based on HTTP status code. + + Args: + response: The HTTP response object + + Raises: + CovesAuthenticationError: For 401 Unauthorized + CovesNotFoundError: For 404 Not Found + CovesRateLimitError: For 429 Too Many Requests + CovesAPIError: For other 4xx/5xx errors + """ + status_code = response.status_code + error_body = response.text + + if status_code == 401: + raise CovesAuthenticationError( + f"Authentication failed: {error_body}", + status_code=status_code, + response_body=error_body + ) + elif status_code == 403: + raise CovesForbiddenError( + f"Access forbidden: {error_body}", + status_code=status_code, + response_body=error_body + ) + elif status_code == 404: + raise CovesNotFoundError( + f"Resource not found: {error_body}", + status_code=status_code, + response_body=error_body + ) + elif status_code == 429: + raise CovesRateLimitError( + f"Rate limit exceeded: {error_body}", + status_code=status_code, + response_body=error_body + ) + else: + raise CovesAPIError( + f"API request failed ({status_code}): {error_body}", + status_code=status_code, + response_body=error_body + ) + def _get_timestamp(self) -> str: """ Get current timestamp in ISO 8601 format. diff --git a/aggregators/kagi-news/src/main.py b/aggregators/kagi-news/src/main.py index 87b9f43..ce415d7 100644 --- a/aggregators/kagi-news/src/main.py +++ b/aggregators/kagi-news/src/main.py @@ -71,21 +71,17 @@ class Aggregator: if coves_client: self.coves_client = coves_client else: - # Get credentials from environment - aggregator_handle = os.getenv('AGGREGATOR_HANDLE') - aggregator_password = os.getenv('AGGREGATOR_PASSWORD') - pds_url = os.getenv('PDS_URL') # Optional: separate PDS for auth + # Get API key from environment + api_key = os.getenv('COVES_API_KEY') - if not aggregator_handle or not aggregator_password: + if not api_key: raise ValueError( - "Missing AGGREGATOR_HANDLE or AGGREGATOR_PASSWORD environment variables" + "COVES_API_KEY environment variable required" ) self.coves_client = CovesClient( api_url=self.config.coves_api_url, - handle=aggregator_handle, - password=aggregator_password, - pds_url=pds_url # Auth through PDS if specified + api_key=api_key ) def run(self): diff --git a/aggregators/kagi-news/tests/test_coves_client.py b/aggregators/kagi-news/tests/test_coves_client.py index 70c01bf..6bc38ea 100644 --- a/aggregators/kagi-news/tests/test_coves_client.py +++ b/aggregators/kagi-news/tests/test_coves_client.py @@ -4,7 +4,132 @@ Unit tests for CovesClient. Tests the client's local functionality without requiring live infrastructure. """ import pytest -from src.coves_client import CovesClient +from unittest.mock import Mock +from src.coves_client import ( + CovesClient, + CovesAPIError, + CovesAuthenticationError, + CovesForbiddenError, + CovesNotFoundError, + CovesRateLimitError, +) + + +# Valid test API key (70 chars total: 6 prefix + 64 hex chars) +VALID_TEST_API_KEY = "ckapi_" + "a" * 64 + + +class TestAPIKeyValidation: + """Tests for API key format validation in constructor.""" + + def test_rejects_empty_api_key(self): + """Empty API key should raise ValueError.""" + with pytest.raises(ValueError, match="cannot be empty"): + CovesClient(api_url="http://localhost", api_key="") + + def test_rejects_wrong_prefix(self): + """API key with wrong prefix should raise ValueError.""" + wrong_prefix_key = "wrong_" + "a" * 64 + with pytest.raises(ValueError, match="must start with 'ckapi_'"): + CovesClient(api_url="http://localhost", api_key=wrong_prefix_key) + + def test_rejects_short_api_key(self): + """API key that is too short should raise ValueError.""" + short_key = "ckapi_tooshort" + with pytest.raises(ValueError, match="must be 70 characters"): + CovesClient(api_url="http://localhost", api_key=short_key) + + def test_rejects_long_api_key(self): + """API key that is too long should raise ValueError.""" + long_key = "ckapi_" + "a" * 100 + with pytest.raises(ValueError, match="must be 70 characters"): + CovesClient(api_url="http://localhost", api_key=long_key) + + def test_accepts_valid_api_key(self): + """Valid API key format should be accepted.""" + client = CovesClient(api_url="http://localhost", api_key=VALID_TEST_API_KEY) + assert client.api_key == VALID_TEST_API_KEY + + +class TestRaiseForStatus: + """Tests for _raise_for_status method.""" + + @pytest.fixture + def client(self): + """Create a CovesClient instance for testing.""" + return CovesClient(api_url="http://localhost", api_key=VALID_TEST_API_KEY) + + def test_raises_authentication_error_for_401(self, client): + """401 response should raise CovesAuthenticationError.""" + mock_response = Mock() + mock_response.status_code = 401 + mock_response.text = "Invalid API key" + + with pytest.raises(CovesAuthenticationError) as exc_info: + client._raise_for_status(mock_response) + + assert exc_info.value.status_code == 401 + assert "Authentication failed" in str(exc_info.value) + + def test_raises_forbidden_error_for_403(self, client): + """403 response should raise CovesForbiddenError.""" + mock_response = Mock() + mock_response.status_code = 403 + mock_response.text = "Not authorized for this community" + + with pytest.raises(CovesForbiddenError) as exc_info: + client._raise_for_status(mock_response) + + assert exc_info.value.status_code == 403 + assert "Access forbidden" in str(exc_info.value) + + def test_raises_not_found_error_for_404(self, client): + """404 response should raise CovesNotFoundError.""" + mock_response = Mock() + mock_response.status_code = 404 + mock_response.text = "Community not found" + + with pytest.raises(CovesNotFoundError) as exc_info: + client._raise_for_status(mock_response) + + assert exc_info.value.status_code == 404 + assert "Resource not found" in str(exc_info.value) + + def test_raises_rate_limit_error_for_429(self, client): + """429 response should raise CovesRateLimitError.""" + mock_response = Mock() + mock_response.status_code = 429 + mock_response.text = "Rate limit exceeded" + + with pytest.raises(CovesRateLimitError) as exc_info: + client._raise_for_status(mock_response) + + assert exc_info.value.status_code == 429 + assert "Rate limit exceeded" in str(exc_info.value) + + def test_raises_generic_api_error_for_500(self, client): + """500 response should raise generic CovesAPIError.""" + mock_response = Mock() + mock_response.status_code = 500 + mock_response.text = "Internal server error" + + with pytest.raises(CovesAPIError) as exc_info: + client._raise_for_status(mock_response) + + assert exc_info.value.status_code == 500 + assert not isinstance(exc_info.value, CovesAuthenticationError) + assert not isinstance(exc_info.value, CovesNotFoundError) + + def test_exception_includes_response_body(self, client): + """Exception should include the response body.""" + mock_response = Mock() + mock_response.status_code = 400 + mock_response.text = '{"error": "Bad request details"}' + + with pytest.raises(CovesAPIError) as exc_info: + client._raise_for_status(mock_response) + + assert exc_info.value.response_body == '{"error": "Bad request details"}' class TestCreateExternalEmbed: @@ -15,8 +140,7 @@ class TestCreateExternalEmbed: """Create a CovesClient instance for testing.""" return CovesClient( api_url="http://localhost:8081", - handle="test.handle", - password="test_password" + api_key=VALID_TEST_API_KEY ) def test_creates_embed_without_sources(self, client):