Files
college_craft/site/social/connectors/linkedin.py
T
ai_ml_operations 3a14bfb996 Initial commit
2026-08-27 04:17:34 -07:00

164 lines
5.7 KiB
Python

"""LinkedIn API connector."""
from __future__ import annotations
import json
import logging
import requests
from core.models import StoredFile
from social.crypto import decrypt_tokens
from social.media import split_media
logger = logging.getLogger(__name__)
class LinkedInConnector:
platform = "linkedin"
def publish(self, post, target) -> str:
tokens = json.loads(decrypt_tokens(target.account.encrypted_tokens) or "{}")
access_token = tokens.get("access_token")
author_urn = target.account.external_id or tokens.get("author_urn")
if not access_token or not author_urn:
raise RuntimeError("LinkedIn account missing access_token/author_urn")
media = post.media if isinstance(post.media, list) else []
images, videos = split_media(media)
headers = {
"Authorization": f"Bearer {access_token}",
"X-Restli-Protocol-Version": "2.0.0",
"Content-Type": "application/json",
}
if videos:
raise RuntimeError(
"LinkedIn video publish is not wired yet — attach images or post text-only."
)
if images:
# Share first image (multi-image LinkedIn needs multi-image recipe).
asset_urn = self._upload_image(
access_token=access_token,
author_urn=author_urn,
image=images[0],
)
payload = {
"author": author_urn,
"lifecycleState": "PUBLISHED",
"specificContent": {
"com.linkedin.ugc.ShareContent": {
"shareCommentary": {"text": post.body or ""},
"shareMediaCategory": "IMAGE",
"media": [
{
"status": "READY",
"description": {"text": post.body or ""},
"media": asset_urn,
"title": {"text": images[0].get("filename") or "Image"},
}
],
}
},
"visibility": {
"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
},
}
else:
payload = {
"author": author_urn,
"lifecycleState": "PUBLISHED",
"specificContent": {
"com.linkedin.ugc.ShareContent": {
"shareCommentary": {"text": post.body or ""},
"shareMediaCategory": "NONE",
}
},
"visibility": {
"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
},
}
response = requests.post(
"https://api.linkedin.com/v2/ugcPosts",
json=payload,
headers=headers,
timeout=60,
)
if response.status_code >= 400:
logger.warning(
"LinkedIn publish failed: %s %s",
response.status_code,
response.text[:500],
)
raise RuntimeError(
f"LinkedIn publish failed ({response.status_code}): {response.text[:400]}"
)
return str(response.headers.get("x-restli-id") or response.json().get("id") or "")
def _upload_image(
self, *, access_token: str, author_urn: str, image: dict
) -> str:
file_id = image.get("id")
if not file_id:
raise RuntimeError("LinkedIn image missing StoredFile id")
stored = StoredFile.objects.get(pk=file_id)
binary = bytes(stored.data)
register = requests.post(
"https://api.linkedin.com/v2/assets?action=registerUpload",
json={
"registerUploadRequest": {
"recipes": ["urn:li:digitalmediaRecipe:feedshare-image"],
"owner": author_urn,
"serviceRelationships": [
{
"relationshipType": "OWNER",
"identifier": "urn:li:userGeneratedContent",
}
],
}
},
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
"X-Restli-Protocol-Version": "2.0.0",
},
timeout=30,
)
if register.status_code >= 400:
raise RuntimeError(
f"LinkedIn image register failed ({register.status_code}): "
f"{register.text[:400]}"
)
value = register.json().get("value") or {}
asset = value.get("asset")
upload_mech = (
(value.get("uploadMechanism") or {}).get(
"com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest"
)
or {}
)
upload_url = upload_mech.get("uploadUrl")
upload_headers = upload_mech.get("headers") or {}
if not asset or not upload_url:
raise RuntimeError("LinkedIn registerUpload missing asset/uploadUrl")
put_headers = {"Authorization": f"Bearer {access_token}"}
put_headers.update(upload_headers)
put = requests.put(
upload_url,
data=binary,
headers=put_headers,
timeout=120,
)
if put.status_code >= 400:
raise RuntimeError(
f"LinkedIn image upload failed ({put.status_code}): {put.text[:400]}"
)
return str(asset)
def refresh_token(self, account) -> None:
logger.info("LinkedIn token refresh stub for %s", account.pk)