# social/services/meta.py - FIXED VERSION import requests import time import hmac import hashlib import logging import datetime from urllib.parse import urlencode from django.conf import settings from django.utils import timezone from apps.social.utils.meta import BASE_GRAPH_URL, META_SCOPES, BASE_AUTH_URL logger = logging.getLogger(__name__) class MetaAPIError(Exception): pass class MetaService: # --- AUTHENTICATION --- @staticmethod def get_auth_url(): params = { "client_id": settings.META_APP_ID, "redirect_uri": settings.META_REDIRECT_URI, "scope": ",".join(META_SCOPES), "response_type": "code", } return f"{BASE_AUTH_URL}/dialog/oauth?{urlencode(params)}" @staticmethod def exchange_code_for_tokens(code): """Exchanges code for a long-lived User Access Token""" # Step 1: Get short-lived token res = requests.post(f"{BASE_GRAPH_URL}/oauth/access_token", data={ "client_id": settings.META_APP_ID, "client_secret": settings.META_APP_SECRET, "code": code, "redirect_uri": settings.META_REDIRECT_URI, }) data = MetaService._handle_api_response(res) # Step 2: Exchange for long-lived token long_res = requests.post(f"{BASE_GRAPH_URL}/oauth/access_token", data={ "grant_type": "fb_exchange_token", "client_id": settings.META_APP_ID, "client_secret": settings.META_APP_SECRET, "fb_exchange_token": data['access_token'] }) long_data = MetaService._handle_api_response(long_res) expires_in = long_data.get('expires_in', 5184000) return { "access_token": long_data['access_token'], "expires_at": timezone.now() + datetime.timedelta(seconds=expires_in) } # --- API HELPER --- @staticmethod def _handle_api_response(response): """Handle API response with proper error checking and rate limit handling""" try: data = response.json() except: raise MetaAPIError(f"Invalid JSON response: {response.text}") if 'error' in data: error_code = data['error'].get('code') error_msg = data['error'].get('message', 'Unknown error') # Handle rate limits if error_code in [4, 17, 32]: logger.warning(f"Rate limit hit (code {error_code}). Waiting 60 seconds...") time.sleep(60) raise MetaAPIError(f"Rate limited: {error_msg}") # Handle permission errors if error_code in [200, 190, 102]: raise MetaAPIError(f"Permission error: {error_msg}") raise MetaAPIError(f"API Error (code {error_code}): {error_msg}") return data # --- DISCOVERY --- @staticmethod def discover_pages_and_ig(user_access_token): """ Returns a list of manageable entities (FB Pages & IG Business Accounts). Each dict contains: platform ('FB'|'IG'), native_id, name, access_token """ entities = [] next_page = None while True: params = { "access_token": user_access_token, "fields": "id,name,access_token,instagram_business_account{id,username}", "limit": 100 } if next_page: params['after'] = next_page try: res = requests.get(f"{BASE_GRAPH_URL}/me/accounts", params=params) data = MetaService._handle_api_response(res) for page in data.get('data', []): # 1. Add Facebook Page entities.append({ 'platform': 'FB', 'native_id': page['id'], 'name': page['name'], 'access_token': page['access_token'], 'is_permanent': True # Page tokens don't expire if app is active }) # 2. Add Linked Instagram Business Account (if exists) ig_data = page.get('instagram_business_account') if ig_data: entities.append({ 'platform': 'IG', 'native_id': ig_data['id'], 'name': f"IG: {ig_data.get('username', page['name'])}", 'access_token': page['access_token'], 'is_permanent': True, 'parent_page_id': page['id'] }) next_page = data.get('paging', {}).get('cursors', {}).get('after') if not next_page: break except MetaAPIError as e: logger.error(f"Discovery Error: {e}") break except Exception as e: logger.error(f"Discovery Exception: {e}") break return entities # --- DATA FETCHING --- @staticmethod def fetch_posts(entity_id, access_token, platform_type): """ Fetches posts from a specific FB Page or IG Account. """ posts = [] next_page = None # Determine endpoint and fields based on platform if platform_type == "FB": if entity_id == 'me': endpoint = f"{entity_id}/feed" else: endpoint = f"{entity_id}/feed" fields = "id,message,created_time,permalink_url" else: # Instagram endpoint = f"{entity_id}/media" fields = "id,caption,timestamp,permalink,media_type,media_url,thumbnail_url" while True: params = { "access_token": access_token, "limit": 25, "fields": fields } if next_page: params['after'] = next_page try: res = requests.get(f"{BASE_GRAPH_URL}/{endpoint}", params=params) res_json = res.json() if 'error' in res_json: error_msg = res_json.get('error', {}).get('message', 'Unknown error') logger.warning(f"API Error fetching posts for {entity_id}: {error_msg}") break posts_data = res_json.get('data', []) posts.extend(posts_data) paging = res_json.get('paging', {}) next_page = paging.get('cursors', {}).get('after') if not next_page: break time.sleep(0.5) # Rate limiting except requests.exceptions.RequestException as e: logger.error(f"Network error fetching posts for {entity_id}: {e}") break except Exception as e: logger.error(f"Exception fetching posts for {entity_id}: {e}", exc_info=True) break logger.info(f"Fetched total of {len(posts)} posts for {entity_id}") return posts @staticmethod def fetch_comments_for_post(post_id, access_token, since_timestamp=None): """ Fetches comments for a specific post (works for both FB and IG). FIXED: Dynamically selects fields based on platform detection to avoid Error #100 (nonexisting field 'name' on IGCommentFromUser). """ url = f"{BASE_GRAPH_URL}/{post_id}/comments" comments = [] next_page = None # --- Platform Detection --- # Instagram IDs typically start with 17 or 18. str_post_id = str(post_id) is_instagram = str_post_id.startswith('17') or str_post_id.startswith('18') # --- Field Selection --- if is_instagram: # IG: Use 'username' if available, but NEVER 'name' on the user object # Note: 'username' is usually available on IGCommentFromUser request_fields = "id,from{id,username},message,text,created_time,post,like_count,comment_count,attachment" else: # FB: 'name' is standard request_fields = "id,from{id,name},message,text,created_time,post,like_count,comment_count,attachment" while True: params = { "access_token": access_token, "limit": 50, "fields": request_fields, # Use the selected fields "order": "reverse_chronological" } if since_timestamp: if isinstance(since_timestamp, datetime.datetime): since_timestamp = int(since_timestamp.timestamp()) params['since'] = since_timestamp if next_page: params['after'] = next_page try: res = requests.get(url, params=params) data = MetaService._handle_api_response(res) new_comments = data.get('data', []) if not new_comments: break comments.extend(new_comments) next_page = data.get('paging', {}).get('cursors', {}).get('after') if not next_page: break time.sleep(0.5) # Rate limiting except MetaAPIError as e: logger.warning(f"Error fetching comments for {post_id}: {e}") break except Exception as e: logger.error(f"Exception fetching comments for {e}") break return comments @staticmethod def fetch_single_comment(comment_id, access_token): """Fetch a single comment by ID (works for both FB and IG)""" url = f"{BASE_GRAPH_URL}/{comment_id}" # Safe fallback fields usually work for both, but IG might reject 'name' # We'll default to username for safety if it looks like IG str_id = str(comment_id) if str_id.startswith('17') or str_id.startswith('18'): fields = "id,from{id,username},message,text,created_time,post,like_count,attachment" else: fields = "id,from{id,name},message,text,created_time,post,like_count,attachment" params = { "fields": fields, "access_token": access_token } res = requests.get(url, params=params) data = MetaService._handle_api_response(res) return data # @staticmethod # def post_reply(comment_id, access_token, text): # """ # Post a reply to a comment (works for both FB and IG). # """ # url = f"{BASE_GRAPH_URL}/{comment_id}/comments" # try: # res = requests.post( # url, # params={"access_token": access_token}, # json={"message": text} # ) # data = MetaService._handle_api_response(res) # # Graceful handling for Error 100 (Unsupported operation) # error = data.get('error', {}) # if error and error.get('code') == 100 and "Unsupported" in error.get('message', ''): # logger.warning(f"Reply failed for {comment_id}: Comment might be deleted, private, or restricted.") # return data # return data # except MetaAPIError as e: # raise MetaAPIError(f"Reply failed: {str(e)}") # except requests.exceptions.RequestException as e: # raise MetaAPIError(f"Network error posting reply: {str(e)}") @staticmethod def post_reply(comment_id, access_token, platform='FB', text=None): """ Post a reply to a comment (Handle FB vs IG endpoints). Args: platform (str): 'facebook' or 'instagram' (default: 'facebook') """ # STEP 1: Choose the correct endpoint if platform.lower() == 'ig': # Instagram requires /replies endpoint for comments url = f"{BASE_GRAPH_URL}/{comment_id}/replies" else: # Facebook treats replies as 'comments on a comment' url = f"{BASE_GRAPH_URL}/{comment_id}/comments" try: res = requests.post( url, params={"access_token": access_token}, json={"message": text} ) data = MetaService._handle_api_response(res) return data except MetaAPIError as e: # Check for Error 100 (Unsupported operation) # This often happens if you try to reply to an IG comment that is ALREADY a reply # (Instagram only supports 1 level of nesting) error_code = e.args[0] if isinstance(e, tuple) and len(e.args) > 0 else None if error_code == 100 or "Unsupported" in str(e): logger.warning(f"Reply failed for {comment_id} ({platform}): Object might be deleted, restricted, or you are trying to reply to a reply (nested) which IG blocks.") raise e # Re-raise other errors raise e except requests.exceptions.RequestException as e: raise MetaAPIError(f"Network error posting reply: {str(e)}") @staticmethod def subscribe_webhook(page_id, access_token): """ Subscribes a specific page to the app's webhook. """ url = f"{BASE_GRAPH_URL}/{page_id}/subscribed_apps" res = requests.post( url, json={ "access_token": access_token, "subscribed_fields": ["comments", "feed"] } ) data = MetaService._handle_api_response(res) return True # --- WEBHOOK UTILS --- @staticmethod def verify_webhook_signature(received_signature, body_raw, client_secret): """Verify webhook signature from Meta""" if not received_signature or not body_raw: return False calculated_digest = hmac.new( client_secret.encode('utf-8'), body_raw, hashlib.sha256 ).hexdigest() expected_signature = f"sha256={calculated_digest}" return hmac.compare_digest(received_signature, expected_signature) # --- HELPER METHODS --- @staticmethod def detect_source_platform(comment_id, post_id=None): """ Reliably detect if comment is from FB or IG based on ID format. """ if comment_id and comment_id.startswith('17') and comment_id.isdigit(): return 'IG' elif comment_id and '_' in comment_id: return 'FB' elif post_id: # Fallback: Check post ID format if str(post_id).startswith('17') and str(post_id).isdigit(): return 'IG' return 'FB' # Default to Facebook