generated from westfarn/web_django_template
Initial commit
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class SocialConnector(Protocol):
|
||||
platform: str
|
||||
|
||||
def publish(self, post, target) -> str:
|
||||
"""Publish and return remote post id."""
|
||||
|
||||
def refresh_token(self, account) -> None: ...
|
||||
@@ -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)
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Meta Graph API connector (Facebook Page + Instagram via Facebook Login)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
from social.crypto import decrypt_tokens
|
||||
from social.media import split_media
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MetaConnector:
|
||||
platform = "meta"
|
||||
GRAPH = "https://graph.facebook.com/v21.0"
|
||||
|
||||
def publish(self, post, target) -> str:
|
||||
tokens = json.loads(decrypt_tokens(target.account.encrypted_tokens) or "{}")
|
||||
access_token = tokens.get("access_token")
|
||||
if not access_token:
|
||||
raise RuntimeError("Meta/Instagram account missing access_token")
|
||||
|
||||
media = post.media if isinstance(post.media, list) else []
|
||||
images, videos = split_media(media)
|
||||
|
||||
if target.platform == "instagram":
|
||||
ig_user_id = target.account.external_id or tokens.get("ig_user_id")
|
||||
if not ig_user_id:
|
||||
raise RuntimeError("Instagram account missing ig_user_id")
|
||||
return self._publish_instagram(
|
||||
ig_user_id=ig_user_id,
|
||||
access_token=access_token,
|
||||
caption=post.body or "",
|
||||
images=images,
|
||||
videos=videos,
|
||||
)
|
||||
|
||||
page_id = target.account.external_id or tokens.get("page_id")
|
||||
if not page_id:
|
||||
raise RuntimeError("Meta account missing page_id")
|
||||
return self._publish_facebook(
|
||||
page_id=page_id,
|
||||
access_token=access_token,
|
||||
message=post.body or "",
|
||||
images=images,
|
||||
videos=videos,
|
||||
)
|
||||
|
||||
def _publish_facebook(
|
||||
self,
|
||||
*,
|
||||
page_id: str,
|
||||
access_token: str,
|
||||
message: str,
|
||||
images: list[dict],
|
||||
videos: list[dict],
|
||||
) -> str:
|
||||
if videos:
|
||||
video = videos[0]
|
||||
response = requests.post(
|
||||
f"{self.GRAPH}/{page_id}/videos",
|
||||
data={
|
||||
"file_url": video["url"],
|
||||
"description": message,
|
||||
"access_token": access_token,
|
||||
},
|
||||
timeout=120,
|
||||
)
|
||||
self._raise_graph(response, "Facebook video publish")
|
||||
return str(response.json().get("id") or "")
|
||||
|
||||
if len(images) == 1:
|
||||
response = requests.post(
|
||||
f"{self.GRAPH}/{page_id}/photos",
|
||||
data={
|
||||
"url": images[0]["url"],
|
||||
"caption": message,
|
||||
"access_token": access_token,
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
self._raise_graph(response, "Facebook photo publish")
|
||||
return str(response.json().get("id") or response.json().get("post_id") or "")
|
||||
|
||||
if len(images) > 1:
|
||||
attached = []
|
||||
for image in images:
|
||||
resp = requests.post(
|
||||
f"{self.GRAPH}/{page_id}/photos",
|
||||
data={
|
||||
"url": image["url"],
|
||||
"published": "false",
|
||||
"access_token": access_token,
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
self._raise_graph(resp, "Facebook multi-photo upload")
|
||||
photo_id = resp.json().get("id")
|
||||
if photo_id:
|
||||
attached.append({"media_fbid": photo_id})
|
||||
data = {
|
||||
"message": message,
|
||||
"access_token": access_token,
|
||||
}
|
||||
for idx, item in enumerate(attached):
|
||||
data[f"attached_media[{idx}]"] = json.dumps(item)
|
||||
response = requests.post(
|
||||
f"{self.GRAPH}/{page_id}/feed",
|
||||
data=data,
|
||||
timeout=60,
|
||||
)
|
||||
self._raise_graph(response, "Facebook multi-photo feed")
|
||||
return str(response.json().get("id") or "")
|
||||
|
||||
response = requests.post(
|
||||
f"{self.GRAPH}/{page_id}/feed",
|
||||
data={"message": message, "access_token": access_token},
|
||||
timeout=30,
|
||||
)
|
||||
self._raise_graph(response, "Facebook text feed")
|
||||
return str(response.json().get("id") or "")
|
||||
|
||||
def _publish_instagram(
|
||||
self,
|
||||
*,
|
||||
ig_user_id: str,
|
||||
access_token: str,
|
||||
caption: str,
|
||||
images: list[dict],
|
||||
videos: list[dict],
|
||||
) -> str:
|
||||
if not images and not videos:
|
||||
raise RuntimeError(
|
||||
"Instagram requires an image or video attachment (caption-only not allowed)."
|
||||
)
|
||||
|
||||
if videos:
|
||||
creation_id = self._ig_create_container(
|
||||
ig_user_id,
|
||||
access_token,
|
||||
{
|
||||
"media_type": "VIDEO",
|
||||
"video_url": videos[0]["url"],
|
||||
"caption": caption,
|
||||
},
|
||||
)
|
||||
self._ig_wait_container(creation_id, access_token)
|
||||
return self._ig_publish(ig_user_id, access_token, creation_id)
|
||||
|
||||
if len(images) == 1:
|
||||
creation_id = self._ig_create_container(
|
||||
ig_user_id,
|
||||
access_token,
|
||||
{"image_url": images[0]["url"], "caption": caption},
|
||||
)
|
||||
return self._ig_publish(ig_user_id, access_token, creation_id)
|
||||
|
||||
# Carousel: children first, then parent container.
|
||||
children = []
|
||||
for image in images[:10]:
|
||||
child_id = self._ig_create_container(
|
||||
ig_user_id,
|
||||
access_token,
|
||||
{"image_url": image["url"], "is_carousel_item": "true"},
|
||||
)
|
||||
children.append(child_id)
|
||||
creation_id = self._ig_create_container(
|
||||
ig_user_id,
|
||||
access_token,
|
||||
{
|
||||
"media_type": "CAROUSEL",
|
||||
"children": ",".join(children),
|
||||
"caption": caption,
|
||||
},
|
||||
)
|
||||
return self._ig_publish(ig_user_id, access_token, creation_id)
|
||||
|
||||
def _ig_create_container(
|
||||
self, ig_user_id: str, access_token: str, fields: dict
|
||||
) -> str:
|
||||
data = {**fields, "access_token": access_token}
|
||||
response = requests.post(
|
||||
f"{self.GRAPH}/{ig_user_id}/media",
|
||||
data=data,
|
||||
timeout=60,
|
||||
)
|
||||
self._raise_graph(response, "Instagram media container")
|
||||
creation_id = response.json().get("id")
|
||||
if not creation_id:
|
||||
raise RuntimeError("Instagram media container response missing id")
|
||||
return str(creation_id)
|
||||
|
||||
def _ig_wait_container(
|
||||
self, creation_id: str, access_token: str, *, attempts: int = 20
|
||||
) -> None:
|
||||
"""Poll video container until FINISHED (or fail)."""
|
||||
for _ in range(attempts):
|
||||
response = requests.get(
|
||||
f"{self.GRAPH}/{creation_id}",
|
||||
params={
|
||||
"fields": "status_code,status",
|
||||
"access_token": access_token,
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
self._raise_graph(response, "Instagram container status")
|
||||
status = (response.json().get("status_code") or "").upper()
|
||||
if status == "FINISHED":
|
||||
return
|
||||
if status in {"ERROR", "EXPIRED"}:
|
||||
raise RuntimeError(
|
||||
f"Instagram video processing failed ({status}): "
|
||||
f"{response.json().get('status') or ''}"
|
||||
)
|
||||
time.sleep(3)
|
||||
raise RuntimeError("Instagram video still processing — try again shortly.")
|
||||
|
||||
def _ig_publish(
|
||||
self, ig_user_id: str, access_token: str, creation_id: str
|
||||
) -> str:
|
||||
response = requests.post(
|
||||
f"{self.GRAPH}/{ig_user_id}/media_publish",
|
||||
data={"creation_id": creation_id, "access_token": access_token},
|
||||
timeout=60,
|
||||
)
|
||||
self._raise_graph(response, "Instagram media_publish")
|
||||
return str(response.json().get("id") or "")
|
||||
|
||||
@staticmethod
|
||||
def _raise_graph(response: requests.Response, label: str) -> None:
|
||||
if response.status_code < 400:
|
||||
return
|
||||
detail = response.text[:500]
|
||||
try:
|
||||
err = response.json().get("error") or {}
|
||||
detail = err.get("message") or detail
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
logger.warning("%s failed: %s %s", label, response.status_code, detail)
|
||||
raise RuntimeError(f"{label} failed ({response.status_code}): {detail}")
|
||||
|
||||
def refresh_token(self, account) -> None:
|
||||
logger.info(
|
||||
"Meta/Instagram token refresh not yet implemented for account %s",
|
||||
account.pk,
|
||||
)
|
||||
Reference in New Issue
Block a user