diff --git a/.gitignore b/.gitignore index ae1cab2..4959ece 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ pxy_city_digital_twins/__backup__/ Dockerfile.dev docker-compose.override.yml docker-compose.override.yml +pxy_meta_pages.zip diff --git a/polisplexity/settings.py b/polisplexity/settings.py index 0c51a70..abcd4f6 100644 --- a/polisplexity/settings.py +++ b/polisplexity/settings.py @@ -51,6 +51,7 @@ INSTALLED_APPS = [ "pxy_dashboard.components", "pxy_dashboard.layouts", "pxy_building_digital_twins", + "pxy_messenger", # Third-party apps "crispy_forms", @@ -176,3 +177,8 @@ EMAIL_USE_SSL = True EMAIL_HOST_USER = "noreply@polisplexity.tech" # Cambia esto por tu correo real EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD") # Mejor usar .env DEFAULT_FROM_EMAIL = "Polisplexity " + +MESSENGER_VERIFY_TOKEN = os.getenv("MESSENGER_VERIFY_TOKEN", "dev-change-me") + +FACEBOOK_APP_SECRET = os.getenv("FACEBOOK_APP_SECRET", "") # set this in .env for prod + diff --git a/polisplexity/urls.py b/polisplexity/urls.py index 620fe36..f75aec6 100644 --- a/polisplexity/urls.py +++ b/polisplexity/urls.py @@ -37,6 +37,7 @@ urlpatterns = [ include(("pxy_building_digital_twins.urls", "pxy_building_digital_twins"), namespace="pxy_building_digital_twins"), ), + path("messenger/", include("pxy_messenger.urls")), ] diff --git a/pxy_dashboard/middleware.py b/pxy_dashboard/middleware.py index 3a5a66c..fea3342 100644 --- a/pxy_dashboard/middleware.py +++ b/pxy_dashboard/middleware.py @@ -44,6 +44,13 @@ EXEMPT_URLS += [ re.compile(r"^pxy_meta_pages/webhook/?$"), ] +# Webhook de Facebook Messenger +EXEMPT_URLS += [ + "messenger/webhook/", # exact string match (no leading slash) + re.compile(r"^messenger/webhook/?$"), # regex with optional trailing slash +] + + class LoginRequiredMiddleware(MiddlewareMixin): def process_request(self, request): if not request.user.is_authenticated: diff --git a/pxy_messenger/__init__.py b/pxy_messenger/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pxy_messenger/admin.py b/pxy_messenger/admin.py new file mode 100644 index 0000000..3d24468 --- /dev/null +++ b/pxy_messenger/admin.py @@ -0,0 +1,7 @@ +from django.contrib import admin +from .models import MessengerEvent + +@admin.register(MessengerEvent) +class MessengerEventAdmin(admin.ModelAdmin): + list_display = ("mid", "sender_id", "page_id", "created_at") + search_fields = ("mid", "sender_id", "page_id") diff --git a/pxy_messenger/apps.py b/pxy_messenger/apps.py new file mode 100644 index 0000000..478e2bd --- /dev/null +++ b/pxy_messenger/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PxyMessengerConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'pxy_messenger' diff --git a/pxy_messenger/migrations/0001_initial.py b/pxy_messenger/migrations/0001_initial.py new file mode 100644 index 0000000..e567f2a --- /dev/null +++ b/pxy_messenger/migrations/0001_initial.py @@ -0,0 +1,25 @@ +# Generated by Django 5.0.3 on 2025-09-06 07:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='MessengerEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('mid', models.CharField(db_index=True, max_length=255, unique=True)), + ('sender_id', models.CharField(max_length=64)), + ('page_id', models.CharField(blank=True, default='', max_length=64)), + ('payload', models.JSONField(blank=True, default=dict)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + ] diff --git a/pxy_messenger/migrations/__init__.py b/pxy_messenger/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pxy_messenger/models.py b/pxy_messenger/models.py new file mode 100644 index 0000000..e3fc8f9 --- /dev/null +++ b/pxy_messenger/models.py @@ -0,0 +1,11 @@ +from django.db import models + +class MessengerEvent(models.Model): + mid = models.CharField(max_length=255, unique=True, db_index=True) # message id + sender_id = models.CharField(max_length=64) + page_id = models.CharField(max_length=64, blank=True, default="") + payload = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.mid diff --git a/pxy_messenger/tests.py b/pxy_messenger/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/pxy_messenger/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/pxy_messenger/urls.py b/pxy_messenger/urls.py new file mode 100644 index 0000000..c4a6411 --- /dev/null +++ b/pxy_messenger/urls.py @@ -0,0 +1,6 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path("webhook/", views.webhook, name="messenger_webhook"), +] diff --git a/pxy_messenger/views.py b/pxy_messenger/views.py new file mode 100644 index 0000000..d12810c --- /dev/null +++ b/pxy_messenger/views.py @@ -0,0 +1,113 @@ +# pxy_messenger/views.py +import hashlib, hmac, json, logging, os +from django.conf import settings +from django.http import HttpResponse, HttpResponseForbidden +from django.views.decorators.csrf import csrf_exempt + +# Optional: if you created a model to dedupe events (you have one in admin!) +from .models import MessengerEvent # keep if present + +import requests + +log = logging.getLogger(__name__) +GRAPH_API = os.getenv("META_GRAPH_API", "https://graph.facebook.com/v18.0") + +def _verify_get(request): + mode = request.GET.get("hub.mode") + token = request.GET.get("hub.verify_token") + chal = request.GET.get("hub.challenge", "") + if mode == "subscribe" and token == getattr(settings, "MESSENGER_VERIFY_TOKEN", ""): + return HttpResponse(chal) + return HttpResponseForbidden("Bad verify token") + +def _verify_signature(request): + app_secret = getattr(settings, "FACEBOOK_APP_SECRET", "") + if not app_secret: + return True # dev mode (no signature check) + + provided = request.headers.get("X-Hub-Signature-256", "") + body = request.body + + # keep a copy for troubleshooting + try: + with open("/tmp/m_sig_body.bin", "wb") as f: + f.write(body) + except Exception: + pass + + calc_hex = hmac.new(app_secret.encode("utf-8"), body, hashlib.sha256).hexdigest() + expected = "sha256=" + calc_hex + log.info("[messenger] sig check: provided=%r expected=%r body_len=%d", provided, expected, len(body)) + return hmac.compare_digest(provided, expected) + +def _send_text(psid: str, text: str) -> bool: + token = getattr(settings, "PAGE_ACCESS_TOKEN", "") + if not token: + log.warning("[messenger] no PAGE_ACCESS_TOKEN; would send to %s: %s", psid, text) + return False + + url = f"{GRAPH_API}/me/messages" + params = {"access_token": token} + payload = { + "messaging_type": "RESPONSE", + "recipient": {"id": psid}, + "message": {"text": text}, + } + try: + r = requests.post(url, params=params, json=payload, timeout=10) + if r.status_code != 200: + log.error("[messenger] Send API error %s: %s", r.status_code, r.text) + return False + log.info("[messenger] sent to %s ok: %s", psid, r.text) + return True + except Exception as e: + log.exception("[messenger] Send API exception: %s", e) + return False + +@csrf_exempt +def webhook(request): + if request.method == "GET": + return _verify_get(request) + + if request.method != "POST": + return HttpResponse(status=405) + + if not _verify_signature(request): + return HttpResponseForbidden("Invalid signature") + + try: + payload = json.loads(request.body.decode("utf-8") or "{}") + except Exception: + payload = {} + log.info("[messenger] payload: %s", json.dumps(payload)[:2000]) + + # Iterate entries and messaging events + for entry in payload.get("entry", []): + for m in entry.get("messaging", []): + sender = (m.get("sender") or {}).get("id") + recipient = (m.get("recipient") or {}).get("id") + + # dedupe by message mid when present + mid = (m.get("message") or {}).get("mid") \ + or (m.get("delivery") or {}).get("mids", [None])[0] + if mid: + # store if not seen (your admin shows MessengerEvent already) + obj, created = MessengerEvent.objects.get_or_create( + mid=mid, + defaults={ + "sender_id": sender or "", + "page_id": recipient or "", + }, + ) + if not created: + log.info("[messenger] duplicate mid %s — skipping", mid) + continue + + # Basic text echo + if "message" in m and "text" in m["message"] and sender: + txt = m["message"]["text"].strip() + reply = f"You said: {txt}" + _send_text(sender, reply) + + # must 200 quickly + return HttpResponse("OK") diff --git a/templates/account/* b/templates/account/* deleted file mode 120000 index 2f68b4b..0000000 --- a/templates/account/* +++ /dev/null @@ -1 +0,0 @@ -../../pxy_dashboard/templates/pxy_dashboard/account/* \ No newline at end of file