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 @@ f
Facebook Page -

Connect a Page you manage with a Page access token.

+

Connect via Facebook Login for Business. Shared Meta App ID with Instagram.

IG
- Instagram Business -

Requires a Facebook Page linked to an IG business account.

+ Instagram +

Facebook Login for Business — Professional account linked to a Page.

- {% if connect_meta.oauth and connect_platform == 'linkedin' %} + {% if connect_meta.oauth %}
-
{% csrf_token %} - + +
- +
- + - {% if linkedin_secret_saved %} + {% if oauth_secret_saved %}

Secret already saved (encrypted). Enter a new value only to replace it.

{% endif %}
- {% if linkedin_oauth_ready %} - Connect with LinkedIn + {% if oauth_ready and oauth_start_url_name %} + {{ connect_meta.connect_label }} {% else %} - + {% endif %} Cancel
@@ -155,6 +156,10 @@
{% if account.platform == 'linkedin' %} Re-authorize + {% elif account.platform == 'instagram' %} + Re-authorize + {% elif account.platform == 'facebook' %} + Re-authorize {% else %} Update tokens {% endif %} @@ -189,7 +194,7 @@

When to re-auth

    -
  • LinkedIn access tokens expire on a short cycle
  • +
  • LinkedIn / Instagram access tokens expire on a short cycle
  • Meta password changes or app review updates
  • Publishing fails with an auth error
@@ -209,11 +214,11 @@ document.getElementById('connect-form')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); {% endif %} -{% if connect_platform == 'linkedin' %} +{% if connect_meta and connect_meta.oauth %} +{% endblock %} diff --git a/site/social/templates/social/post_detail.html b/site/social/templates/social/post_detail.html index e98d316..620e21f 100644 --- a/site/social/templates/social/post_detail.html +++ b/site/social/templates/social/post_detail.html @@ -10,6 +10,17 @@

Caption

+ {% if post.media %} +
+ {% for item in post.media %} + {% if item.type == "video" %} + + {% else %} + {{ item.filename|default:'Image' }} + {% endif %} + {% endfor %} +
+ {% endif %}
{{ post.body }}
{% if post.scheduled_for %}

Scheduled {{ post.scheduled_for|date:"M j, Y g:i A" }}

