Add Meta Facebook Login OAuth and social media attach/publish.
Deploy Beta / unit-tests (push) Successful in 14s
Deploy Beta / docker (push) Successful in 17s
Deploy Beta / deploy-beta (push) Successful in 1m32s

Connect Facebook and Instagram via shared portal Meta App credentials (Facebook Login for Business), and let compose attach DB-stored images/videos with live preview and platform publish.
This commit is contained in:
2026-08-11 14:45:23 -05:00
parent c8dacd0d94
commit 30ce58e610
13 changed files with 1412 additions and 142 deletions
+128 -13
View File
@@ -1,11 +1,15 @@
"""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__)
@@ -20,29 +24,140 @@ class LinkedInConnector:
if not access_token or not author_urn:
raise RuntimeError("LinkedIn account missing access_token/author_urn")
payload = {
"author": author_urn,
"lifecycleState": "PUBLISHED",
"specificContent": {
"com.linkedin.ugc.ShareContent": {
"shareCommentary": {"text": post.body},
"shareMediaCategory": "NONE",
}
},
"visibility": {"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"},
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}",
"X-Restli-Protocol-Version": "2.0.0",
"Content-Type": "application/json",
"X-Restli-Protocol-Version": "2.0.0",
},
timeout=30,
)
response.raise_for_status()
return str(response.headers.get("x-restli-id") or response.json().get("id") or "")
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)
+222 -15
View File
@@ -1,12 +1,15 @@
"""Meta Graph API connector (Facebook Page + Instagram Business)."""
"""Meta Graph API connector (Facebook Page + Instagram via Facebook Login)."""
from __future__ import annotations
import json
import logging
import time
import requests
from django.conf import settings
from social.crypto import decrypt_tokens
import json
from social.media import split_media
logger = logging.getLogger(__name__)
@@ -18,26 +21,230 @@ class MetaConnector:
def publish(self, post, target) -> str:
tokens = json.loads(decrypt_tokens(target.account.encrypted_tokens) or "{}")
access_token = tokens.get("access_token")
page_id = target.account.external_id or tokens.get("page_id")
if not access_token or not page_id:
raise RuntimeError("Meta account missing access_token/page_id")
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 content publishing is a multi-step Graph flow; stub container create.
raise NotImplementedError(
"Instagram publish requires IG business account wiring — complete OAuth first"
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": post.body, "access_token": access_token},
data={"message": message, "access_token": access_token},
timeout=30,
)
response.raise_for_status()
self._raise_graph(response, "Facebook text feed")
return str(response.json().get("id") or "")
def refresh_token(self, account) -> None:
# Long-lived token exchange when META_APP_ID/SECRET are set.
if not settings.META_APP_ID or not settings.META_APP_SECRET:
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
logger.info("Meta token refresh not yet implemented for account %s", account.pk)
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,
)