466 lines
17 KiB
Python
466 lines
17 KiB
Python
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) |