diff --git a/site/social/urls.py b/site/social/urls.py index 9f61a3f..f5a7046 100644 --- a/site/social/urls.py +++ b/site/social/urls.py @@ -17,7 +17,28 @@ urlpatterns = [ views.linkedin_oauth_callback, name="linkedin_oauth_callback", ), + path( + "accounts/facebook/start/", + views.facebook_oauth_start, + name="facebook_oauth_start", + ), + path( + "accounts/instagram/start/", + views.instagram_oauth_start, + name="instagram_oauth_start", + ), + path( + "accounts/meta/callback/", + views.meta_oauth_callback, + name="meta_oauth_callback", + ), + path( + "accounts/meta/callback/complete/", + views.meta_oauth_complete, + name="meta_oauth_complete", + ), path("compose/", views.composer, name="composer"), + path("media/upload/", views.media_upload, name="media_upload"), path("api/generate/", views.api_generate, name="api_generate"), path("/", views.post_detail, name="post_detail"), ] diff --git a/site/social/views.py b/site/social/views.py index d1cbc8d..bdbf83e 100644 --- a/site/social/views.py +++ b/site/social/views.py @@ -11,7 +11,16 @@ from django.utils.safestring import mark_safe from django.views.decorators.http import require_GET, require_http_methods, require_POST from messaging.services import parse_scheduled_for +from messaging.models import StoredFile from social.crypto import SocialCryptoError, encrypt_tokens +from social.media import ( + ALLOWED_IMAGE_TYPES, + ALLOWED_VIDEO_TYPES, + MAX_IMAGE_BYTES, + MAX_VIDEO_BYTES, + media_item_from_stored, + parse_media_json, +) from social.models import ( Platform, SocialAccount, @@ -21,59 +30,95 @@ from social.models import ( ) from social.oauth_linkedin import ( LinkedInOAuthError, - authorization_url, - credentials_configured, - exchange_code, - fetch_member_profile, - get_app_credentials, + authorization_url as linkedin_authorization_url, + credentials_configured as linkedin_credentials_configured, + exchange_code as linkedin_exchange_code, + fetch_member_profile as linkedin_fetch_member_profile, + get_app_credentials as get_linkedin_app_credentials, redirect_uri as linkedin_redirect_uri, - token_blob_from_oauth, + token_blob_from_oauth as linkedin_token_blob_from_oauth, +) +from social.oauth_meta import ( + MetaOAuthError, + authorization_url as meta_authorization_url, + credentials_configured as meta_credentials_configured, + facebook_token_blob, + fetch_pages, + get_app_credentials as get_meta_app_credentials, + instagram_token_blob, + redirect_uri as meta_redirect_uri, ) from social.ollama import OllamaError, generate_social_post from social.tasks import publish_social_post +_META_DOCS = ( + "https://developers.facebook.com/documentation/instagram-platform/" + "instagram-api-with-facebook-login/business-login-for-instagram" +) + CONNECT_INSTRUCTIONS = { Platform.FACEBOOK: { "title": "Connect Facebook Page", + "oauth": True, + "oauth_platform": "facebook", + "client_id_label": "Meta App ID", + "client_secret_label": "Meta App Secret", + "connect_label": "Connect with Facebook", + "save_action": "save_meta_app", + "callback_label": ( + "Valid OAuth Redirect URI — paste into Meta → Facebook Login for Business → Settings" + ), "steps": [ mark_safe( - 'Go to Meta developer registration ' - "(opens in a new tab) and fill out the information." + 'In Meta for Developers, use a Business-type app and add ' + "Facebook Login for Business plus Instagram " + "(API setup with Facebook login)." ), - "Open Meta for Developers → your app → Tools → Graph API Explorer.", - "Select your app, then Get Page Access Token for the Page you manage.", - "Grant pages_manage_posts and pages_read_engagement (and pages_show_list).", - "Copy the Page access token and the numeric Page ID.", - "Paste both below. Tokens are encrypted at rest.", - ], - "docs_url": "https://developers.facebook.com/docs/pages/access-tokens/", - "fields": [ - ("label", "Display name", "Monica Dhillon · EXIT"), - ("external_id", "Page ID", "1029384756"), - ("access_token", "Page access token", ""), + "Under Facebook Login for Business → Settings → Valid OAuth Redirect URIs, " + "paste the callback URL shown below (exact match required).", + "Copy Meta App ID and App Secret into the fields below, then Save app credentials.", + "Click Connect with Facebook — grant Page permissions; we store the Page " + "access token for posting.", ], + "docs_url": _META_DOCS, + "fields": [], }, Platform.INSTAGRAM: { - "title": "Connect Instagram Business", + "title": "Connect Instagram", + "oauth": True, + "oauth_platform": "instagram", + "client_id_label": "Meta App ID", + "client_secret_label": "Meta App Secret", + "connect_label": "Connect with Instagram", + "save_action": "save_meta_app", + "callback_label": ( + "Valid OAuth Redirect URI — paste into Meta → Facebook Login for Business → Settings" + ), "steps": [ - "Instagram publishing uses a Facebook Page linked to an IG professional account.", - "In Meta Business Suite, confirm the IG account is connected to your Page.", - "From Graph API Explorer, get a Page token that can manage the linked IG account.", - "Use the Instagram Business Account ID (not the username) as External ID.", - "Paste Page access token + IG business account ID below.", - ], - "docs_url": "https://developers.facebook.com/docs/instagram-api/getting-started/", - "fields": [ - ("label", "Display name", "@mkdrealtor"), - ("external_id", "IG business account ID", ""), - ("access_token", "Page access token", ""), - ("page_id", "Facebook Page ID (optional)", ""), + mark_safe( + 'Follow Facebook Login for Business ' + "for Instagram — Professional IG account linked to a Facebook Page." + ), + "Same Meta App ID / Secret as Facebook (shared). Save credentials once.", + "Add the redirect URI below to Valid OAuth Redirect URIs, then Connect with Instagram.", + "We request Page + Instagram publish scopes and save the linked Instagram " + "Business account using the Page access token.", ], + "docs_url": _META_DOCS, + "fields": [], }, Platform.LINKEDIN: { "title": "Connect LinkedIn", "oauth": True, + "oauth_platform": "linkedin", + "client_id_label": "Client ID", + "client_secret_label": "Primary Client Secret", + "connect_label": "Connect with LinkedIn", + "save_action": "save_linkedin_app", + "callback_label": "Authorized redirect URL — paste into LinkedIn Auth tab", "steps": [ mark_safe( 'Create a LinkedIn app in the ' @@ -132,56 +177,65 @@ def account_list(request): account.save(update_fields=["is_active", "updated_at"]) messages.success(request, f"Reactivated {account.label}.") return redirect("social:account_list") - if action == "save_linkedin_app": + if action in {"save_linkedin_app", "save_meta_app", "save_instagram_app"}: + if action == "save_linkedin_app": + platform = Platform.LINKEDIN + connect_platform_name = "linkedin" + label = "LinkedIn" + else: + # Meta App ID/Secret shared by Facebook + Instagram (Facebook Login for Business). + platform = Platform.FACEBOOK + connect_return = (request.POST.get("connect_return") or "").strip() + if connect_return not in {Platform.FACEBOOK, Platform.INSTAGRAM}: + connect_return = Platform.FACEBOOK + connect_platform_name = connect_return + label = "Meta" + connect_q = f"?connect={connect_platform_name}" client_id = (request.POST.get("client_id") or "").strip() client_secret = (request.POST.get("client_secret") or "").strip() if not client_id: - messages.error(request, "Client ID is required.") - return redirect(f"{request.path}?connect=linkedin") - app, _created = SocialAppCredentials.objects.get_or_create( - platform=Platform.LINKEDIN - ) + messages.error(request, "App ID / Client ID is required.") + return redirect(f"{request.path}{connect_q}") + app, _created = SocialAppCredentials.objects.get_or_create(platform=platform) app.client_id = client_id if client_secret: try: app.encrypted_client_secret = encrypt_tokens(client_secret) except SocialCryptoError as exc: messages.error(request, str(exc)) - return redirect(f"{request.path}?connect=linkedin") + return redirect(f"{request.path}{connect_q}") elif not app.encrypted_client_secret: messages.error( request, - "Client Secret is required the first time you save LinkedIn app credentials.", + "App Secret is required the first time you save credentials.", ) - return redirect(f"{request.path}?connect=linkedin") + return redirect(f"{request.path}{connect_q}") app.save() messages.success( request, - "LinkedIn app credentials saved. You can Connect with LinkedIn now.", + f"{label} app credentials saved. You can Connect now.", ) - return redirect(f"{request.path}?connect=linkedin") + return redirect(f"{request.path}{connect_q}") platform = (request.POST.get("platform") or "").strip() if platform not in Platform.values: messages.error(request, "Choose a platform.") return redirect("social:account_list") + # Manual token paste remains as fallback for non-OAuth platforms only. + if platform in {Platform.FACEBOOK, Platform.INSTAGRAM, Platform.LINKEDIN}: + messages.error( + request, + "Use Connect with OAuth for this platform (Save app credentials first).", + ) + return redirect(f"{request.path}?connect={platform}") label = (request.POST.get("label") or "").strip() or platform.title() external_id = (request.POST.get("external_id") or "").strip() access_token = (request.POST.get("access_token") or "").strip() - page_id = (request.POST.get("page_id") or "").strip() if not access_token or not external_id: messages.error(request, "Access token and external ID are required.") return redirect(f"{request.path}?connect={platform}") token_blob = {"access_token": access_token} - if platform == Platform.FACEBOOK: - token_blob["page_id"] = external_id - elif platform == Platform.INSTAGRAM: - if page_id: - token_blob["page_id"] = page_id - elif platform == Platform.LINKEDIN: - token_blob["author_urn"] = external_id - try: encrypted = encrypt_tokens(json.dumps(token_blob)) except SocialCryptoError as exc: @@ -202,13 +256,48 @@ def account_list(request): messages.success(request, f"{verb} {account.get_platform_display()} · {account.label}.") return redirect("social:account_list") - linkedin_app = get_app_credentials() - linkedin_oauth_ready = credentials_configured() + linkedin_app = get_linkedin_app_credentials() + meta_app = get_meta_app_credentials() linkedin_callback = "" + meta_callback = "" try: linkedin_callback = linkedin_redirect_uri() except LinkedInOAuthError: linkedin_callback = "(set PUBLIC_SITE_URL, then restart)" + try: + meta_callback = meta_redirect_uri() + except MetaOAuthError: + meta_callback = "(set PUBLIC_SITE_URL, then restart)" + + meta_ready = meta_credentials_configured() + meta_client_id = meta_app.client_id if meta_app else "" + meta_secret_saved = bool(meta_app and meta_app.encrypted_client_secret) + oauth_ctx = { + "linkedin": { + "ready": linkedin_credentials_configured(), + "callback_url": linkedin_callback, + "client_id": (linkedin_app.client_id if linkedin_app else ""), + "secret_saved": bool( + linkedin_app and linkedin_app.encrypted_client_secret + ), + "start_url_name": "social:linkedin_oauth_start", + }, + "facebook": { + "ready": meta_ready, + "callback_url": meta_callback, + "client_id": meta_client_id, + "secret_saved": meta_secret_saved, + "start_url_name": "social:facebook_oauth_start", + }, + "instagram": { + "ready": meta_ready, + "callback_url": meta_callback, + "client_id": meta_client_id, + "secret_saved": meta_secret_saved, + "start_url_name": "social:instagram_oauth_start", + }, + } + oauth_state = oauth_ctx.get(connect_platform) or {} return render( request, @@ -218,12 +307,11 @@ def account_list(request): "connect_platform": connect_platform, "connect_meta": CONNECT_INSTRUCTIONS.get(connect_platform), "platforms": Platform, - "linkedin_oauth_ready": linkedin_oauth_ready, - "linkedin_callback_url": linkedin_callback, - "linkedin_client_id": (linkedin_app.client_id if linkedin_app else ""), - "linkedin_secret_saved": bool( - linkedin_app and linkedin_app.encrypted_client_secret - ), + "oauth_ready": oauth_state.get("ready", False), + "oauth_callback_url": oauth_state.get("callback_url", ""), + "oauth_client_id": oauth_state.get("client_id", ""), + "oauth_secret_saved": oauth_state.get("secret_saved", False), + "oauth_start_url_name": oauth_state.get("start_url_name", ""), }, ) @@ -232,7 +320,7 @@ def account_list(request): @require_GET def linkedin_oauth_start(request): """Redirect browser to LinkedIn consent screen.""" - if not credentials_configured(): + if not linkedin_credentials_configured(): messages.error( request, "Save LinkedIn Client ID and Client Secret on the Connect LinkedIn form first.", @@ -241,7 +329,7 @@ def linkedin_oauth_start(request): state = secrets.token_urlsafe(24) request.session["linkedin_oauth_state"] = state try: - return redirect(authorization_url(state=state)) + return redirect(linkedin_authorization_url(state=state)) except LinkedInOAuthError as exc: messages.error(request, str(exc)) return redirect(f"{reverse('social:account_list')}?connect=linkedin") @@ -269,10 +357,10 @@ def linkedin_oauth_callback(request): return redirect("social:account_list") try: - token_payload = exchange_code(code) - profile = fetch_member_profile(token_payload["access_token"]) + token_payload = linkedin_exchange_code(code) + profile = linkedin_fetch_member_profile(token_payload["access_token"]) author_urn = profile["author_urn"] - blob = token_blob_from_oauth(token_payload, author_urn=author_urn) + blob = linkedin_token_blob_from_oauth(token_payload, author_urn=author_urn) account, created = SocialAccount.objects.update_or_create( platform=Platform.LINKEDIN, external_id=author_urn, @@ -301,6 +389,134 @@ def linkedin_oauth_callback(request): return redirect("social:account_list") +@login_required +@require_GET +def facebook_oauth_start(request): + """Start Facebook Login for Business; save Facebook Page account on complete.""" + return _meta_oauth_start(request, intent=Platform.FACEBOOK) + + +@login_required +@require_GET +def instagram_oauth_start(request): + """Start Facebook Login for Business; save linked Instagram Business account.""" + return _meta_oauth_start(request, intent=Platform.INSTAGRAM) + + +def _meta_oauth_start(request, *, intent: str): + connect_q = f"?connect={intent}" + if not meta_credentials_configured(): + messages.error( + request, + "Save Meta App ID and App Secret on the Connect form first.", + ) + return redirect(f"{reverse('social:account_list')}{connect_q}") + state = secrets.token_urlsafe(24) + request.session["meta_oauth_state"] = state + request.session["meta_oauth_intent"] = intent + try: + return redirect(meta_authorization_url(state=state)) + except MetaOAuthError as exc: + messages.error(request, str(exc)) + return redirect(f"{reverse('social:account_list')}{connect_q}") + + +@login_required +@require_GET +def meta_oauth_callback(request): + """ + Landing page after Facebook redirect. + + Tokens arrive in the URL fragment (#) per Meta docs (response_type=token), + so JS reads them and POSTs to meta_oauth_complete. + """ + return render(request, "social/meta_oauth_callback.html") + + +@login_required +@require_POST +def meta_oauth_complete(request): + """Persist Facebook Page and/or Instagram accounts from Facebook Login tokens.""" + intent = request.session.pop("meta_oauth_intent", Platform.FACEBOOK) + connect_q = f"?connect={intent}" + + error = (request.POST.get("error") or "").strip() + if error: + desc = (request.POST.get("error_description") or error).strip() + messages.error(request, f"Facebook authorization denied: {desc}") + return redirect(f"{reverse('social:account_list')}{connect_q}") + + state = (request.POST.get("state") or "").strip() + expected = request.session.pop("meta_oauth_state", None) + if not state or not expected or state != expected: + messages.error(request, "Meta OAuth state mismatch — try Connect again.") + return redirect(f"{reverse('social:account_list')}{connect_q}") + + user_token = ( + (request.POST.get("long_lived_token") or "").strip() + or (request.POST.get("access_token") or "").strip() + ) + if not user_token: + messages.error(request, "Facebook did not return an access token.") + return redirect(f"{reverse('social:account_list')}{connect_q}") + + try: + pages = fetch_pages(user_token) + connected = [] + + if intent == Platform.INSTAGRAM: + ig_pages = [p for p in pages if (p.get("instagram_business_account") or {}).get("id")] + if not ig_pages: + raise MetaOAuthError( + "No Instagram Professional account linked to your Facebook Pages. " + "Link IG in Meta Business Suite, then Connect again." + ) + for page in ig_pages: + blob = instagram_token_blob(page, user_token=user_token) + account, created = SocialAccount.objects.update_or_create( + platform=Platform.INSTAGRAM, + external_id=blob["ig_user_id"], + defaults={ + "label": blob["label"], + "encrypted_tokens": encrypt_tokens(json.dumps(blob)), + "is_active": True, + "owner": request.user, + }, + ) + connected.append(("Connected" if created else "Re-authorized", account)) + else: + for page in pages: + blob = facebook_token_blob(page, user_token=user_token) + label = (page.get("name") or "").strip() or f"Page {page['id']}" + account, created = SocialAccount.objects.update_or_create( + platform=Platform.FACEBOOK, + external_id=str(page["id"]), + defaults={ + "label": label, + "encrypted_tokens": encrypt_tokens(json.dumps(blob)), + "is_active": True, + "owner": request.user, + }, + ) + connected.append(("Connected" if created else "Re-authorized", account)) + except (MetaOAuthError, SocialCryptoError) as exc: + messages.error(request, str(exc)) + return redirect(f"{reverse('social:account_list')}{connect_q}") + except Exception: + messages.error( + request, + "Unexpected error saving Meta account. Check app logs.", + ) + raise + + for verb, account in connected: + messages.success( + request, + f"{verb} {account.get_platform_display()} · {account.label}.", + ) + return redirect("social:account_list") + + @login_required @require_http_methods(["GET", "POST"]) def composer(request): @@ -312,6 +528,7 @@ def composer(request): "publish_mode": "schedule", "scheduled_for": "", "account_ids": [], + "media_json": "[]", } error = "" draft = "" @@ -322,6 +539,7 @@ def composer(request): form["publish_mode"] = (request.POST.get("publish_mode") or "schedule").strip() form["scheduled_for"] = request.POST.get("scheduled_for") or "" form["account_ids"] = request.POST.getlist("account_ids") + form["media_json"] = (request.POST.get("media_json") or "[]").strip() or "[]" action = (request.POST.get("action") or "save").strip() if action == "generate" and form["prompt"]: @@ -331,9 +549,15 @@ def composer(request): except OllamaError as exc: error = str(exc) elif action in {"save", "publish"}: - if not form["body"]: - error = "Caption / body is required." - elif form["publish_mode"] != "draft" and not form["account_ids"]: + media_items: list = [] + try: + media_items = parse_media_json(form["media_json"]) + except ValueError as exc: + error = str(exc) + + if not error and not form["body"] and not media_items: + error = "Add a caption and/or attach an image or video." + elif not error and form["publish_mode"] != "draft" and not form["account_ids"]: error = "Select at least one connected account." else: scheduled_for = None @@ -362,9 +586,19 @@ def composer(request): ) if form["publish_mode"] != "draft" and not selected.exists(): error = "No valid accounts selected." + elif ( + form["publish_mode"] != "draft" + and selected.filter(platform=Platform.INSTAGRAM).exists() + and not media_items + ): + error = ( + "Instagram requires an image or video. " + "Attach media before publishing to Instagram." + ) else: post = SocialPost.objects.create( body=form["body"], + media=media_items, ollama_prompt=form["prompt"], scheduled_for=scheduled_for, status=status, @@ -399,10 +633,68 @@ def composer(request): "form": form, "error": error, "draft": draft or form["body"], + "media_upload_url": reverse("social:media_upload"), }, ) +@login_required +@require_POST +def media_upload(request): + """Upload image/video for social compose; store bytes in StoredFile.""" + upload = ( + request.FILES.get("media") + or request.FILES.get("file") + or request.FILES.get("image") + or request.FILES.get("video") + ) + if not upload: + return JsonResponse({"error": "No file uploaded."}, status=400) + + content_type = (getattr(upload, "content_type", None) or "").lower() + if content_type in ALLOWED_IMAGE_TYPES: + kind = StoredFile.Kind.SOCIAL_IMAGE + max_bytes = MAX_IMAGE_BYTES + label = "Image" + elif content_type in ALLOWED_VIDEO_TYPES: + kind = StoredFile.Kind.SOCIAL_VIDEO + max_bytes = MAX_VIDEO_BYTES + label = "Video" + else: + return JsonResponse( + { + "error": "Use JPEG/PNG/GIF/WebP images or MP4/MOV/WebM video.", + }, + status=400, + ) + + size = int(getattr(upload, "size", 0) or 0) + if size and size > max_bytes: + return JsonResponse( + {"error": f"{label} must be {max_bytes // (1024 * 1024)} MB or smaller."}, + status=400, + ) + + data = upload.read() + if len(data) > max_bytes: + return JsonResponse( + {"error": f"{label} must be {max_bytes // (1024 * 1024)} MB or smaller."}, + status=400, + ) + + original = (getattr(upload, "name", None) or "media")[:255] + stored = StoredFile.objects.create( + kind=kind, + filename=original, + content_type=content_type, + size=len(data), + data=data, + uploaded_by=request.user if request.user.is_authenticated else None, + ) + item = media_item_from_stored(stored) + return JsonResponse(item) + + @login_required @require_POST def api_generate(request):