generated from westfarn/web_django_template
164 lines
5.7 KiB
Python
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)
|