"""LinkedIn API connector.""" from __future__ import annotations import json import logging import requests from messaging.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)