2026-02-12 15:09:48 +03:00

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