diff --git a/.env.example b/.env.example index 6af7622..ad3a4f9 100644 --- a/.env.example +++ b/.env.example @@ -68,7 +68,7 @@ PCM_RETURN_ADDRESS= # PCM_RETURN_ZIP= # Social -# LinkedIn Client ID / Secret are entered in Portal → Social accounts (not env). +# LinkedIn / Instagram App ID + Secret: Portal → Social accounts (not env). META_APP_ID= META_APP_SECRET= # Generate: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" diff --git a/site/messaging/migrations/0006_storedfile_social_kinds.py b/site/messaging/migrations/0006_storedfile_social_kinds.py new file mode 100644 index 0000000..588ef86 --- /dev/null +++ b/site/messaging/migrations/0006_storedfile_social_kinds.py @@ -0,0 +1,26 @@ +# Generated manually for StoredFile social media kinds + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("messaging", "0005_message_opened_clicked_statuses"), + ] + + operations = [ + migrations.AlterField( + model_name="storedfile", + name="kind", + field=models.CharField( + choices=[ + ("campaign_image", "Campaign image"), + ("social_image", "Social image"), + ("social_video", "Social video"), + ], + default="campaign_image", + max_length=32, + ), + ), + ] diff --git a/site/messaging/models.py b/site/messaging/models.py index 25b1a66..6f12f94 100644 --- a/site/messaging/models.py +++ b/site/messaging/models.py @@ -122,6 +122,8 @@ class StoredFile(UUIDPrimaryKeyModel, TimeStampedModel): class Kind(models.TextChoices): CAMPAIGN_IMAGE = "campaign_image", "Campaign image" + SOCIAL_IMAGE = "social_image", "Social image" + SOCIAL_VIDEO = "social_video", "Social video" kind = models.CharField( max_length=32, choices=Kind.choices, default=Kind.CAMPAIGN_IMAGE diff --git a/site/social/connectors/linkedin.py b/site/social/connectors/linkedin.py index aec4045..8d2f8b9 100644 --- a/site/social/connectors/linkedin.py +++ b/site/social/connectors/linkedin.py @@ -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) diff --git a/site/social/connectors/meta.py b/site/social/connectors/meta.py index 11ef8f0..5ec4efc 100644 --- a/site/social/connectors/meta.py +++ b/site/social/connectors/meta.py @@ -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, + ) diff --git a/site/social/media.py b/site/social/media.py new file mode 100644 index 0000000..6688271 --- /dev/null +++ b/site/social/media.py @@ -0,0 +1,91 @@ +"""Helpers for social post media (StoredFile-backed).""" + +from __future__ import annotations + +import json +from typing import Any + +from django.conf import settings +from django.urls import reverse + +from messaging.models import StoredFile + +ALLOWED_IMAGE_TYPES = frozenset( + {"image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"} +) +ALLOWED_VIDEO_TYPES = frozenset( + {"video/mp4", "video/quicktime", "video/webm"} +) +MAX_IMAGE_BYTES = 8 * 1024 * 1024 # 8 MB +MAX_VIDEO_BYTES = 100 * 1024 * 1024 # 100 MB +MAX_IMAGES = 10 + + +def public_file_url(file_id: str) -> str: + """Absolute URL Meta/LinkedIn can fetch (UUID = capability token).""" + base = (settings.PUBLIC_SITE_URL or "").rstrip("/") + path = reverse("messaging:stored_file", kwargs={"pk": file_id}) + if not base: + return path + return f"{base}{path}" + + +def media_item_from_stored(stored: StoredFile) -> dict[str, Any]: + kind = stored.kind + media_type = "video" if kind == StoredFile.Kind.SOCIAL_VIDEO else "image" + return { + "id": str(stored.pk), + "type": media_type, + "url": public_file_url(str(stored.pk)), + "content_type": stored.content_type, + "filename": stored.filename or "", + "size": stored.size, + } + + +def parse_media_json(raw: str) -> list[dict[str, Any]]: + """Parse composer hidden media JSON into a cleaned list.""" + raw = (raw or "").strip() + if not raw: + return [] + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + raise ValueError("Invalid media payload.") from exc + if not isinstance(data, list): + raise ValueError("Media payload must be a list.") + + items: list[dict[str, Any]] = [] + for entry in data: + if not isinstance(entry, dict): + continue + file_id = str(entry.get("id") or "").strip() + if not file_id: + continue + try: + stored = StoredFile.objects.get(pk=file_id) + except (StoredFile.DoesNotExist, ValueError) as exc: + raise ValueError(f"Unknown media file: {file_id}") from exc + if stored.kind not in { + StoredFile.Kind.SOCIAL_IMAGE, + StoredFile.Kind.SOCIAL_VIDEO, + StoredFile.Kind.CAMPAIGN_IMAGE, + }: + raise ValueError("File is not a social media attachment.") + items.append(media_item_from_stored(stored)) + + videos = [m for m in items if m["type"] == "video"] + images = [m for m in items if m["type"] == "image"] + if len(videos) > 1: + raise ValueError("Attach at most one video per post.") + if videos and images: + raise ValueError("Use either images or one video — not both.") + if len(images) > MAX_IMAGES: + raise ValueError(f"Attach at most {MAX_IMAGES} images.") + return items + + +def split_media(media: list[dict[str, Any]]) -> tuple[list[dict], list[dict]]: + images = [m for m in media if m.get("type") == "image"] + videos = [m for m in media if m.get("type") == "video"] + return images, videos diff --git a/site/social/oauth_meta.py b/site/social/oauth_meta.py new file mode 100644 index 0000000..85b1cdf --- /dev/null +++ b/site/social/oauth_meta.py @@ -0,0 +1,185 @@ +"""Facebook Login for Business (Instagram API with Facebook Login). + +Docs: +https://developers.facebook.com/documentation/instagram-platform/instagram-api-with-facebook-login/business-login-for-instagram + +Uses response_type=token (tokens arrive in the URL fragment). A thin callback +page reads the fragment and POSTs tokens to the server. +""" + +from __future__ import annotations + +import json +import logging +from datetime import timedelta +from urllib.parse import urlencode + +import requests +from django.conf import settings +from django.urls import reverse +from django.utils import timezone + +from social.crypto import decrypt_tokens +from social.models import Platform, SocialAppCredentials + +logger = logging.getLogger(__name__) + +GRAPH_VERSION = "v21.0" +GRAPH = f"https://graph.facebook.com/{GRAPH_VERSION}" +AUTHORIZE_URL = f"https://www.facebook.com/{GRAPH_VERSION}/dialog/oauth" + +# Permissions for Page posting + Instagram content publish via Facebook Login. +DEFAULT_SCOPES = ( + "instagram_basic", + "instagram_content_publish", + "pages_show_list", + "pages_read_engagement", + "pages_manage_posts", +) + +# Triggers Instagram Professional onboarding inside Facebook Login for Business. +IG_ONBOARDING_EXTRAS = {"setup": {"channel": "IG_API_ONBOARDING"}} + + +class MetaOAuthError(RuntimeError): + """Raised when Facebook Login for Business or Page/IG lookup fails.""" + + +def get_app_credentials() -> SocialAppCredentials | None: + """Meta App ID/Secret — stored on the facebook credentials row (shared).""" + fb = SocialAppCredentials.objects.filter(platform=Platform.FACEBOOK).first() + if fb and fb.is_configured: + return fb + # Back-compat if credentials were saved from the Instagram form earlier. + return SocialAppCredentials.objects.filter(platform=Platform.INSTAGRAM).first() + + +def load_client_credentials() -> tuple[str, str]: + app = get_app_credentials() + if not app or not app.client_id or not app.encrypted_client_secret: + raise MetaOAuthError( + "Meta app credentials are not saved yet. " + "Enter Meta App ID and App Secret on the Connect Facebook or Instagram form." + ) + try: + secret = decrypt_tokens(app.encrypted_client_secret) + except ValueError as exc: + raise MetaOAuthError( + "Could not decrypt Meta app secret. Re-enter the App Secret." + ) from exc + if not secret: + raise MetaOAuthError("Meta app secret is empty — save it again.") + return app.client_id.strip(), secret.strip() + + +def credentials_configured() -> bool: + app = get_app_credentials() + return bool(app and app.is_configured) + + +def redirect_uri() -> str: + """Must match Valid OAuth Redirect URIs (Facebook Login for Business settings).""" + base = (settings.PUBLIC_SITE_URL or "").rstrip("/") + if not base: + raise MetaOAuthError( + "PUBLIC_SITE_URL is not set; cannot build Meta OAuth redirect URI." + ) + return f"{base}{reverse('social:meta_oauth_callback')}" + + +def authorization_url(*, state: str, scopes: tuple[str, ...] = DEFAULT_SCOPES) -> str: + client_id, _secret = load_client_credentials() + params = { + "client_id": client_id, + "display": "page", + "extras": json.dumps(IG_ONBOARDING_EXTRAS, separators=(",", ":")), + "redirect_uri": redirect_uri(), + "response_type": "token", + "scope": ",".join(scopes), + "state": state, + } + return f"{AUTHORIZE_URL}?{urlencode(params)}" + + +def fetch_pages(user_access_token: str) -> list[dict]: + """ + GET /me/accounts — Pages the user can manage, with linked IG business accounts. + + Each item: id, name, access_token (page), instagram_business_account (optional). + """ + response = requests.get( + f"{GRAPH}/me/accounts", + params={ + "fields": "id,name,access_token,instagram_business_account{id,username,name}", + "access_token": user_access_token, + }, + timeout=30, + ) + if response.status_code >= 400: + logger.warning( + "Meta /me/accounts failed: %s %s", + response.status_code, + response.text[:500], + ) + raise MetaOAuthError( + f"Could not list Facebook Pages ({response.status_code}). " + "Confirm pages_show_list was granted and the user manages a Page." + ) + data = response.json().get("data") or [] + if not data: + raise MetaOAuthError( + "No Facebook Pages found for this user. Create a Page and link a " + "Professional Instagram account, then try again." + ) + return data + + +def facebook_token_blob(page: dict, *, user_token: str = "") -> dict: + page_token = page.get("access_token") or "" + if not page_token: + raise MetaOAuthError(f"Page {page.get('id')} missing access_token.") + blob: dict = { + "access_token": page_token, + "page_id": str(page["id"]), + "user_access_token": user_token, + "auth_type": "facebook_login_for_business", + } + ig = page.get("instagram_business_account") or {} + if ig.get("id"): + blob["ig_user_id"] = str(ig["id"]) + return blob + + +def instagram_token_blob(page: dict, *, user_token: str = "") -> dict: + ig = page.get("instagram_business_account") or {} + ig_id = str(ig.get("id") or "").strip() + if not ig_id: + raise MetaOAuthError( + f"Page “{page.get('name') or page.get('id')}” has no linked " + "Instagram Professional account." + ) + page_token = page.get("access_token") or "" + if not page_token: + raise MetaOAuthError("Page access token missing — required for Instagram Graph API.") + username = (ig.get("username") or "").strip() + name = (ig.get("name") or "").strip() + label = f"@{username}" if username else (name or "Instagram account") + return { + "access_token": page_token, + "page_id": str(page["id"]), + "ig_user_id": ig_id, + "username": username, + "label": label, + "user_access_token": user_token, + "auth_type": "facebook_login_for_business", + } + + +def expires_at_from_fragment(expires_in: str | int | None) -> str | None: + try: + seconds = int(expires_in or 0) + except (TypeError, ValueError): + return None + if seconds <= 0: + return None + return (timezone.now() + timedelta(seconds=seconds)).isoformat() diff --git a/site/social/templates/social/account_list.html b/site/social/templates/social/account_list.html index a71e058..94b143e 100644 --- a/site/social/templates/social/account_list.html +++ b/site/social/templates/social/account_list.html @@ -11,15 +11,15 @@
Connect a Page you manage with a Page access token.
+Connect via Facebook Login for Business. Shared Meta App ID with Instagram.
Requires a Facebook Page linked to an IG business account.
+ Instagram +Facebook Login for Business — Professional account linked to a Page.