418 lines
15 KiB
Python
418 lines
15 KiB
Python
# 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 |