Template
Populate the client website template with catalog feature flags.
Extract always-on public/portal/UTM plus optional email_sms, directmail, blog, payments, social, and social_ai so new client sites can be bootstrapped from this seed. Refs #1 Refs #2 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user