320 lines
13 KiB
Python
320 lines
13 KiB
Python
# pxy_meta_pages/admin.py
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from typing import Optional, Dict, Any
|
||
|
||
import requests
|
||
from django.conf import settings
|
||
from django.contrib import admin, messages
|
||
|
||
from .models import FacebookPageAssistant, EventType, BotInteraction
|
||
from .services import FacebookService
|
||
|
||
|
||
# -----------------------------------------------------------------------------
|
||
# Config
|
||
# -----------------------------------------------------------------------------
|
||
FACEBOOK_API_VERSION: str = getattr(settings, "FACEBOOK_API_VERSION", "v22.0")
|
||
APP_ID: Optional[str] = getattr(settings, "FACEBOOK_APP_ID", None)
|
||
|
||
# Fields we require on every Page subscription (Page Feed + Messenger)
|
||
REQUIRED_FIELDS = [
|
||
# Page feed (comments/shares/mentions)
|
||
"feed",
|
||
"mention",
|
||
# Messenger
|
||
"messages",
|
||
"messaging_postbacks",
|
||
"message_reads",
|
||
"message_deliveries",
|
||
"message_reactions",
|
||
"message_echoes",
|
||
]
|
||
|
||
|
||
# -----------------------------------------------------------------------------
|
||
# Small Graph helpers with consistent error handling
|
||
# -----------------------------------------------------------------------------
|
||
def _graph_get(url: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""GET wrapper that raises RequestException on Graph errors."""
|
||
resp = requests.get(url, params=params, timeout=15)
|
||
data = resp.json() if resp.content else {}
|
||
if isinstance(data, dict) and "error" in data:
|
||
# Normalize to RequestException so callers can unify handling
|
||
raise requests.RequestException(json.dumps(data["error"]))
|
||
resp.raise_for_status()
|
||
return data or {}
|
||
|
||
|
||
def _graph_post(url: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""POST wrapper that raises RequestException on Graph errors."""
|
||
resp = requests.post(url, data=data, timeout=15)
|
||
payload = resp.json() if resp.content else {}
|
||
if isinstance(payload, dict) and "error" in payload:
|
||
raise requests.RequestException(json.dumps(payload["error"]))
|
||
resp.raise_for_status()
|
||
return payload or {}
|
||
|
||
|
||
def _decode_graph_error(e: requests.RequestException) -> str:
|
||
"""
|
||
Attempt to pretty-print a Graph API error dict, else return the raw message.
|
||
"""
|
||
msg = str(e)
|
||
try:
|
||
err = json.loads(msg)
|
||
# Typical Graph error shape
|
||
code = err.get("code")
|
||
sub = err.get("error_subcode")
|
||
text = err.get("message", "Graph error")
|
||
return f"Graph error (code={code}, subcode={sub}): {text}"
|
||
except Exception:
|
||
return msg
|
||
|
||
|
||
def _get_page_token(fb_service: FacebookService, page_id: str) -> Optional[str]:
|
||
"""
|
||
Works with either a public get_page_access_token or the private _get_page_access_token.
|
||
"""
|
||
getter = getattr(fb_service, "get_page_access_token", None)
|
||
if callable(getter):
|
||
return getter(page_id)
|
||
private_getter = getattr(fb_service, "_get_page_access_token", None)
|
||
if callable(private_getter):
|
||
return private_getter(page_id)
|
||
return None
|
||
|
||
|
||
# -----------------------------------------------------------------------------
|
||
# Admins
|
||
# -----------------------------------------------------------------------------
|
||
@admin.register(FacebookPageAssistant)
|
||
class FacebookPageAssistantAdmin(admin.ModelAdmin):
|
||
"""
|
||
Admin for wiring a Facebook Page to an OpenAI assistant and managing webhook subscriptions.
|
||
"""
|
||
list_display = (
|
||
"page_name",
|
||
"page_id",
|
||
"assistant",
|
||
"is_subscribed",
|
||
"created_at",
|
||
"comment_count",
|
||
"share_count",
|
||
)
|
||
search_fields = ("page_name", "page_id", "assistant__name")
|
||
list_filter = ("is_subscribed", "assistant")
|
||
|
||
actions = [
|
||
"ensure_feed_and_messenger_subscription",
|
||
"check_subscription_status",
|
||
"probe_messenger_access",
|
||
]
|
||
|
||
# ----- Counters ----------------------------------------------------------
|
||
def comment_count(self, obj: FacebookPageAssistant) -> int:
|
||
return obj.events.filter(event_type__code="comment").count()
|
||
|
||
comment_count.short_description = "Comments"
|
||
|
||
def share_count(self, obj: FacebookPageAssistant) -> int:
|
||
return obj.events.filter(event_type__code="share").count()
|
||
|
||
share_count.short_description = "Shares"
|
||
|
||
# ----- Action 1: Ensure required fields (feed + Messenger) --------------
|
||
def ensure_feed_and_messenger_subscription(self, request, queryset):
|
||
"""
|
||
For each selected Page:
|
||
1) Fetch the Page Access Token via FacebookService.
|
||
2) Read current subscribed_fields.
|
||
3) Add any missing REQUIRED_FIELDS in a single POST.
|
||
"""
|
||
fb_service = FacebookService(user_access_token=settings.PAGE_ACCESS_TOKEN)
|
||
|
||
for page in queryset:
|
||
try:
|
||
page_access_token = _get_page_token(fb_service, page.page_id)
|
||
if not page_access_token:
|
||
self.message_user(
|
||
request,
|
||
f"[{page.page_name}] Unable to obtain Page Access Token.",
|
||
level=messages.ERROR,
|
||
)
|
||
continue
|
||
|
||
list_url = f"https://graph.facebook.com/{FACEBOOK_API_VERSION}/{page.page_id}/subscribed_apps"
|
||
current_data = _graph_get(list_url, {"access_token": page_access_token})
|
||
entries = current_data.get("data", [])
|
||
|
||
# If APP_ID is known, narrow to our app row; otherwise use first row if present
|
||
app_entry = None
|
||
if APP_ID:
|
||
app_entry = next((e for e in entries if str(e.get("id")) == str(APP_ID)), None)
|
||
if app_entry is None and entries:
|
||
app_entry = entries[0]
|
||
|
||
current_fields = set(app_entry.get("subscribed_fields", [])) if app_entry else set()
|
||
required = set(REQUIRED_FIELDS)
|
||
|
||
if required - current_fields:
|
||
new_fields_csv = ",".join(sorted(current_fields | required))
|
||
_graph_post(list_url, {
|
||
"subscribed_fields": new_fields_csv,
|
||
"access_token": page_access_token,
|
||
})
|
||
page.is_subscribed = True
|
||
page.save(update_fields=["is_subscribed"])
|
||
self.message_user(
|
||
request,
|
||
f"[{page.page_name}] Subscribed/updated. Now includes: {new_fields_csv}",
|
||
level=messages.SUCCESS,
|
||
)
|
||
else:
|
||
page.is_subscribed = True
|
||
page.save(update_fields=["is_subscribed"])
|
||
self.message_user(
|
||
request,
|
||
f"[{page.page_name}] Already has required fields.",
|
||
level=messages.INFO,
|
||
)
|
||
|
||
except requests.RequestException as e:
|
||
self.message_user(
|
||
request,
|
||
f"[{page.page_name}] {_decode_graph_error(e)}",
|
||
level=messages.ERROR,
|
||
)
|
||
except Exception as e:
|
||
self.message_user(
|
||
request,
|
||
f"[{page.page_name}] Unexpected error: {e}",
|
||
level=messages.ERROR,
|
||
)
|
||
|
||
ensure_feed_and_messenger_subscription.short_description = "Ensure Webhooks (feed + Messenger)"
|
||
|
||
# ----- Action 2: Check subscription status ------------------------------
|
||
def check_subscription_status(self, request, queryset):
|
||
"""
|
||
Shows the exact subscribed_fields currently active for each Page.
|
||
"""
|
||
fb_service = FacebookService(user_access_token=settings.PAGE_ACCESS_TOKEN)
|
||
|
||
for page in queryset:
|
||
try:
|
||
page_access_token = _get_page_token(fb_service, page.page_id)
|
||
if not page_access_token:
|
||
self.message_user(
|
||
request,
|
||
f"[{page.page_name}] Unable to obtain Page Access Token.",
|
||
level=messages.ERROR,
|
||
)
|
||
continue
|
||
|
||
url = f"https://graph.facebook.com/{FACEBOOK_API_VERSION}/{page.page_id}/subscribed_apps"
|
||
data = _graph_get(url, {"access_token": page_access_token})
|
||
entries = data.get("data", [])
|
||
|
||
app_entry = None
|
||
if APP_ID:
|
||
app_entry = next((e for e in entries if str(e.get("id")) == str(APP_ID)), None)
|
||
if app_entry is None and entries:
|
||
app_entry = entries[0]
|
||
|
||
fields = app_entry.get("subscribed_fields", []) if app_entry else []
|
||
page.is_subscribed = bool(fields)
|
||
page.save(update_fields=["is_subscribed"])
|
||
|
||
has_all = set(REQUIRED_FIELDS).issubset(set(fields))
|
||
level = messages.SUCCESS if has_all else messages.WARNING
|
||
self.message_user(
|
||
request,
|
||
f"[{page.page_name}] Subscribed fields: {', '.join(fields) if fields else '(none)'}",
|
||
level=level,
|
||
)
|
||
|
||
except requests.RequestException as e:
|
||
self.message_user(
|
||
request,
|
||
f"[{page.page_name}] {_decode_graph_error(e)}",
|
||
level=messages.ERROR,
|
||
)
|
||
except Exception as e:
|
||
self.message_user(
|
||
request,
|
||
f"[{page.page_name}] Unexpected error: {e}",
|
||
level=messages.ERROR,
|
||
)
|
||
|
||
check_subscription_status.short_description = "Check webhook subscription fields"
|
||
|
||
# ----- Action 3: Probe Messenger access ---------------------------------
|
||
def probe_messenger_access(self, request, queryset):
|
||
"""
|
||
Light probe for Messenger perms using /{PAGE_ID}/conversations.
|
||
(In Dev Mode, you’ll only see app-role users here.)
|
||
"""
|
||
fb_service = FacebookService(user_access_token=settings.PAGE_ACCESS_TOKEN)
|
||
|
||
for page in queryset:
|
||
try:
|
||
page_access_token = _get_page_token(fb_service, page.page_id)
|
||
if not page_access_token:
|
||
self.message_user(
|
||
request,
|
||
f"[{page.page_name}] Unable to obtain Page Access Token.",
|
||
level=messages.ERROR,
|
||
)
|
||
continue
|
||
|
||
url = f"https://graph.facebook.com/{FACEBOOK_API_VERSION}/{page.page_id}/conversations"
|
||
data = _graph_get(url, {"access_token": page_access_token, "limit": 1})
|
||
total = len(data.get("data", []))
|
||
self.message_user(
|
||
request,
|
||
(
|
||
f"[{page.page_name}] Messenger probe OK. Conversations sample: {total}. "
|
||
"Note: in Dev Mode you’ll only see app-role users."
|
||
),
|
||
level=messages.SUCCESS,
|
||
)
|
||
|
||
except requests.RequestException as e:
|
||
msg = _decode_graph_error(e)
|
||
# Add quick hints for common codes
|
||
hint = ""
|
||
if '"code": 190' in msg or "Invalid OAuth 2.0" in msg:
|
||
hint = " (Token invalid/expired)"
|
||
elif '"code": 200' in msg:
|
||
hint = " (Permissions error: check pages_messaging & pages_manage_metadata; app roles or Advanced Access)"
|
||
elif '"code": 10' in msg:
|
||
hint = " (Permission denied: user role or access level missing)"
|
||
self.message_user(
|
||
request,
|
||
f"[{page.page_name}] Messenger probe failed: {msg}{hint}",
|
||
level=messages.ERROR,
|
||
)
|
||
except Exception as e:
|
||
self.message_user(
|
||
request,
|
||
f"[{page.page_name}] Unexpected error: {e}",
|
||
level=messages.ERROR,
|
||
)
|
||
|
||
probe_messenger_access.short_description = "Probe Messenger access"
|
||
|
||
|
||
@admin.register(EventType)
|
||
class EventTypeAdmin(admin.ModelAdmin):
|
||
list_display = ("code", "label")
|
||
search_fields = ("code", "label")
|
||
|
||
|
||
@admin.register(BotInteraction)
|
||
class BotInteractionAdmin(admin.ModelAdmin):
|
||
list_display = ("page", "object_id", "parent_object_id", "platform", "created_at")
|
||
search_fields = ("object_id", "prompt", "bot_response")
|
||
list_filter = ("platform",)
|