import requests import time import datetime import hmac import hashlib from urllib.parse import urlencode, quote from django.conf import settings from django.utils import timezone from apps.social.utils.linkedin import LinkedInConstants from apps.social.models import SocialAccount class LinkedInAPIError(Exception): """Custom exception for LinkedIn API errors""" pass class LinkedInService: """Service class for LinkedIn API interactions (RestLi 2.0)""" # ========================================== # HELPER METHODS # ========================================== @staticmethod def _get_headers(token): """Generate headers for LinkedIn API requests""" return { "Authorization": f"Bearer {token}", "Linkedin-Version": LinkedInConstants.API_VERSION, "X-Restli-Protocol-Version": "2.0.0", "Content-Type": "application/json" } @staticmethod def _normalize_urn(platform_id, urn_type="organization"): """ Normalize platform ID to proper URN format. Args: platform_id: Either a bare ID or full URN urn_type: Type of URN (organization, person, etc.) Returns: Properly formatted URN string """ if not platform_id: raise ValueError("platform_id cannot be empty") # If it already looks like a URN (contains colons), return it as-is. # This prevents corrupting 'urn:li:share:123' into 'urn:li:organization:urn:li:share:123' if ":" in platform_id: return platform_id urn_prefix = f"urn:li:{urn_type}:" return f"{urn_prefix}{platform_id}" @staticmethod def _encode_urn(urn): """URL encode URN for use in API requests""" return quote(urn, safe='') @staticmethod def _parse_timestamp(time_ms): """ Convert LinkedIn timestamp (milliseconds since epoch) to Django timezone-aware datetime. Args: time_ms: Timestamp in milliseconds Returns: Timezone-aware datetime object or current time if parsing fails """ if not time_ms: return timezone.now() try: # LinkedIn returns milliseconds, divide by 1000 for seconds return datetime.datetime.fromtimestamp( time_ms / 1000.0, tz=datetime.timezone.utc ) except (ValueError, OSError): return timezone.now() # ========================================== # AUTHENTICATION # ========================================== @staticmethod def get_auth_url(state=None): """Generate LinkedIn OAuth authorization URL.""" params = { "response_type": "code", "client_id": settings.LINKEDIN_CLIENT_ID, "redirect_uri": settings.LINKEDIN_REDIRECT_URI, "scope": " ".join(LinkedInConstants.SCOPES), "state": state or "random_state_123", } return f"{LinkedInConstants.AUTH_URL}?{urlencode(params)}" @staticmethod def exchange_code_for_token(code): """Exchange authorization code for access token.""" payload = { "grant_type": "authorization_code", "code": code, "redirect_uri": settings.LINKEDIN_REDIRECT_URI, "client_id": settings.LINKEDIN_CLIENT_ID, "client_secret": settings.LINKEDIN_CLIENT_SECRET } response = requests.post(LinkedInConstants.TOKEN_URL, data=payload) if response.status_code != 200: raise LinkedInAPIError(f"Token Exchange Failed: {response.text}") return response.json() @staticmethod def refresh_access_token(account): """Refresh access token if expired or expiring soon.""" if not account.is_active: raise LinkedInAPIError("Account is inactive") # Refresh if token expires within 15 minutes if timezone.now() >= account.expires_at - datetime.timedelta(minutes=15): payload = { "grant_type": "refresh_token", "refresh_token": account.refresh_token, "client_id": settings.LINKEDIN_CLIENT_ID, "client_secret": settings.LINKEDIN_CLIENT_SECRET, } response = requests.post(LinkedInConstants.TOKEN_URL, data=payload) if response.status_code != 200: account.is_active = False account.save() raise LinkedInAPIError(f"Refresh Token Expired: {response.text}") data = response.json() account.access_token = data['access_token'] account.expires_at = timezone.now() + datetime.timedelta(seconds=data['expires_in']) if 'refresh_token' in data: account.refresh_token = data['refresh_token'] account.save() return account.access_token # ========================================== # API REQUEST HANDLER # ========================================== @staticmethod def _make_request(account, method, url, payload=None, retry_count=0): """Make authenticated API request with rate limit handling.""" token = LinkedInService.refresh_access_token(account) headers = LinkedInService._get_headers(token) try: if method == "GET": response = requests.get(url, headers=headers, params=payload, timeout=30) elif method == "POST": response = requests.post(url, headers=headers, json=payload, timeout=30) elif method == "DELETE": response = requests.delete(url, headers=headers, params=payload, timeout=30) else: raise ValueError(f"Unsupported HTTP method: {method}") # Handle rate limiting if response.status_code == 429: if retry_count >= LinkedInConstants.MAX_RETRIES: raise LinkedInAPIError("Max retries exceeded for rate limit") reset_time = int(response.headers.get('X-RateLimit-Reset', time.time() + 60)) sleep_duration = max(1, reset_time - int(time.time())) print(f"[Rate Limit] Sleeping for {sleep_duration}s (attempt {retry_count + 1})") time.sleep(sleep_duration) return LinkedInService._make_request(account, method, url, payload, retry_count + 1) # Handle 404 as empty response (resource not found) if response.status_code == 404: return {} # Handle other errors if response.status_code >= 400: raise LinkedInAPIError(f"API Error {response.status_code}: {response.text}") return response.json() except requests.exceptions.RequestException as e: raise LinkedInAPIError(f"Request failed: {str(e)}") # ========================================== # POSTS API # ========================================== @staticmethod def fetch_posts(account, count=50): """ Fetch organization posts using new Posts API. Returns post objects containing full URNs (e.g., urn:li:share:123). """ posts = [] org_urn = LinkedInService._normalize_urn(account.platform_id, "organization") params = { "author": org_urn, "q": "author", "count": min(count, LinkedInConstants.MAX_PAGE_SIZE), "sortBy": "LAST_MODIFIED" } try: data = LinkedInService._make_request( account, "GET", f"{LinkedInConstants.BASE_URL}/posts", # versioned endpoint payload=params ) posts = data.get('elements', []) except LinkedInAPIError as e: print(f"Error fetching posts: {e}") return posts # ========================================== # COMMENTS API # ========================================== @staticmethod def fetch_all_comments(account, post_urn): """ Fetch ALL comments for a post (for complete historical sync). post_urn: Must be full URN (e.g., urn:li:share:123) """ comments = [] start = 0 batch_size = LinkedInConstants.DEFAULT_PAGE_SIZE encoded_urn = LinkedInService._encode_urn(post_urn) while True: params = {"count": batch_size, "start": start} try: data = LinkedInService._make_request( account, "GET", f"{LinkedInConstants.BASE_URL}/socialActions/{encoded_urn}/comments", payload=params ) except LinkedInAPIError as e: print(f"Error fetching comments: {e}") break if not data or 'elements' not in data: break elements = data.get('elements', []) if not elements: break for comment in elements: comment['post_urn'] = post_urn comments.append(comment) if len(elements) < batch_size: break start += batch_size time.sleep(0.3) return comments @staticmethod def fetch_comments_limited(account, post_urn, limit=200): """Fetch limited number of most recent comments.""" comments = [] start = 0 batch_size = LinkedInConstants.DEFAULT_PAGE_SIZE encoded_urn = LinkedInService._encode_urn(post_urn) while len(comments) < limit: remaining = limit - len(comments) current_batch = min(batch_size, remaining) params = {"count": current_batch, "start": start} try: data = LinkedInService._make_request( account, "GET", f"{LinkedInConstants.BASE_URL}/socialActions/{encoded_urn}/comments", payload=params ) except LinkedInAPIError as e: print(f"Error fetching comments: {e}") break if not data or 'elements' not in data: break elements = data.get('elements', []) if not elements: break for comment in elements: comment['post_urn'] = post_urn comments.append(comment) if len(elements) < current_batch: break start += current_batch time.sleep(0.3) return comments @staticmethod def fetch_comments_delta(account, post_urn, since_timestamp=None): """Fetch only NEW comments since a specific timestamp.""" comments = [] start = 0 batch_size = LinkedInConstants.DEFAULT_PAGE_SIZE encoded_urn = LinkedInService._encode_urn(post_urn) while True: params = {"count": batch_size, "start": start} try: data = LinkedInService._make_request( account, "GET", f"{LinkedInConstants.BASE_URL}/socialActions/{encoded_urn}/comments", payload=params ) except LinkedInAPIError as e: print(f"Error fetching comments: {e}") break if not data or 'elements' not in data: break elements = data.get('elements', []) if not elements: break # Optimization: Check newest item in batch first newest_in_batch = elements[0].get('created', {}).get('time') if since_timestamp and newest_in_batch: newest_dt = LinkedInService._parse_timestamp(newest_in_batch) if newest_dt <= since_timestamp: break # Even the newest comment is old, stop entirely # Check oldest item to see if we should stop paginating if since_timestamp and elements: oldest_in_batch = elements[-1].get('created', {}).get('time') if oldest_in_batch: oldest_dt = LinkedInService._parse_timestamp(oldest_in_batch) if oldest_dt <= since_timestamp: # Filter only those strictly newer than timestamp for comment in elements: c_time_ms = comment.get('created', {}).get('time') if c_time_ms: c_dt = LinkedInService._parse_timestamp(c_time_ms) if c_dt > since_timestamp: comment['post_urn'] = post_urn comments.append(comment) break for comment in elements: comment['post_urn'] = post_urn comments.append(comment) if len(elements) < batch_size: break start += batch_size time.sleep(0.3) return comments @staticmethod def fetch_single_comment(account, post_urn, comment_id): """ Fetch a specific comment by ID (efficient for webhook processing). """ encoded_post_urn = LinkedInService._encode_urn(post_urn) url = f"{LinkedInConstants.BASE_URL}/socialActions/{encoded_post_urn}/comments/{comment_id}" try: data = LinkedInService._make_request(account, "GET", url) if data: data['post_urn'] = post_urn return data except LinkedInAPIError as e: print(f"Error fetching comment {comment_id}: {e}") return None # ========================================== # COMMENT ACTIONS # ========================================== @staticmethod def post_reply(account, parent_urn, text): """ Reply to a post or comment. parent_urn: URN of the post (urn:li:share:...) or comment (urn:li:comment:...) """ encoded_parent_urn = LinkedInService._encode_urn(parent_urn) url = f"{LinkedInConstants.BASE_URL}/socialActions/{encoded_parent_urn}/comments" org_urn = LinkedInService._normalize_urn(account.platform_id, "organization") payload = { "actor": org_urn, "message": { "text": text } } return LinkedInService._make_request(account, "POST", url, payload=payload) # @staticmethod # def delete_comment(account, post_urn, comment_id): # """ # Delete a comment. # Note: The 'actor' is NOT passed as a query parameter in the new API. # It is derived from the OAuth Access Token. # """ # encoded_post_urn = LinkedInService._encode_urn(post_urn) # # Construct URL # url = f"{LinkedInConstants.BASE_URL}/socialActions/{encoded_post_urn}/comments/{comment_id}" # # Make request (payload={} for safe DELETE handling in _make_request) # LinkedInService._make_request(account, "DELETE", url, payload={}) # return True # ========================================== # WEBHOOK UTILITIES # ========================================== @staticmethod def calculate_hmac_sha256(secret_key, message): """Calculate HMAC-SHA256 signature.""" if isinstance(message, str): message = message.encode('utf-8') if isinstance(secret_key, str): secret_key = secret_key.encode('utf-8') return hmac.new(secret_key, message, hashlib.sha256).hexdigest() @staticmethod def verify_webhook_signature(received_signature, body_raw, client_secret): """Verify webhook signature for authenticity.""" if not received_signature or not body_raw: return False calculated_digest = LinkedInService.calculate_hmac_sha256(client_secret, body_raw) expected_signature = f"hmacsha256={calculated_digest}" return hmac.compare_digest(received_signature, expected_signature)