Compare commits

...

No commits in common. "main" and "428697cd24dc810ecd858c63f124c2adbedcfd15" have entirely different histories.

5351 changed files with 649 additions and 1664809 deletions

View File

@ -1,51 +0,0 @@
kind: pipeline
type: docker
name: deploy-polisplexity
clone:
depth: 1
submodules: false
steps:
- name: install dependencies and run Django checks
image: python:3.10
environment:
DATABASE_URL:
from_secret: DATABASE_URL
SECRET_KEY:
from_secret: SECRET_KEY
DEBUG: "False"
NEO4J_URI:
from_secret: NEO4J_URI
NEO4J_USERNAME:
from_secret: NEO4J_USERNAME
NEO4J_PASSWORD:
from_secret: NEO4J_PASSWORD
OPENAI_API_KEY:
from_secret: OPENAI_API_KEY
PAGE_ACCESS_TOKEN:
from_secret: PAGE_ACCESS_TOKEN
VERIFY_TOKEN:
from_secret: VERIFY_TOKEN
commands:
- python -m pip install --upgrade pip
- pip install -r requirements.txt
- python manage.py check --deploy --fail-level ERROR
- echo "✅ Django deploy checks passed"
- name: deploy to production server
image: appleboy/drone-ssh
settings:
host: 191.101.233.39
username: drone
port: 22
key:
from_secret: PROD_SSH_KEY_B
script:
- cd /home/polisplexity/polisplexity
- git pull origin main
- docker compose down
- docker compose up -d --build
- docker compose exec web python manage.py migrate --noinput
- docker compose exec web python manage.py collectstatic --noinput
- echo "🚀 Production deployment complete"

30
.env Normal file
View File

@ -0,0 +1,30 @@
# Database Configuration
POSTGRES_DB=polisplexity
POSTGRES_USER=postgres
POSTGRES_PASSWORD=mysecretpassword
# Django Environment Variables
DEBUG=True
SECRET_KEY=django-insecure-%*=%u3gv38cv*2iwy)m^)flo3p4w7ol*n5*-7lr*i4^u+(v=#q
ALLOWED_HOSTS=127.0.0.1,localhost,app.polisplexity.tech,191.101.233.39,srv566867.hstgr.cloud
# Database URL
DATABASE_URL=postgres://postgres:mysecretpassword@db:5432/polisplexity
# Static and Media Files
STATIC_URL=/static/
STATIC_ROOT=/app/static
MEDIA_URL=/media/
MEDIA_ROOT=/app/media
# Neo4j Database Configuration
NEO4J_URI=neo4j+s://74d433fb.databases.neo4j.io
NEO4J_USERNAME=neo4j
NEO4J_PASSWORD=4Y5-ppefHkgEiLr-l0qzbf8wNJw0zkOmRmk7cSkSrTg
# OpenAI API Key
OPENAI_API_KEY=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
# Facebook API Tokens
PAGE_ACCESS_TOKEN=EAAIq9z4rVPIBOxJxRnmbjIUsqJ9ZB5hZC9MF4qN64VNpxUCYguMCqUNKSsAjQZAcD9hlhZCv2RcV4GOIFC3Ni6VGoMp3rTFlLwtXxFIklj0FqZAVqSh7i0QT3Kwt9SCx9V9iioSsyFhUQrnpTXZCoDPJy0i2kMkzkY5ZA58hieSeQZBZARz3ZC7XeZCi5uSZBXYCeatGuAZDZD
VERIFY_TOKEN=YzQ2VWcODWO922j30HZ9AV113kAisTjcacc3wzURPvFjHCOWjcYP39ThgCWlPQ1w

39
.gitignore vendored
View File

@ -1,39 +0,0 @@
# Python
__pycache__/
*.py[cod]
*.sqlite3
# Environment
.env
*.env
*.env.*
# Django
db.sqlite3
*.log
# VSCode
.vscode/
*.code-workspace
# Media/static/runtime
/media
/static
/cache
*.dump.sql
*.load
*.sql
# System
.DS_Store
pxy_city_digital_twins/__backup__/
Dockerfile.dev
docker-compose.override.yml
docker-compose.override.yml
pxy_meta_pages.zip
pxy_openai.zip
pxy_bots.zip
pxy_bots (2).zip
pxy_bots.zip
pxy_bots.zip
pxy_bots.zip

View File

@ -1,34 +1,17 @@
# Dockerfile (prod)
FROM python:3.10-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
# System deps needed by geopandas/shapely/pyproj, mysqlclient, etc.
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
curl \
libgeos-dev \
libspatialindex-dev \
libproj-dev proj-data proj-bin \
gdal-bin libgdal-dev \
python3-dev pkg-config \
default-libmysqlclient-dev \
&& rm -rf /var/lib/apt/lists/*
# Use official Python image
FROM python:3.10
# Set working directory
WORKDIR /app
# Install Python deps first (layer cache friendly)
COPY requirements.txt .
RUN python -m pip install --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt
# Copy project
# Copy application code
COPY . .
# Expose prod port (compose overrides CMD/port, but this documents intent)
EXPOSE 8002
# Install dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Default CMD (compose will override with your shell that migrates, collectstatic, and runs gunicorn:8002)
CMD ["gunicorn", "polisplexity.wsgi:application", "--bind", "0.0.0.0:8002", "--workers=4", "--timeout=180"]
# Expose the application port
EXPOSE 8000
# Run Gunicorn server
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "polisplexity.wsgi:application"]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
{"version": 0.6, "generator": "Overpass API 0.7.62.5 1bd436f1", "osm3s": {"timestamp_osm_base": "2025-05-12T23:01:58Z", "copyright": "The data included in this document is from www.openstreetmap.org. The data is made available under ODbL."}, "elements": []}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,48 +1,52 @@
{% extends 'pxy_dashboard/partials/base.html' %}
{% load static %}
{% extends 'base.html' %}
{% block title %}Polisplexity Portal{% endblock title %}
{% block extra_css %}
<style>
.card-img-top {
height: 200px; /* Fixed height for consistency */
object-fit: cover; /* Ensures image covers the area nicely */
}
.card:hover {
transform: scale(1.05); /* Slight zoom effect on hover for interactivity */
transition: transform 0.3s ease-in-out;
}
</style>
{% endblock extra_css %}
{% block content %}
<div class="container-fluid">
<div class="row justify-content-center">
<div class="col-12 text-center mb-4">
<h2 class="mt-3">Welcome to Polisplexity</h2>
<p class="text-muted">Select one of the available options</p>
</div>
</div>
<div class="container mt-4">
<h2 class="text-center mb-4">Welcome to Polisplexity</h2>
<p class="text-center mb-4">Select one of the options Bellow</p>
{% for category, items in grouped_menu_items.items %}
{% if not forloop.first %}
<div class="row mt-4">
<div class="col-12">
<hr class="border-secondary">
</div>
</div>
<hr> <!-- Horizontal separator for every new category except the first one -->
{% endif %}
<div class="row mb-4">
<div class="col-12">
<h4 class="text-primary">{{ category.name }}</h4>
<p class="text-muted">{{ category.description }}</p>
</div>
</div>
<div class="row">
<div class="col-12 col-md-3 mb-4">
<h3> {{ category.name }}</h3>
<small> {{ category.description }}</small>
</div>
{% for menu_item in items %}
<div class="col-sm-6 col-lg-3">
<div class="card d-block">
<div class="col-12 col-md-3 mb-4">
<div class="card h-100">
{% if menu_item.image %}
<img class="card-img-top" src="{{ menu_item.image.url }}" alt="{{ menu_item.title }}">
<!-- Display Image if available -->
<img src="{{ menu_item.image.url }}" class="card-img-top" alt="{{ menu_item.title }}">
{% elif menu_item.icon %}
<div class="card-header text-center bg-light-subtle">
<span class="material-symbols-rounded display-4 text-primary">{{ menu_item.icon }}</span>
<!-- Display Icon if image is not available but icon is -->
<div class="card-header text-center">
<span class="material-symbols-rounded md-48">{{ menu_item.icon }}</span>
</div>
{% endif %}
<div class="card-body">
<h5 class="card-title">{{ menu_item.title }}</h5>
<p class="card-text">{{ menu_item.description }}</p>
<a href="{{ menu_item.url }}" class="btn btn-sm btn-primary stretched-link">{{ menu_item.url_text }}</a>
</div>
<div class="card-footer bg-white">
<a href="{{ menu_item.url }}" class="btn btn-primary">{{ menu_item.url_text }}</a>
</div>
</div>
</div>
@ -51,3 +55,12 @@
{% endfor %}
</div>
{% endblock content %}
{% block extra_js %}
<script>
$(function () {
$('[data-bs-toggle="tooltip"]').tooltip() // Initialize Bootstrap tooltips
})
</script>
{% endblock extra_js %}

View File

@ -1,28 +0,0 @@
# polisplexity/core/urlbuild.py
from django.conf import settings
def public_base(request=None) -> str:
"""
1) settings.PUBLIC_BASE_URL (recommended in prod)
2) request scheme/host (proxy aware)
3) http://localhost:8000 fallback
"""
base = getattr(settings, "PUBLIC_BASE_URL", "").rstrip("/")
if base:
return base
if request is not None:
proto = (request.META.get("HTTP_X_FORWARDED_PROTO")
or ("https" if request.is_secure() else "http"))
host = request.get_host()
return f"{proto.split(',')[0].strip()}://{host}"
return "http://localhost:8000"
def public_url(path_or_abs: str, request=None) -> str:
"""Join public base to a path; pass through absolute URLs untouched."""
if not path_or_abs:
return path_or_abs
if "://" in path_or_abs:
return path_or_abs
if not path_or_abs.startswith("/"):
path_or_abs = "/" + path_or_abs
return public_base(request) + path_or_abs

View File

@ -1,6 +0,0 @@
name,lat,lon,category
Cafe Centro,19.4335,-99.1342,cafe
Cafe Alameda,19.4350,-99.1410,cafe
Cafe Madero,19.4321,-99.1358,cafe
Cafe Zocalo,19.4329,-99.1320,cafe
Cafe Bellas Artes,19.4365,-99.1415,cafe
1 name lat lon category
2 Cafe Centro 19.4335 -99.1342 cafe
3 Cafe Alameda 19.4350 -99.1410 cafe
4 Cafe Madero 19.4321 -99.1358 cafe
5 Cafe Zocalo 19.4329 -99.1320 cafe
6 Cafe Bellas Artes 19.4365 -99.1415 cafe

View File

@ -1,9 +0,0 @@
cell_id,lat,lon,pop
ZC_01,19.4334,-99.1322,1200
ZC_02,19.4318,-99.1339,950
ZC_03,19.4347,-99.1351,800
ZC_04,19.4309,-99.1311,700
ZC_05,19.4360,-99.1405,1100
ZC_06,19.4298,-99.1368,600
ZC_07,19.4382,-99.1450,900
ZC_08,19.4355,-99.1289,750
1 cell_id lat lon pop
2 ZC_01 19.4334 -99.1322 1200
3 ZC_02 19.4318 -99.1339 950
4 ZC_03 19.4347 -99.1351 800
5 ZC_04 19.4309 -99.1311 700
6 ZC_05 19.4360 -99.1405 1100
7 ZC_06 19.4298 -99.1368 600
8 ZC_07 19.4382 -99.1450 900
9 ZC_08 19.4355 -99.1289 750

View File

@ -1,4 +0,0 @@
city,value
CDMX,100
GDL,55
MTY,60
1 city value
2 CDMX 100
3 GDL 55
4 MTY 60

View File

@ -1,4 +0,0 @@
city,N
CDMX,9209944
GDL,5269191
MTY,5341174
1 city N
2 CDMX 9209944
3 GDL 5269191
4 MTY 5341174

BIN
db.sqlite3 Normal file

Binary file not shown.

View File

@ -5,12 +5,12 @@ services:
image: postgres:15
container_name: polisplexity_postgres
restart: always
ports:
- "5434:5432"
volumes:
- pgdata:/var/lib/postgresql/data
env_file:
- .env
ports:
- "5434:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
@ -24,22 +24,16 @@ services:
depends_on:
db:
condition: service_healthy
volumes:
- .:/app # Ensure correct project structure
ports:
- "8010:8002"
- "8010:8001"
env_file:
- .env
command: >
sh -c "python manage.py migrate &&
python manage.py collectstatic --noinput &&
gunicorn polisplexity.wsgi:application --bind 0.0.0.0:8002 --workers=4 --timeout=180"
volumes:
- static_data:/app/static
- media_data:/app/media
- ./staticfiles:/app/staticfiles
- ./data:/app/polisplexity/data:ro
# - .:/app # ←❌ No lo uses en producción: desactiva para evitar sobrescribir
exec gunicorn --bind 0.0.0.0:8001 polisplexity.wsgi:application"
volumes:
pgdata:
static_data:
media_data:

BIN
main.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 708 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,48 +1,43 @@
"""
Django settings for polisplexity project.
Generated by 'django-admin startproject' using Django 5.0.3.
For more information on this file, see
https://docs.djangoproject.com/en/5.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.0/ref/settings/
"""
from pathlib import Path
import os
import dj_database_url
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
BASE_URL = "https://app.polisplexity.tech"
import sys
sys.path.append(str(BASE_DIR))
# Core security settings
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.getenv("SECRET_KEY")
DEBUG = os.getenv("DEBUG", "False") == "True"
import os
_raw = os.getenv("ALLOWED_HOSTS", "")
ALLOWED_HOSTS = [h.strip() for h in _raw.split(",") if h.strip()] # from .env if present
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = os.getenv("DEBUG") == "True"
# Hotfix: always allow local calls from inside container & host mapping
for h in ("127.0.0.1", "localhost"):
if h not in ALLOWED_HOSTS:
ALLOWED_HOSTS.append(h)
ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "").split(",")
# Application definition
INSTALLED_APPS = [
# Django built-in apps
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.sites", # Required for allauth
# Allauth
"allauth",
"allauth.account",
"allauth.socialaccount",
"allauth.socialaccount.providers.github", # GitHub login only
# Your custom apps
"core",
"pxy_de",
"pxy_cr",
@ -53,51 +48,14 @@ INSTALLED_APPS = [
"pxy_meta_pages",
"pxy_langchain",
"pxy_neo4j",
"pxy_dashboard",
"pxy_dashboard.custom",
"pxy_dashboard.apps",
"pxy_dashboard.components",
"pxy_dashboard.layouts",
"pxy_building_digital_twins",
"pxy_messenger",
'pxy_contracts',
'pxy_sami',
'pxy_routing',
'pxy_sites',
"rest_framework",
"pxy_api",
'pxy_agents_coral',
# Third-party apps
"crispy_forms",
"crispy_bootstrap5",
]
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
CRISPY_TEMPLATE_PACK = "bootstrap5"
SITE_ID = int(os.getenv("SITE_ID", 1))
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend", # default
"allauth.account.auth_backends.AuthenticationBackend", # allauth support
]
LOGIN_REDIRECT_URL = "/"
ACCOUNT_LOGOUT_REDIRECT_URL = "/accounts/login/"
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"allauth.account.middleware.AccountMiddleware",
"pxy_dashboard.middleware.LoginRequiredMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
@ -107,10 +65,7 @@ ROOT_URLCONF = "polisplexity.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [
os.path.join(BASE_DIR, "templates"),
os.path.join(BASE_DIR, "pxy_dashboard", "templates"),
],
"DIRS": [os.path.join(BASE_DIR, "templates")],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
@ -118,17 +73,14 @@ TEMPLATES = [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"pxy_dashboard.context_processors.sidebar_context",
],
},
},
]
WSGI_APPLICATION = "polisplexity.wsgi.application"
# Database
# Database Configuration
DATABASES = {
"default": dj_database_url.config(default=os.getenv("DATABASE_URL"))
}
@ -147,113 +99,29 @@ TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
# Static & Media Files
STATIC_URL = "/static/"
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
STATIC_ROOT = os.path.join(BASE_DIR, "static") # Ensure this line is correct
STATICFILES_DIRS = [
os.path.join(BASE_DIR, "polisplexity/pxy_dashboard/static"), # Jidox assets
]
# Add this if missing
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') # Fixes mixed content issues
CSRF_TRUSTED_ORIGINS = ['https://app.polisplexity.tech'] # Allow CSRF over HTTPS
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
MEDIA_ROOT = os.getenv("MEDIA_ROOT", os.path.join(BASE_DIR, "media"))
# Default primary key field type
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# External services
# Facebook API Tokens
PAGE_ACCESS_TOKEN = os.getenv("PAGE_ACCESS_TOKEN")
VERIFY_TOKEN = os.getenv("VERIFY_TOKEN")
# Async-safe for Neo4j or Celery
os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true"
# ...pero silenciamos la comprobación que falla en producción:
SILENCED_SYSTEM_CHECKS = ["async.E001"]
# Neo4j
# Neo4j Database Configuration
NEO4J_URI = os.getenv("NEO4J_URI")
NEO4J_USERNAME = os.getenv("NEO4J_USERNAME")
NEO4J_PASSWORD = os.getenv("NEO4J_PASSWORD")
# OpenAI
# OpenAI API Key
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
# CSRF protection for production
CSRF_TRUSTED_ORIGINS = [
"https://app.polisplexity.tech",
]
# Support for secure reverse proxy (e.g., Nginx or Hostinger HTTPS proxy)
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "smtp.hostinger.com"
EMAIL_PORT = 465
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 <noreply@polisplexity.tech>"
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
REST_FRAMEWORK = {
# Deshabilitamos auth por ahora para evitar CSRF en curl
"DEFAULT_AUTHENTICATION_CLASSES": [],
# Throttling global + por-scope
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.AnonRateThrottle",
"rest_framework.throttling.UserRateThrottle",
"rest_framework.throttling.ScopedRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {
"anon": "100/hour",
"user": "1000/hour",
"sami_run": "30/minute",
"sites_search": "15/minute",
"routing_isochrone": "60/minute",
"routing_health": "120/minute",
"sami_health": "120/minute",
"sites_health": "120/minute",
},
# Manejo de errores uniforme
"EXCEPTION_HANDLER": "pxy_api.exceptions.envelope_exception_handler",
}
AGENTS_INTERNAL_BASE = os.getenv("AGENTS_INTERNAL_BASE", "")
LOGGING = {
# ... keep your existing handlers/formatters ...
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
},
},
"loggers": {
"pxy_bots": { # package logger
"handlers": ["console"],
"level": "INFO", # or "DEBUG"
"propagate": False,
},
# make sure __name__ in router resolves here; if your module path is different, use that
"pxy_bots.router": {
"handlers": ["console"],
"level": "INFO",
"propagate": False,
},
},
}
# honor reverse proxy + allow “Host” from proxy
USE_X_FORWARDED_HOST = True
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# optional hard override (recommended in prod)
PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "").rstrip("/")

View File

@ -25,42 +25,12 @@ admin.site.index_title = "Welcome to Polisplexity City Technologies Portal"
urlpatterns = [
path("admin/", admin.site.urls),
path("accounts/", include("allauth.urls")), # ← Add this line
path('', include('pxy_dashboard.urls')),
path('core', include('core.urls')),
path('', include('core.urls')),
path('', include('pxy_city_digital_twins.urls')),
path('pxy_whatsapp/', include('pxy_whatsapp.urls')),
path('bots/', include('pxy_bots.urls')),
path('bots/', include('pxy_bots.urls')), # Webhook URL: /bots/webhook/<bot_name>/
path('pxy_meta_pages/', include('pxy_meta_pages.urls', namespace='pxy_meta_pages')),
path(
"building/",
include(("pxy_building_digital_twins.urls", "pxy_building_digital_twins"),
namespace="pxy_building_digital_twins"),
),
path("messenger/", include("pxy_messenger.urls")),
path("", include("pxy_sami.api.urls")),
path("", include("pxy_routing.api.urls")),
path("", include("pxy_sites.api.urls")),
path("", include("pxy_de.urls")),
path("share/", include("pxy_dashboard.share_urls")), # ← NEW
path("api/", include("pxy_bots.api.urls")),
path("api/langchain/", include("pxy_langchain.api.urls")),
path("", include("pxy_openai.urls")),
path("", include("pxy_contracts.urls")),
path('', include('pxy_agents_coral.urls')),
]
if settings.DEBUG:
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

View File

@ -1,3 +0,0 @@
from django.contrib import admin
# Register your models here.

View File

@ -1,6 +0,0 @@
from django.apps import AppConfig
class PxyAgentsCoralConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'pxy_agents_coral'

View File

@ -1,117 +0,0 @@
from __future__ import annotations #
import json
import re
import requests
from typing import Any, Dict, Tuple
from django.http import JsonResponse, HttpRequest
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
# ---------- helpers ----------
def _base(request: HttpRequest) -> str:
return request.build_absolute_uri("/")[:-1]
def _load_body(request: HttpRequest) -> Dict[str, Any]:
try:
raw = (request.body or b"").decode("utf-8")
return json.loads(raw or "{}")
except Exception:
return {}
def _extract_payload(body: Dict[str, Any]) -> Tuple[Dict[str, Any], str]:
"""
Returns (payload, src) where src is 'payload', 'args_raw', or 'empty'.
Accepts:
1) {"payload": {...}}
2) full canonical envelope with .input.args_raw="/sami {...}"
"""
# 1) direct payload
if isinstance(body.get("payload"), dict):
return body["payload"], "payload"
# 2) canonical envelope: parse JSON after the command in args_raw
args_raw = (body.get("input", {}) or {}).get("args_raw") or ""
# strip leading "/sami " or "/sites "
cleaned = re.sub(r"^/\w+\s*", "", args_raw).strip()
if cleaned:
try:
return json.loads(cleaned), "args_raw"
except Exception:
pass
return {}, "empty"
def _post_execute(request: HttpRequest, agent: str, payload: Dict[str, Any], timeout: float = 30.0):
url = f"{_base(request)}/api/agents/execute"
try:
r = requests.post(url, json={"agent": agent, "payload": payload}, timeout=timeout)
# try parse json regardless of status
try:
data = r.json()
except Exception:
data = {"code": "NON_JSON", "message": r.text[:2000]}
return r.status_code, data
except requests.Timeout:
return 504, {"code": "UPSTREAM_TIMEOUT", "message": "agent upstream timed out"}
except Exception as e:
return 500, {"code": "EXEC_ERROR", "message": str(e)}
# ---------- text builders ----------
def _text_sami(data: Dict[str, Any]) -> str:
if "beta" in data and "r2" in data:
lines = [f"SAMI run: β={data['beta']:.3f}, R²={data['r2']:.3f}"]
resid = data.get("residuals") or []
top = sorted(resid, key=lambda x: x.get("rank", 1e9))[:3]
for c in top:
lines.append(f"{c.get('rank')}. {c.get('city')}: {c.get('sami',0):+0.2f}")
if data.get("share_url"):
lines += ["", data["share_url"]]
return "\n".join(lines)
if data.get("code"):
return f"⚠️ {data.get('code')}: {data.get('message','')}"
return "SAMI results ready."
def _text_sites(data: Dict[str, Any]) -> str:
if isinstance(data.get("candidates"), list):
city = data.get("city", "?")
business = data.get("business", "?")
lines = [f"Top sites for {business} in {city}:"]
for i, c in enumerate(data["candidates"][:3], 1):
lat = c.get("lat", 0); lon = c.get("lon", 0); sc = c.get("score", 0)
lines.append(f"{i}. score={sc:.2f} @ ({lat:.5f},{lon:.5f})")
for k in ("share_url", "isochrones_geojson_url", "candidates_geojson_url"):
if data.get(k): lines.append(data[k])
return "\n".join(lines)
if data.get("code"):
return f"⚠️ {data.get('code')}: {data.get('message','')}"
return "Site scoring ready."
# ---------- views ----------
@csrf_exempt
@require_http_methods(["GET", "POST"])
def format_sami(request: HttpRequest):
body = _load_body(request)
payload, src = _extract_payload(body)
status, data = _post_execute(request, "sami", payload, timeout=30.0)
# add echo + text
data = data if isinstance(data, dict) else {"result": data}
data.setdefault("_echo", {"src": src, "payload_keys": list(payload.keys())})
try:
data["text"] = _text_sami(data)
except Exception:
pass
return JsonResponse(data, status=status, safe=False)
@csrf_exempt
@require_http_methods(["GET", "POST"])
def format_sites(request: HttpRequest):
body = _load_body(request)
payload, src = _extract_payload(body)
status, data = _post_execute(request, "sites", payload, timeout=30.0)
data = data if isinstance(data, dict) else {"result": data}
data.setdefault("_echo", {"src": src, "payload_keys": list(payload.keys())})
try:
data["text"] = _text_sites(data)
except Exception:
pass
return JsonResponse(data, status=status, safe=False)

View File

@ -1,3 +0,0 @@
from django.db import models
# Create your models here.

View File

@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

View File

@ -1,10 +0,0 @@
from django.urls import path
from .formatters import format_sami, format_sites
from .views import agents_list, agents_execute
urlpatterns = [
path("api/agents/list", agents_list, name="agents_list"),
path("api/agents/execute", agents_execute, name="agents_execute"),
path("api/agents/format/sami", format_sami, name="agents_format_sami"),
path("api/agents/format/sites", format_sites, name="agents_format_sites"),
]

View File

@ -1,233 +0,0 @@
# polisplexity/pxy_agents_coral/views.py
from __future__ import annotations
import json
import re
from typing import Any, Dict, Tuple
from urllib.parse import urlparse
import requests
from django.conf import settings
from django.http import JsonResponse, HttpRequest
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods, require_POST
# build absolute public URLs from a request + path
from core.urlbuild import public_url
# ----- contracts version (best-effort) -----
try:
from pxy_contracts.version import SPEC_VERSION
except Exception:
SPEC_VERSION = "0.1.0"
# ----- INTERNAL CALL BASES -----
# For the generic /api/agents/execute proxy (kept for compatibility)
AGENTS_INTERNAL_BASE = getattr(settings, "AGENTS_INTERNAL_BASE", "")
# For the formatter endpoints we *force* an internal base and never guess from Host.
# Set in .env: AGENTS_INTERNAL_BASE=http://127.0.0.1:8002
# Fallback keeps you safe even if env is missing/misread.
FORMAT_INTERNAL_BASE = (AGENTS_INTERNAL_BASE or "http://127.0.0.1:8002").rstrip("/")
# ===== helpers =====
def _load_body(request: HttpRequest) -> Dict[str, Any]:
try:
raw = (request.body or b"").decode("utf-8")
return json.loads(raw or "{}")
except Exception:
return {}
def _extract_payload(body: Dict[str, Any]) -> Tuple[Dict[str, Any], str]:
"""
Returns (payload, src) where src is 'payload', 'args_raw', or 'empty'.
Accepts:
1) {"payload": {...}}
2) Canonical tg envelope with .input.args_raw like "/sami {...}"
"""
if isinstance(body.get("payload"), dict):
return body["payload"], "payload"
args_raw = (body.get("input", {}) or {}).get("args_raw") or ""
cleaned = re.sub(r"^/\w+\s*", "", args_raw).strip()
if cleaned:
try:
return json.loads(cleaned), "args_raw"
except Exception:
pass
return {}, "empty"
def _post_underlying(agent: str, payload: Dict[str, Any], timeout: float = 60.0):
"""
Call the *real* internal APIs via a fixed base (no build_absolute_uri):
sami -> /api/sami/run
sites -> /api/sites/search
"""
path = "/api/sami/run" if agent == "sami" else "/api/sites/search"
url = f"{FORMAT_INTERNAL_BASE}{path}"
try:
r = requests.post(url, json=payload, timeout=timeout)
try:
data = r.json()
except Exception:
data = {"code": "NON_JSON", "message": r.text[:2000]}
return r.status_code, data
except requests.Timeout:
return 504, {"code": "UPSTREAM_TIMEOUT", "message": "agent upstream timed out", "_debug_url": url}
except Exception as e:
return 500, {"code": "EXEC_ERROR", "message": str(e), "_debug_url": url}
def _normalize_urls_to_public(data: dict, request: HttpRequest) -> None:
"""
Convert any absolute URLs that may point to 127.0.0.1:8002 into public URLs
using the current request host while preserving the path.
"""
if not isinstance(data, dict):
return
url_keys = {
# common keys from sami + sites
"share_url", "map_url", "demand_map_url", "competition_map_url",
"main_download_url", "demand_download_url", "competition_download_url",
"main_preview_url", "demand_preview_url", "competition_preview_url",
"isochrones_geojson_url", "candidates_geojson_url",
"pois_competition_geojson_url", "popgrid_geojson_url",
"chart_url",
}
for k in list(url_keys):
v = data.get(k)
if not isinstance(v, str) or not v:
continue
try:
p = urlparse(v)
# only rewrite absolute http(s) URLs; keep relative ones
if p.scheme in ("http", "https") and p.path:
data[k] = public_url(request, p.path)
except Exception:
# never fail formatting due to a bad URL
pass
# Tiny text builders for bot replies
def _text_sami(data: Dict[str, Any]) -> str:
if "beta" in data and "r2" in data:
lines = [f"SAMI run: β={data['beta']:.3f}, R²={data['r2']:.3f}"]
for c in sorted(data.get("residuals", []), key=lambda x: x.get("rank", 1e9))[:3]:
lines.append(f"{c.get('rank')}. {c.get('city')}: {c.get('sami',0):+0.2f}")
if data.get("share_url"):
lines += ["", data["share_url"]]
return "\n".join(lines)
if data.get("code"):
return f"⚠️ {data.get('code')}: {data.get('message','')}"
return "SAMI results ready."
def _text_sites(data: Dict[str, Any]) -> str:
if isinstance(data.get("candidates"), list):
city = data.get("city", "?"); biz = data.get("business", "?")
lines = [f"Top sites for {biz} in {city}:"]
for i, c in enumerate(data["candidates"][:3], 1):
lines.append(f"{i}. score={c.get('score',0):.2f} @ ({c.get('lat',0):.5f},{c.get('lon',0):.5f})")
for k in ("share_url", "isochrones_geojson_url", "candidates_geojson_url"):
if data.get(k): lines.append(data[k])
return "\n".join(lines)
if data.get("code"):
return f"⚠️ {data.get('code')}: {data.get('message','')}"
return "Site scoring ready."
# ===== public endpoints =====
@csrf_exempt
@require_http_methods(["GET", "POST"])
def agents_list(request: HttpRequest):
# use request host only for *outward* links (safe)
base = request.build_absolute_uri("/")[:-1]
agents = [
{
"agent": "sami",
"name": "SAMI-Agent",
"version": "1.0.0",
"spec_version": SPEC_VERSION,
"contracts_url": f"{base}/api/contracts/sami.json",
"execute_url": f"{base}/api/agents/execute",
"description": "Urban scaling (β, R²) + SAMI residuals + chart",
},
{
"agent": "sites",
"name": "Sites-Agent",
"version": "1.0.0",
"spec_version": SPEC_VERSION,
"contracts_url": f"{base}/api/contracts/sites.json",
"execute_url": f"{base}/api/agents/execute",
"description": "Site scoring (access, demand, competition) with maps",
},
]
lines = ["Available agents:"]
for a in agents:
lines.append(f"- {a['agent']}: {a['description']}")
lines += [
"",
"Try:",
'/sami {"indicator":"imss_wages_2023","cities":["CDMX","GDL","MTY"]}',
'/sites {"city":"CDMX","business":"cafe","time_bands":[10,20]}',
]
return JsonResponse({"agents": agents, "text": "\n".join(lines)})
@csrf_exempt
@require_POST
def agents_execute(request: HttpRequest):
"""
Body: { "agent": "sami"|"sites", "payload": {...} }
Proxies to the *internal* API using AGENTS_INTERNAL_BASE (or same-host fallback).
"""
try:
body = json.loads(request.body.decode("utf-8") or "{}")
agent = (body.get("agent") or "").strip().lower()
payload = body.get("payload")
if agent not in {"sami", "sites"}:
return JsonResponse({"code": "AGENT_NOT_FOUND", "message": f"unknown agent '{agent}'"}, status=404)
if payload is None:
return JsonResponse({"code": "BAD_REQUEST", "message": "missing 'payload'"}, status=400)
path = "/api/sami/run" if agent == "sami" else "/api/sites/search"
base = (AGENTS_INTERNAL_BASE or "http://127.0.0.1:8002").rstrip("/")
url = f"{base}{path}"
r = requests.post(url, json=payload, timeout=90)
return JsonResponse(r.json(), status=r.status_code, safe=False)
except requests.Timeout:
return JsonResponse({"code": "UPSTREAM_TIMEOUT", "message": "agent upstream timed out"}, status=504)
except ValueError as ve:
return JsonResponse({"code": "BAD_JSON", "message": str(ve)}, status=400)
except Exception as e:
return JsonResponse({"code": "AGENT_EXEC_ERROR", "message": str(e)}, status=500)
# ----- formatters (call underlying APIs directly via fixed base) -----
@csrf_exempt
@require_http_methods(["GET", "POST"])
def format_sami(request: HttpRequest):
body = _load_body(request)
payload, src = _extract_payload(body)
status, data = _post_underlying("sami", payload, timeout=60.0)
data = data if isinstance(data, dict) else {"result": data}
_normalize_urls_to_public(data, request) # ensure public links
data.setdefault("_echo", {"src": src, "payload_keys": list(payload.keys())})
try:
data["text"] = _text_sami(data)
except Exception:
pass
return JsonResponse(data, status=status, safe=False)
@csrf_exempt
@require_http_methods(["GET", "POST"])
def format_sites(request: HttpRequest):
body = _load_body(request)
payload, src = _extract_payload(body)
status, data = _post_underlying("sites", payload, timeout=60.0)
data = data if isinstance(data, dict) else {"result": data}
_normalize_urls_to_public(data, request) # ensure public links
data.setdefault("_echo", {"src": src, "payload_keys": list(payload.keys())})
try:
data["text"] = _text_sites(data)
except Exception:
pass
return JsonResponse(data, status=status, safe=False)

View File

@ -1 +0,0 @@
default_app_config = "pxy_api.apps.PxyApiConfig"

View File

@ -1,5 +0,0 @@
from django.apps import AppConfig
class PxyApiConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "pxy_api"

View File

@ -1,57 +0,0 @@
from __future__ import annotations
import uuid, traceback
from django.conf import settings
from rest_framework.views import exception_handler as drf_exception_handler
from rest_framework.response import Response
from rest_framework import status
from rest_framework.exceptions import ValidationError as DRFValidationError
from pydantic import ValidationError as PydValidationError
def envelope_exception_handler(exc, context):
"""
Envuelve *todas* las excepciones DRF en:
{ ok: false, code, message, errors?, hint?, trace_id, detail?(DEBUG) }
"""
resp = drf_exception_handler(exc, context)
trace_id = str(uuid.uuid4())
if resp is not None:
# DRF ya resolvió un status_code razonable
code = getattr(exc, "default_code", "error")
message = None
if isinstance(exc, DRFValidationError):
message = "Validation error"
else:
# fallback a string corto
message = str(getattr(exc, "detail", "")) or exc.__class__.__name__
data = {
"ok": False,
"code": code,
"message": message,
"errors": resp.data, # DRF normaliza los errores aquí
"hint": None,
"trace_id": trace_id,
}
if settings.DEBUG:
data["detail"] = _short_trace()
return Response(data, status=resp.status_code)
# Excepción no manejada por DRF -> 500
data = {
"ok": False,
"code": "server_error",
"message": "Unexpected server error",
"hint": None,
"trace_id": trace_id,
}
if settings.DEBUG:
data["detail"] = _short_trace()
return Response(data, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def _short_trace():
try:
return "\n".join(traceback.format_exc().splitlines()[-6:])
except Exception:
return None

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,80 +1,52 @@
# pxy_bots/admin.py
from django.contrib import admin
from .models import TelegramBot, TelegramConversation, TelegramMessage, Connection, CommandRoute
from .models import TelegramBot
from django.utils.html import format_html
# ---- Connections ----
@admin.register(Connection)
class ConnectionAdmin(admin.ModelAdmin):
list_display = ("name", "base_url", "auth_type", "timeout_s", "is_active")
list_filter = ("auth_type", "is_active")
search_fields = ("name", "base_url")
readonly_fields = ("created_at", "updated_at")
fieldsets = (
(None, {
"fields": ("name", "is_active")
}),
("Endpoint", {
"fields": ("base_url", "path_default", "timeout_s", "allowed_hosts")
}),
("Auth & Headers", {
"fields": ("auth_type", "auth_value", "headers_json"),
"description": "Optional headers in JSON (e.g. {\"X-Tenant\":\"mx\"})."
}),
("Timestamps", {
"fields": ("created_at", "updated_at")
}),
)
# ---- Command routes inline under each bot ----
class CommandRouteInline(admin.TabularInline):
model = CommandRoute
extra = 0
fields = ("enabled", "priority", "trigger", "command_name", "connection", "path", "note")
ordering = ("priority", "id")
autocomplete_fields = ("connection",)
show_change_link = True
@admin.register(CommandRoute)
class CommandRouteAdmin(admin.ModelAdmin):
list_display = ("bot", "enabled", "priority", "trigger", "command_name", "connection", "path")
list_filter = ("enabled", "trigger", "bot", "connection")
search_fields = ("command_name", "note", "bot__name", "connection__name")
ordering = ("bot__name", "priority", "id")
# ---- Bots (with routes inline) ----
@admin.register(TelegramBot)
class TelegramBotAdmin(admin.ModelAdmin):
list_display = ("name", "username", "token_preview", "is_active", "created_at")
list_filter = ("is_active",)
list_display = ("name", "username", "is_active", "get_assistant_name", "set_webhook_action")
search_fields = ("name", "username")
inlines = [CommandRouteInline]
readonly_fields = ("created_at", "updated_at")
list_filter = ("is_active",)
actions = ["set_webhooks"]
def token_preview(self, obj):
return f"{obj.token[:8]}{obj.token[-4:]}" if obj.token else ""
token_preview.short_description = "Token"
@admin.action(description="Set webhooks for selected bots")
def set_webhooks(self, request, queryset):
base_url = request.build_absolute_uri("/")[:-1] # Get base server URL
results = []
for bot in queryset:
if bot.is_active:
try:
result = bot.set_webhook(base_url)
self.message_user(
request,
f"Webhook set for {bot.name}: {result}",
level="success",
)
except Exception as e:
self.message_user(
request,
f"Failed to set webhook for {bot.name}: {str(e)}",
level="error",
)
results.append(result)
else:
self.message_user(
request,
f"Skipped inactive bot: {bot.name}",
level="warning",
)
# ---- (Optional) conversation/message logs, already simple ----
@admin.register(TelegramConversation)
class TelegramConversationAdmin(admin.ModelAdmin):
list_display = ("bot", "user_id", "started_at")
list_filter = ("bot",)
search_fields = ("user_id",)
def get_assistant_name(self, obj):
"""Show the name of the assistant linked to the bot."""
return obj.assistant.name if obj.assistant else "None"
@admin.register(TelegramMessage)
class TelegramMessageAdmin(admin.ModelAdmin):
list_display = ("conversation", "direction", "short", "timestamp", "response_time_ms")
list_filter = ("direction",)
search_fields = ("content",)
def short(self, obj): return (obj.content or "")[:60]
get_assistant_name.short_description = "Assistant Name"
def set_webhook_action(self, obj):
"""Button in the Django admin to manually trigger webhook setup."""
return format_html(
'<a class="button" href="{}">Set Webhook</a>',
f"/admin/pxy_bots/set_webhook/{obj.id}/"
)
from django.contrib import admin
from .models import BotReplyTemplate
@admin.register(BotReplyTemplate)
class BotReplyTemplateAdmin(admin.ModelAdmin):
list_display = ("bot", "trigger", "command_name", "enabled", "priority")
list_filter = ("bot", "trigger", "enabled", "parse_mode")
search_fields = ("command_name", "text_template", "note")
ordering = ("bot", "priority", "id")
set_webhook_action.short_description = "Webhook"

View File

@ -1,9 +0,0 @@
# pxy_bots/api/urls.py
from django.urls import path
from . import views
urlpatterns = [
path("bots/health/", views.health, name="pxy_bots_health"), # (if you have it)
path("bots/echo_render", views.echo_render, name="pxy_bots_echo_render"), # (you already had)
path("bots/template_reply", views.template_reply, name="pxy_bots_template_reply"), # <-- NEW
]

View File

@ -1,117 +0,0 @@
# pxy_bots/api/views.py
import json
from django.http import JsonResponse, HttpResponse
from django.views.decorators.csrf import csrf_exempt
def health(request):
return JsonResponse({"ok": True, "service": "pxy_bots", "schema_ready": ["req.v1", "render.v1"]})
@csrf_exempt
def echo_render(request):
try:
data = json.loads(request.body.decode("utf-8") or "{}")
except Exception:
data = {}
text = (((data.get("input") or {}).get("text")) or "Hola 👋")
who = (((data.get("user") or {}).get("id")) or "user")
cmd = (((data.get("command") or {}).get("name")) or "none")
spec = {
"schema_version": "render.v1",
"messages": [
{"type": "text", "text": f"echo: user={who} cmd={cmd}"},
{"type": "text", "text": f"you said: {text}"},
],
}
return JsonResponse(spec)
# pxy_bots/api/views.py
import json, string
from django.http import JsonResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
from pxy_bots.models import TelegramBot, BotReplyTemplate
def _ctx_from_req(req):
inp = (req.get("input") or {})
loc = inp.get("location") or {}
args_raw = (inp.get("args_raw") or "") or (inp.get("text") or "") or ""
# Strip /cmd if present for ${args}
if args_raw.startswith("/"):
parts = args_raw.split(" ", 1)
args_raw = parts[1] if len(parts) > 1 else ""
return {
"user_id": ((req.get("user") or {}).get("id")),
"chat_id": ((req.get("chat") or {}).get("id")),
"cmd": ((req.get("command") or {}).get("name")),
"trigger": ((req.get("command") or {}).get("trigger")),
"text": (inp.get("text") or ""),
"caption": (inp.get("caption") or ""),
"args": args_raw,
"lat": loc.get("lat"),
"lon": loc.get("lon"),
}
def _choose_template(bot, trigger, cmd):
qs = (BotReplyTemplate.objects
.filter(bot=bot, enabled=True, trigger=trigger)
.order_by("priority", "id"))
t = (qs.filter(command_name=(cmd or None)).first()
or qs.filter(command_name__isnull=True).first()
or qs.filter(command_name="").first())
return t
@csrf_exempt
def template_reply(request):
if request.method != "POST":
return HttpResponseBadRequest("POST only")
try:
req = json.loads(request.body.decode("utf-8") or "{}")
except Exception:
return HttpResponseBadRequest("invalid json")
bot_name = ((req.get("bot") or {}).get("username"))
if not bot_name:
return HttpResponseBadRequest("missing bot.username")
bot = TelegramBot.objects.filter(name=bot_name, is_active=True).first() \
or TelegramBot.objects.filter(username=bot_name, is_active=True).first()
if not bot:
return HttpResponseBadRequest("bot not found")
trigger = ((req.get("command") or {}).get("trigger")) or "message"
cmd = ((req.get("command") or {}).get("name") or "")
cmd = cmd.strip().lstrip("/").lower() or None
tpl = _choose_template(bot, trigger, cmd)
if not tpl:
# Soft fallback to help you see wiring issues
return JsonResponse({
"schema_version": "render.v1",
"messages": [{"type": "text",
"text": f"(no template) bot={bot_name} trigger={trigger} cmd={cmd or '(default)'}"}]
})
ctx = _ctx_from_req(req)
text = string.Template(tpl.text_template or "").safe_substitute(**{k:("" if v is None else v) for k,v in ctx.items()})
msg_list = []
if tpl.media_url:
# If media present, put text in caption; otherwise plain text message
if text.strip():
msg_list.append({"type": "photo", "media_url": tpl.media_url,
"caption": text, "parse_mode": tpl.parse_mode.upper() if tpl.parse_mode!="none" else None})
else:
msg_list.append({"type": "photo", "media_url": tpl.media_url})
if (not tpl.media_url) and text.strip():
msg = {"type": "text", "text": text}
if tpl.parse_mode != BotReplyTemplate.PARSE_NONE:
msg["parse_mode"] = tpl.parse_mode.upper()
msg_list.append(msg)
spec = {"schema_version": "render.v1", "messages": msg_list}
btns = tpl.buttons()
if btns:
spec["buttons"] = btns
return JsonResponse(spec)

View File

@ -1,126 +0,0 @@
# pxy_bots/canonical.py
from typing import Any, Dict, Optional
def _pick_photo(sizes):
# Telegram sends photos as array of sizes; pick the largest
if not sizes:
return None
sizes = sorted(sizes, key=lambda s: (s.get("width", 0) * s.get("height", 0)), reverse=True)
top = sizes[0]
return {
"type": "photo",
"file_id": top.get("file_id"),
"mime": "image/jpeg", # Telegram photos are JPEG
"size_bytes": None, # Telegram doesn't include bytes here; leave None
"width": top.get("width"),
"height": top.get("height"),
}
def _extract_media(msg: Dict[str, Any]) -> Optional[Dict[str, Any]]:
if "photo" in msg:
return _pick_photo(msg.get("photo") or [])
if "voice" in msg:
v = msg["voice"]
return {"type": "voice", "file_id": v.get("file_id"), "mime": v.get("mime_type"), "size_bytes": v.get("file_size"), "duration": v.get("duration")}
if "audio" in msg:
a = msg["audio"]
return {"type": "audio", "file_id": a.get("file_id"), "mime": a.get("mime_type"), "size_bytes": a.get("file_size"), "duration": a.get("duration")}
if "video" in msg:
v = msg["video"]
return {"type": "video", "file_id": v.get("file_id"), "mime": v.get("mime_type"), "size_bytes": v.get("file_size"), "duration": v.get("duration"), "width": v.get("width"), "height": v.get("height")}
if "video_note" in msg:
v = msg["video_note"]
return {"type": "video_note", "file_id": v.get("file_id"), "mime": None, "size_bytes": v.get("file_size"), "duration": v.get("duration"), "length": v.get("length")}
if "animation" in msg:
a = msg["animation"]
return {"type": "animation", "file_id": a.get("file_id"), "mime": a.get("mime_type"), "size_bytes": a.get("file_size")}
if "document" in msg:
d = msg["document"]
return {"type": "document", "file_id": d.get("file_id"), "mime": d.get("mime_type"), "size_bytes": d.get("file_size"), "file_name": d.get("file_name")}
return None
def build_req_v1(update: Dict[str, Any], bot_name: str) -> Dict[str, Any]:
"""
Normalize a Telegram update into our canonical req.v1 envelope.
Pure function. No network, no state.
"""
schema_version = "req.v1"
update_id = update.get("update_id")
# Determine primary container: message, edited_message, callback_query
msg = update.get("message") or update.get("edited_message")
cbq = update.get("callback_query")
# Chat/user basics
if msg:
chat = msg.get("chat") or {}
user = msg.get("from") or {}
message_id = msg.get("message_id")
ts = msg.get("date")
text = msg.get("text")
caption = msg.get("caption")
location = msg.get("location")
media = _extract_media(msg)
trigger = "message"
elif cbq:
m = cbq.get("message") or {}
chat = m.get("chat") or {}
user = cbq.get("from") or {}
message_id = m.get("message_id")
ts = m.get("date") or None
text = None
caption = None
location = None
media = None
trigger = "callback"
else:
# Fallback for other update types we haven't mapped yet
chat = {}
user = update.get("from") or {}
message_id = None
ts = None
text = None
caption = None
location = None
media = None
trigger = "unknown"
# Command name (if text/caption starts with '/')
raw_cmd = None
if text and isinstance(text, str) and text.startswith("/"):
raw_cmd = text.split()[0][1:]
elif caption and isinstance(caption, str) and caption.startswith("/"):
raw_cmd = caption.split()[0][1:]
elif cbq and isinstance(cbq.get("data"), str):
raw_cmd = None # callbacks carry 'action' instead
# Build envelope
env = {
"schema_version": schema_version,
"bot": {"username": bot_name},
"chat": {"id": chat.get("id"), "type": chat.get("type")},
"user": {"id": user.get("id"), "language": user.get("language_code")},
"command": {
"name": raw_cmd,
"version": 1,
"trigger": ("text_command" if raw_cmd and trigger == "message" else ("callback" if trigger == "callback" else trigger)),
},
"input": {
"text": text,
"caption": caption,
"args_raw": text or caption,
"media": media,
"location": ({"lat": location.get("latitude"), "lon": location.get("longitude")} if location else None),
},
"callback": (
{"id": cbq.get("id"), "data": cbq.get("data"), "origin": {"message_id": message_id, "chat_id": chat.get("id")}}
if cbq else None
),
"context": {
"message_id": message_id,
"update_id": update_id,
"ts": ts,
"idempotency_key": f"tg:{message_id}:{user.get('id')}" if message_id and user.get("id") else None,
},
}
return env

View File

@ -1,7 +1,7 @@
from telegram import Update, ForceReply
import logging
from pxy_openai.assistants import OpenAIAssistant
from pxy_bots.models import TelegramBot
from .models import TelegramBot
from asgiref.sync import sync_to_async
from pxy_langchain.models import AIAssistant
from pxy_langchain.services import LangchainAIService
@ -33,26 +33,18 @@ async def help_command(update: Update):
user = update.effective_user
await update.message.reply_text(f"Help! How can I assist you, {user.first_name}?")
async def handle_location(update: Update):
"""Respond to a location message."""
location = update.message.location
if location:
lat = location.latitude
lon = location.longitude
text = (
"🚀 Your Digital Twin is ready!\n\n"
f"🌍 Latitude: {lat}\n"
f"🌍 Longitude: {lon}\n\n"
"🔗 Access it here:\n"
f"https://app.polisplexity.tech/city/digital/twin/osm_city/?lat={lat}&long={lon}&scale=0.1\n\n"
"Thanks for sharing your location!")
await update.message.reply_text(text)
await update.message.reply_text(
f"Thanks for sharing your location! Latitude: {location.latitude}, Longitude: {location.longitude}"
)
else:
await update.message.reply_text("Please share your location.")
async def respond(update, bot_name):
"""Respond to user messages using the LangChain AI service."""
try:

View File

@ -1,11 +0,0 @@
from .common import start, help_command, handle_location, respond
from .handlers_citizen import next_truck, report_trash, private_pickup, green_balance
from .handlers_city import next_route, complete_stop, missed_stop, my_eco_score as city_eco_score
from .handlers_private import available_jobs, accept_job, next_pickup, complete_pickup, my_eco_score as private_eco_score
__all__ = [
"start", "help_command", "handle_location", "respond",
"next_truck", "report_trash", "private_pickup", "green_balance",
"next_route", "complete_stop", "missed_stop", "city_eco_score",
"available_jobs", "accept_job", "next_pickup", "complete_pickup", "private_eco_score"
]

View File

@ -1,63 +0,0 @@
from telegram import Update, KeyboardButton, ReplyKeyboardMarkup
async def next_truck(update: Update):
if update.message.location:
lat, lon = update.message.location.latitude, update.message.location.longitude
await update.message.reply_text(
"🎮 *Modo Ruta Activado*\n"
"🏟️ Estadio Banorte — punto de control fijado.\n"
f"📍 Coordenadas: ({lat:.5f}, {lon:.5f})\n"
"🚛 Próximo camión para tu zona aparece *mañana a las 08:00 AM*.\n"
"💡 Tip: Mantén el área limpia para sumar Monedas Verdes.",
parse_mode="Markdown",
)
else:
keyboard = [[KeyboardButton("📍 Enviar ubicación", request_location=True)]]
markup = ReplyKeyboardMarkup(keyboard, resize_keyboard=True, one_time_keyboard=True)
await update.message.reply_text(
"🏟️ *Estadio Banorte*\n"
"Para activar el *radar de camiones*, comparte tu ubicación dentro del estadio.\n"
"Toca el botón de abajo para fijar tu base.",
reply_markup=markup,
parse_mode="Markdown",
)
async def report_trash(update: Update):
user_text = update.message.text
await update.message.reply_text(
"🧹 *Misión registrada*\n"
f"{user_text}\n"
"🌱 CO₂ evitado estimado: *1.6 kg*\n"
"🪙 Recompensa: *+5 Monedas Verdes*\n"
"🎯 Racha activa: ¡sigue reportando para subir de nivel en Estadio Banorte!",
parse_mode="Markdown",
)
async def private_pickup(update: Update):
if update.message.location:
await update.message.reply_text(
"🛵 *Reco Privado invocado*\n"
"NPC *Pepe (la motito)* aceptó la misión.\n"
"⏱️ ETA: *10 min*\n"
"📌 Dirígete al punto indicado y prepara el material. ¡Buen trabajo, asistente!",
parse_mode="Markdown",
)
else:
keyboard = [[KeyboardButton("📍 Enviar ubicación", request_location=True)]]
markup = ReplyKeyboardMarkup(keyboard, resize_keyboard=True, one_time_keyboard=True)
await update.message.reply_text(
"🎯 Para solicitar *Reco Privado* dentro del *Estadio Banorte*, comparte tu ubicación.\n"
"Usa el botón de abajo para fijar tu punto.",
reply_markup=markup,
parse_mode="Markdown",
)
async def green_balance(update: Update):
await update.message.reply_text(
"📊 *Panel de Progreso — Estadio Banorte*\n"
"🪙 Monedas Verdes: *23*\n"
"🌱 CO₂ evitado: *15 kg*\n"
"🏅 Rango: *Asistente Bronce*\n"
"➡️ Completa misiones (reportes y entregas) para subir de rango.",
parse_mode="Markdown",
)

View File

@ -1,42 +0,0 @@
from telegram import Update
async def next_route(update: Update):
await update.message.reply_text(
"🎮 *Modo Circuito Activo*\n"
"🏟️ *Estadio Banorte*\n"
"Tu circuito de hoy:\n"
"• Anillo *Concourse Norte* (Puertas 14)\n"
"• Lateral *Oriente* — Secciones 112120\n"
"• Cabecera *Sur* — Zona Eco (TX-05, TX-06)\n"
"\n"
"Marca cada punto del circuito cuando llegues.\n"
"Comandos rápidos: ✅ */complete_stop* · 🚧 */missed_stop*",
parse_mode="Markdown",
)
async def complete_stop(update: Update):
await update.message.reply_text(
"✅ *Punto asegurado*\n"
"Se registró la recolección en la bitácora del *Estadio Banorte*.\n"
"🪙 Recompensa: *+2 Monedas Verdes*\n"
"🔥 Racha +1 — ¡sigue así, asistente!",
parse_mode="Markdown",
)
async def missed_stop(update: Update):
await update.message.reply_text(
"🚧 *Punto no recolectado*\n"
"Abrí una alerta leve para replanificar el circuito en el *gemelo digital*.\n"
"💡 Sugerencia: anexa foto y motivo en el reporte para conservar tu racha.",
parse_mode="Markdown",
)
async def my_eco_score(update: Update):
await update.message.reply_text(
"📊 *Panel de Progreso — Estadio Banorte*\n"
"🪙 Monedas Verdes (mes): *120*\n"
"🌱 CO₂ evitado (estimado): *32 kg*\n"
"🏅 Rango: *Asistente Plata*\n"
"🎯 Tip: completa 5 puntos seguidos para activar el *Boost de Eficiencia*.",
parse_mode="Markdown",
)

View File

@ -1,49 +0,0 @@
from telegram import Update
async def available_jobs(update: Update):
await update.message.reply_text(
"🎮 *Misiones disponibles — Estadio Banorte*\n"
"1) ♻️ *BIN-112* · Concourse *Norte* (Puerta 3) — Orgánico\n"
"2) 🧴 *ECO-TX06* · Cabecera *Sur* — Punto Eco (plásticos/papel)\n"
"\n"
"Usa */accept_job* para tomar la primera misión disponible.",
parse_mode="Markdown",
)
async def accept_job(update: Update):
await update.message.reply_text(
"👌 *Misión aceptada*: *BIN-112*\n"
"📍 Dirígete a *Concourse Norte (Puerta 3)*\n"
"⏱️ ETA: *4 min a pie*\n"
"🎯 Objetivo: vacía contenedor orgánico y verifica tapa/bolsas.\n"
"Cuando termines, ejecuta */complete_pickup*.",
parse_mode="Markdown",
)
async def next_pickup(update: Update):
await update.message.reply_text(
"➡️ *Siguiente objetivo*: *ECO-TX06*\n"
"🏟️ Ubicación: *Cabecera Sur — Zona Eco*\n"
"📦 Material esperado: plásticos y papel (bolsas azules)\n"
"💡 Tip: registra foto si ves rebose para sumar Monedas extra.",
parse_mode="Markdown",
)
async def complete_pickup(update: Update):
await update.message.reply_text(
"✅ *Recolección completada*\n"
"🪙 Recompensa: *+3 Monedas Verdes*\n"
"🌱 CO₂ evitado (estimado): *0.9 kg*\n"
"🔥 Racha +1 — usa */next_pickup* para continuar el circuito.",
parse_mode="Markdown",
)
async def my_eco_score(update: Update):
await update.message.reply_text(
"🏆 *Progreso — Estadio Banorte*\n"
"🪙 Monedas Verdes (mes): *45*\n"
"🌱 CO₂ evitado (estimado): *12 kg*\n"
"🏅 Rango: *Asistente Bronce*\n"
"🎯 Objetivo del día: completa 3 misiones seguidas sin fallos para activar *Boost de Eficiencia*.",
parse_mode="Markdown",
)

View File

@ -1,34 +0,0 @@
# Generated by Django 5.0.3 on 2025-05-20 08:04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pxy_bots', '0005_remove_telegrambot_assistant_id_and_more'),
]
operations = [
migrations.CreateModel(
name='TelegramConversation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_id', models.CharField(max_length=64)),
('started_at', models.DateTimeField(auto_now_add=True)),
('bot', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='conversations', to='pxy_bots.telegrambot')),
],
),
migrations.CreateModel(
name='TelegramMessage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('direction', models.CharField(choices=[('in', 'In'), ('out', 'Out')], max_length=4)),
('content', models.TextField()),
('timestamp', models.DateTimeField(auto_now_add=True)),
('response_time_ms', models.IntegerField(blank=True, null=True)),
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='pxy_bots.telegramconversation')),
],
),
]

View File

@ -1,89 +0,0 @@
# Generated by Django 5.0.3 on 2025-09-17 03:49
import django.core.validators
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pxy_bots', '0006_telegramconversation_telegrammessage'),
('pxy_langchain', '0003_aiassistant_neo4j_profile_aiassistant_uses_graph'),
]
operations = [
migrations.CreateModel(
name='Connection',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=120, unique=True)),
('base_url', models.CharField(help_text='e.g. https://api.example.com', max_length=500)),
('path_default', models.CharField(blank=True, default='', help_text='Optional default path, e.g. /bots/route', max_length=300)),
('auth_type', models.CharField(choices=[('none', 'None'), ('bearer', 'Bearer token'), ('api_key', 'API key (in header)'), ('basic', 'Basic user:pass')], default='none', max_length=20)),
('auth_value', models.CharField(blank=True, default='', help_text='token | key | user:pass', max_length=500)),
('headers_json', models.TextField(blank=True, default='', help_text='Extra headers as JSON object')),
('timeout_s', models.PositiveIntegerField(default=4, validators=[django.core.validators.MinValueValidator(1)])),
('allowed_hosts', models.CharField(blank=True, default='127.0.0.1,localhost,app.polisplexity.tech', help_text='Comma-separated host allowlist for safety.', max_length=800)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['name'],
},
),
migrations.AlterModelOptions(
name='telegrambot',
options={'ordering': ['name']},
),
migrations.AlterModelOptions(
name='telegramconversation',
options={'ordering': ['-started_at']},
),
migrations.AlterModelOptions(
name='telegrammessage',
options={'ordering': ['timestamp', 'id']},
),
migrations.AddField(
model_name='telegrambot',
name='created_at',
field=models.DateTimeField(default=django.utils.timezone.now, editable=False),
),
migrations.AddField(
model_name='telegrambot',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AlterField(
model_name='telegrambot',
name='assistant',
field=models.ForeignKey(help_text='LangChain AI assistant associated with this bot.', on_delete=django.db.models.deletion.CASCADE, related_name='telegram_bots', to='pxy_langchain.aiassistant'),
),
migrations.AlterField(
model_name='telegrambot',
name='is_active',
field=models.BooleanField(default=True, help_text='If off, webhook can be refused.'),
),
migrations.CreateModel(
name='CommandRoute',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('trigger', models.CharField(choices=[('message', 'Message (no command)'), ('text_command', 'Text command (/cmd)'), ('callback', 'Callback')], default='message', max_length=20)),
('command_name', models.CharField(blank=True, help_text="Without leading '/'. Leave blank for default of that trigger.", max_length=80, null=True)),
('path', models.CharField(blank=True, default='', help_text='Overrides connection.path_default if set', max_length=300)),
('enabled', models.BooleanField(default=True)),
('priority', models.PositiveIntegerField(default=100, help_text='Lower runs first')),
('note', models.CharField(blank=True, default='', max_length=240)),
('created_at', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
('updated_at', models.DateTimeField(auto_now=True)),
('bot', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='routes', to='pxy_bots.telegrambot')),
('connection', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='routes', to='pxy_bots.connection')),
],
options={
'ordering': ['priority', 'id'],
'indexes': [models.Index(fields=['bot', 'trigger', 'command_name', 'enabled', 'priority'], name='pxy_bots_co_bot_id_fd0f4c_idx')],
},
),
]

View File

@ -1,38 +0,0 @@
# Generated by Django 5.0.3 on 2025-09-17 06:20
import django.core.validators
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pxy_bots', '0007_connection_alter_telegrambot_options_and_more'),
]
operations = [
migrations.CreateModel(
name='BotReplyTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('trigger', models.CharField(choices=[('message', 'Message (no command)'), ('text_command', 'Text command (/cmd)'), ('callback', 'Callback')], default='message', max_length=20)),
('command_name', models.CharField(blank=True, help_text="Without leading '/'. Blank = default for this trigger.", max_length=80, null=True)),
('text_template', models.TextField(blank=True, default='', help_text='Use ${args}, ${user_id}, ${lat}, ${lon}, etc.')),
('parse_mode', models.CharField(choices=[('none', 'None'), ('markdown', 'Markdown'), ('html', 'HTML')], default='markdown', max_length=20)),
('media_url', models.CharField(blank=True, default='', help_text='Optional image/video URL', max_length=600)),
('buttons_json', models.TextField(blank=True, default='', help_text='Optional JSON: [{"label":"Open","kind":"open_url","url":"https://..."}]')),
('enabled', models.BooleanField(default=True)),
('priority', models.PositiveIntegerField(default=100, help_text='Lower runs first', validators=[django.core.validators.MinValueValidator(1)])),
('note', models.CharField(blank=True, default='', max_length=240)),
('created_at', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
('updated_at', models.DateTimeField(auto_now=True)),
('bot', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reply_templates', to='pxy_bots.telegrambot')),
],
options={
'ordering': ['priority', 'id'],
'indexes': [models.Index(fields=['bot', 'trigger', 'command_name', 'enabled', 'priority'], name='pxy_bots_bo_bot_id_19f2c3_idx')],
},
),
]

View File

@ -1,247 +1,47 @@
import json
import requests
from django.db import models
from django.core.validators import MinValueValidator
from django.utils import timezone
from pxy_langchain.models import AIAssistant # Now referencing LangChain AI assistants
from pxy_langchain.models import AIAssistant # LangChain assistant
# Telegram bot + simple conversation log
class TelegramBot(models.Model):
"""
Represents a Telegram bot that interacts with users using a LangChain AI assistant.
"""
name = models.CharField(max_length=50, unique=True, help_text="Bot name (e.g., 'SupportBot').")
username = models.CharField(max_length=50, unique=True, help_text="Bot username (e.g., 'SupportBot').")
token = models.CharField(max_length=200, unique=True, help_text="Telegram bot token.")
is_active = models.BooleanField(default=True, help_text="If off, webhook can be refused.")
is_active = models.BooleanField(default=True, help_text="Indicates if this bot is active.")
assistant = models.ForeignKey(
AIAssistant,
on_delete=models.CASCADE,
related_name="telegram_bots",
help_text="LangChain AI assistant associated with this bot.",
help_text="The LangChain AI assistant associated with this Telegram bot.",
)
created_at = models.DateTimeField(default=timezone.now, editable=False)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["name"]
def __str__(self):
return f"{self.name} (@{self.username})"
@staticmethod
def get_bot_token(bot_name: str) -> str:
def get_bot_token(bot_name):
"""Retrieve the token for the given bot name."""
try:
bot = TelegramBot.objects.get(name=bot_name, is_active=True)
return bot.token
except TelegramBot.DoesNotExist:
raise ValueError(f"Bot with name '{bot_name}' not found or inactive.")
def set_webhook(self, base_url: str) -> dict:
def set_webhook(self, base_url):
"""
Set the webhook for this bot dynamically based on the server's base URL.
"""
if not self.is_active:
raise ValueError(f"Bot '{self.name}' is inactive. Activate it before setting the webhook.")
webhook_url = f"{base_url.rstrip('/')}/bots/webhook/{self.name}/"
resp = requests.post(
webhook_url = f"{base_url}/bots/webhook/{self.name}/"
response = requests.post(
f"https://api.telegram.org/bot{self.token}/setWebhook",
data={"url": webhook_url},
timeout=5,
data={"url": webhook_url}
)
resp.raise_for_status()
return resp.json()
class TelegramConversation(models.Model):
bot = models.ForeignKey(TelegramBot, on_delete=models.CASCADE, related_name='conversations')
user_id = models.CharField(max_length=64)
started_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-started_at"]
def __str__(self):
return f"{self.user_id} @ {self.started_at:%Y-%m-%d %H:%M}"
class TelegramMessage(models.Model):
IN = 'in'
OUT = 'out'
DIRECTION_CHOICES = [(IN, 'In'), (OUT, 'Out')]
conversation = models.ForeignKey(TelegramConversation, on_delete=models.CASCADE, related_name='messages')
direction = models.CharField(max_length=4, choices=DIRECTION_CHOICES)
content = models.TextField()
timestamp = models.DateTimeField(auto_now_add=True)
response_time_ms = models.IntegerField(null=True, blank=True)
class Meta:
ordering = ["timestamp", "id"]
def __str__(self):
return f"[{self.direction}] {self.content[:30]}"
# Configurable routing (Admin-driven)
class Connection(models.Model):
AUTH_NONE = "none"
AUTH_BEARER = "bearer"
AUTH_API_KEY = "api_key"
AUTH_BASIC = "basic"
AUTH_CHOICES = [
(AUTH_NONE, "None"),
(AUTH_BEARER, "Bearer token"),
(AUTH_API_KEY, "API key (in header)"),
(AUTH_BASIC, "Basic user:pass"),
]
name = models.CharField(max_length=120, unique=True)
base_url = models.CharField(max_length=500, help_text="e.g. https://api.example.com")
path_default = models.CharField(max_length=300, blank=True, default="", help_text="Optional default path, e.g. /bots/route")
auth_type = models.CharField(max_length=20, choices=AUTH_CHOICES, default=AUTH_NONE)
auth_value = models.CharField(max_length=500, blank=True, default="", help_text="token | key | user:pass")
headers_json = models.TextField(blank=True, default="", help_text='Extra headers as JSON object')
timeout_s = models.PositiveIntegerField(default=4, validators=[MinValueValidator(1)])
allowed_hosts = models.CharField(
max_length=800,
blank=True,
default="127.0.0.1,localhost,app.polisplexity.tech",
help_text="Comma-separated host allowlist for safety."
)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(default=timezone.now, editable=False)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
def allowed_host_set(self):
return {h.strip().lower() for h in (self.allowed_hosts or "").split(",") if h.strip()}
def extra_headers(self):
if not self.headers_json:
return {}
try:
obj = json.loads(self.headers_json)
return obj if isinstance(obj, dict) else {}
except Exception:
return {}
def auth_headers(self):
h = {}
if self.auth_type == self.AUTH_BEARER and self.auth_value:
h["Authorization"] = f"Bearer {self.auth_value}"
elif self.auth_type == self.AUTH_API_KEY and self.auth_value:
h["X-API-Key"] = self.auth_value # convention; adjust if needed
elif self.auth_type == self.AUTH_BASIC and self.auth_value:
h["Authorization"] = f"Basic {self.auth_value}" # store user:pass
return h
class CommandRoute(models.Model):
TRIG_MESSAGE = "message"
TRIG_TEXTCMD = "text_command"
TRIG_CALLBACK = "callback"
TRIGGER_CHOICES = [
(TRIG_MESSAGE, "Message (no command)"),
(TRIG_TEXTCMD, "Text command (/cmd)"),
(TRIG_CALLBACK, "Callback"),
]
bot = models.ForeignKey("pxy_bots.TelegramBot", on_delete=models.CASCADE, related_name="routes")
trigger = models.CharField(max_length=20, choices=TRIGGER_CHOICES, default=TRIG_MESSAGE)
command_name = models.CharField(
max_length=80, blank=True, null=True,
help_text="Without leading '/'. Leave blank for default of that trigger."
)
connection = models.ForeignKey(Connection, on_delete=models.PROTECT, related_name="routes")
path = models.CharField(max_length=300, blank=True, default="", help_text="Overrides connection.path_default if set")
enabled = models.BooleanField(default=True)
priority = models.PositiveIntegerField(default=100, help_text="Lower runs first")
note = models.CharField(max_length=240, blank=True, default="")
created_at = models.DateTimeField(default=timezone.now, editable=False)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["priority", "id"]
indexes = [
models.Index(fields=["bot", "trigger", "command_name", "enabled", "priority"]),
]
def __str__(self):
cmd = self.command_name or "(default)"
return f"{self.bot.name} · {self.trigger} · {cmd}{self.connection.name}"
def clean(self):
if self.command_name:
self.command_name = self.command_name.strip().lstrip("/").lower()
# --- Admin-configurable reply templates --------------------------------------
from django.core.validators import MinValueValidator
from django.utils import timezone
import json
class BotReplyTemplate(models.Model):
PARSE_NONE = "none"
PARSE_MD = "markdown"
PARSE_HTML = "html"
PARSE_CHOICES = [
(PARSE_NONE, "None"),
(PARSE_MD, "Markdown"),
(PARSE_HTML, "HTML"),
]
TRIG_MESSAGE = "message"
TRIG_TEXTCMD = "text_command"
TRIG_CALLBACK = "callback"
TRIGGER_CHOICES = [
(TRIG_MESSAGE, "Message (no command)"),
(TRIG_TEXTCMD, "Text command (/cmd)"),
(TRIG_CALLBACK, "Callback"),
]
bot = models.ForeignKey("pxy_bots.TelegramBot", on_delete=models.CASCADE, related_name="reply_templates")
trigger = models.CharField(max_length=20, choices=TRIGGER_CHOICES, default=TRIG_MESSAGE)
command_name = models.CharField(max_length=80, blank=True, null=True,
help_text="Without leading '/'. Blank = default for this trigger.")
# Content
text_template = models.TextField(blank=True, default="", help_text="Use ${args}, ${user_id}, ${lat}, ${lon}, etc.")
parse_mode = models.CharField(max_length=20, choices=PARSE_CHOICES, default=PARSE_MD)
media_url = models.CharField(max_length=600, blank=True, default="", help_text="Optional image/video URL")
buttons_json = models.TextField(blank=True, default="",
help_text='Optional JSON: [{"label":"Open","kind":"open_url","url":"https://..."}]')
enabled = models.BooleanField(default=True)
priority = models.PositiveIntegerField(default=100, validators=[MinValueValidator(1)], help_text="Lower runs first")
note = models.CharField(max_length=240, blank=True, default="")
created_at = models.DateTimeField(default=timezone.now, editable=False)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["priority", "id"]
indexes = [models.Index(fields=["bot", "trigger", "command_name", "enabled", "priority"])]
def __str__(self):
cmd = self.command_name or "(default)"
return f"{self.bot.name} · {self.trigger} · {cmd}"
def clean(self):
if self.command_name:
self.command_name = self.command_name.strip().lstrip("/").lower()
# helpers
def buttons(self):
if not self.buttons_json:
return None
try:
obj = json.loads(self.buttons_json)
return obj if isinstance(obj, list) else None
except Exception:
return None
if response.status_code == 200:
return response.json()
else:
raise ValueError(f"Failed to set webhook for {self.name}: {response.json()}")

View File

@ -1,165 +0,0 @@
# pxy_bots/renderer.py
import json
import logging
from typing import Dict, List, Optional
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Bot, Message
from telegram.error import TelegramError
logger = logging.getLogger(__name__)
def _build_keyboard(buttons: Optional[List[dict]]) -> Optional[InlineKeyboardMarkup]:
"""
Build an InlineKeyboardMarkup from a list of button specs.
Supported kinds:
- open_url: {"label":"...", "kind":"open_url", "url":"https://..."}
- callback_api: {"label":"...", "kind":"callback_api", "action":"rerun",
"params": {...}, "state_token":"..."}
"""
if not buttons:
return None
rows: List[List[InlineKeyboardButton]] = []
current_row: List[InlineKeyboardButton] = []
for b in buttons:
kind = (b.get("kind") or "").lower()
label = b.get("label") or ""
if kind == "open_url":
url = b.get("url")
if not url:
logger.warning("renderer: open_url without url; skipping")
continue
btn = InlineKeyboardButton(text=label, url=url)
elif kind == "callback_api":
# Keep callback_data tiny; prefer server-issued state_token.
if b.get("state_token"):
data = {"t": b["state_token"]} # compact key
else:
data = {"a": b.get("action"), "p": b.get("params") or {}}
try:
payload = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
except Exception:
payload = '{"e":"bad"}'
# Telegram recommends <=64 bytes for callback_data
if len(payload.encode("utf-8")) > 64:
logger.warning("renderer: callback_data too long (%sB); trimming",
len(payload.encode("utf-8")))
payload = payload.encode("utf-8")[:64].decode("utf-8", errors="ignore")
btn = InlineKeyboardButton(text=label, callback_data=payload)
else:
logger.warning("renderer: unknown button kind=%s; skipping", kind)
continue
current_row.append(btn)
if len(current_row) >= 3:
rows.append(current_row)
current_row = []
if current_row:
rows.append(current_row)
return InlineKeyboardMarkup(rows) if rows else None
async def render_spec(*, bot: Bot, chat_id: int, spec: Dict) -> List[Message]:
"""
Send messages according to a render_spec:
{
"messages": [
{"type":"text","text":"..."},
{"type":"photo","media_url":"https://...","caption":"..."}
],
"buttons":[ ... ], # optional top-level buttons (attached to the LAST message)
"telemetry": {"run_id":"...", "cache_ttl_s":600},
"schema_version": "render.v1"
}
Returns the list of telegram.Message objects sent.
"""
msgs = spec.get("messages") or []
top_buttons = spec.get("buttons") or None
sent: List[Message] = []
for i, m in enumerate(msgs):
mtype = (m.get("type") or "text").lower()
kb = _build_keyboard(m.get("buttons") or (top_buttons if i == len(msgs) - 1 else None))
try:
if mtype == "text":
text = m.get("text") or ""
msg = await bot.send_message(chat_id=chat_id, text=text, reply_markup=kb)
sent.append(msg)
elif mtype == "photo":
file_id = m.get("file_id")
media_url = m.get("media_url")
caption = m.get("caption") or None
if not (file_id or media_url):
logger.warning("renderer: photo without file_id/media_url; skipping")
continue
try:
msg = await bot.send_photo(chat_id=chat_id,
photo=file_id or media_url,
caption=caption,
reply_markup=kb)
sent.append(msg)
except TelegramError as te:
# Typical: "BadRequest: Wrong type of the web page content"
logger.exception("renderer.photo_error send_photo err=%s url=%s", te, media_url)
# Fallback 1: try as document (Telegram is more permissive)
try:
msg = await bot.send_document(chat_id=chat_id,
document=file_id or media_url,
caption=caption,
reply_markup=kb)
sent.append(msg)
except TelegramError as te2:
logger.exception("renderer.photo_fallback_doc_error err=%s url=%s", te2, media_url)
# Fallback 2: plain text with link
fallback_text = (caption + "\n" if caption else "") + (media_url or "")
if fallback_text.strip():
try:
msg = await bot.send_message(chat_id=chat_id,
text=fallback_text,
reply_markup=kb)
sent.append(msg)
except TelegramError as te3:
logger.exception("renderer.photo_fallback_text_error err=%s url=%s", te3, media_url)
elif mtype == "document":
file_id = m.get("file_id")
media_url = m.get("media_url")
caption = m.get("caption") or None
if not (file_id or media_url):
logger.warning("renderer: document without file_id/media_url; skipping")
continue
msg = await bot.send_document(chat_id=chat_id, document=file_id or media_url,
caption=caption, reply_markup=kb)
sent.append(msg)
elif mtype == "video":
file_id = m.get("file_id")
media_url = m.get("media_url")
caption = m.get("caption") or None
if not (file_id or media_url):
logger.warning("renderer: video without file_id/media_url; skipping")
continue
msg = await bot.send_video(chat_id=chat_id, video=file_id or media_url,
caption=caption, reply_markup=kb)
sent.append(msg)
else:
logger.warning("renderer: unsupported message type=%s; skipping", mtype)
except TelegramError as te:
logger.exception("renderer.telegram_error type=%s err=%s", mtype, te)
except Exception as e:
logger.exception("renderer.unexpected type=%s err=%s", mtype, e)
return sent

View File

@ -1,155 +0,0 @@
# pxy_bots/router.py
import json
import logging
import time # <-- add
from typing import Dict, Optional, Tuple
from urllib.parse import urlparse
logger = logging.getLogger(__name__)
# Try to use requests; fallback to urllib d
try:
import requests # type: ignore
_HAS_REQUESTS = True
except Exception:
import urllib.request # type: ignore
_HAS_REQUESTS = False
def _is_allowed(url: str, allowed_hosts: Optional[set]) -> Tuple[bool, str]:
"""Allowlist check for DB routes (per-Connection)."""
try:
p = urlparse(url)
if p.scheme not in {"http", "https"}:
return False, "bad_scheme"
host = (p.hostname or "").lower()
return (host in (allowed_hosts or set())), f"host={host}"
except Exception as e:
return False, f"invalid_url:{e}"
def _compose_url(base: str, path: str) -> str:
base = (base or "").rstrip("/")
path = (path or "").lstrip("/")
return f"{base}/{path}" if path else base
# -----------------------------
# DB routing (Admin-driven)
# -----------------------------
def pick_db_route(bot_name: str, canon: Dict) -> Optional[Dict]:
"""
Look up CommandRoute for this bot + trigger/(optional) command.
Returns: {"url": str, "headers": dict, "timeout": int}
or None if no active route.
"""
try:
# Lazy import to avoid circulars at startup
from .models import CommandRoute, Connection, TelegramBot # noqa
bot = TelegramBot.objects.filter(name=bot_name, is_active=True).first()
if not bot:
logger.info("router.no_bot bot=%s", bot_name)
return None
trigger = ((canon.get("command") or {}).get("trigger")) or "message"
cmd = ((canon.get("command") or {}).get("name")) or None
cmd = (cmd or "").strip().lstrip("/").lower() or None
logger.info("router.lookup bot=%s trigger=%s cmd=%s", bot.username, trigger, cmd or "")
qs = (
CommandRoute.objects
.select_related("connection")
.filter(bot=bot, enabled=True, connection__is_active=True, trigger=trigger)
.order_by("priority", "id")
)
# Log a small snapshot of candidates
snapshot = list(qs.values("command_name", "trigger", "priority", "path", "connection__name")[:10])
logger.info("router.candidates n=%s sample=%s", qs.count(), snapshot)
# Prefer exact command; then default (blank/null)
route = (
qs.filter(command_name=cmd).first()
or qs.filter(command_name__isnull=True).first()
or qs.filter(command_name="").first()
)
if not route:
logger.info("router.no_match bot=%s trigger=%s cmd=%s", bot.username, trigger, cmd or "")
return None
conn: Connection = route.connection
url = _compose_url(conn.base_url, route.path or conn.path_default)
ok, why = _is_allowed(url, conn.allowed_host_set())
if not ok:
logger.warning(
"router.db.reject url=%s reason=%s allowed=%s route_conn=%s",
url, why, conn.allowed_host_set(), conn.name
)
return None
headers = {}
headers.update(conn.auth_headers())
headers.update(conn.extra_headers())
logger.info(
"router.route_ok bot=%s trigger=%s cmd=%s url=%s conn=%s timeout=%ss",
bot.username, trigger, cmd or "", url, conn.name, conn.timeout_s
)
return {"url": url, "headers": headers, "timeout": conn.timeout_s}
except Exception as e:
logger.exception("router.db.error: %s", e)
return None
# -----------------------------
# HTTP POST (DB routes)
# -----------------------------
def post_json(url: str, payload: Dict, timeout: float = 4.0, headers: Optional[Dict] = None) -> Tuple[int, Dict]:
"""
Blocking POST JSON; never raises.
Returns (status_code, body_json_or_wrapper).
`headers` is optional for DB routes.
"""
hdrs = {"Content-Type": "application/json", **(headers or {})}
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
t0 = time.perf_counter()
if _HAS_REQUESTS:
try:
r = requests.post(url, data=data, headers=hdrs, timeout=timeout)
dt = (time.perf_counter() - t0) * 1000
try:
body = r.json()
preview = str(body)[:200].replace("\n", " ")
except Exception:
body = {"text": r.text[:2000]}
preview = body["text"][:200].replace("\n", " ")
logger.info("router.http requests url=%s status=%s t_ms=%.1f body~=%s", url, r.status_code, dt, preview)
return r.status_code, body
except Exception as e:
dt = (time.perf_counter() - t0) * 1000
logger.exception("router.requests_failed url=%s t_ms=%.1f err=%s", url, dt, e.__class__.__name__)
return 502, {"ok": False, "error": f"requests_failed:{e.__class__.__name__}"}
else:
try:
req = urllib.request.Request(url, data=data, headers=hdrs, method="POST")
with urllib.request.urlopen(req, timeout=timeout) as resp: # nosec
raw = resp.read(65536)
dt = (time.perf_counter() - t0) * 1000
try:
body = json.loads(raw.decode("utf-8"))
preview = str(body)[:200].replace("\n", " ")
except Exception:
txt = raw.decode("utf-8", errors="replace")[:2000]
body = {"text": txt}
preview = txt[:200].replace("\n", " ")
status = getattr(resp, "status", 200)
logger.info("router.http urllib url=%s status=%s t_ms=%.1f body~=%s", url, status, dt, preview)
return status, body
except Exception as e:
dt = (time.perf_counter() - t0) * 1000
logger.exception("router.urllib_failed url=%s t_ms=%.1f err=%s", url, dt, e.__class__.__name__)
return 502, {"ok": False, "error": f"urllib_failed:{e.__class__.__name__}"}

16
pxy_bots/set_webhook.py Normal file
View File

@ -0,0 +1,16 @@
import requests
from pxy_bots.models import TelegramBot
BASE_URL = "https://your-domain.com/bots/webhook/"
def set_telegram_webhooks():
"""Sets webhooks for all active bots."""
bots = TelegramBot.objects.filter(is_active=True)
for bot in bots:
webhook_url = f"{BASE_URL}{bot.name}/"
response = requests.post(
f"https://api.telegram.org/bot{bot.token}/setWebhook",
data={"url": webhook_url}
)
print(f"Webhook for {bot.name} ({bot.username}) set to: {webhook_url}")
print("Response:", response.json())

View File

@ -1,249 +1,69 @@
# pxy_bots/views.py
import json
import logging
from typing import Any, Dict, Optional
from telegram import Bot
from django.http import JsonResponse, HttpResponse
from telegram import Update, Bot
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.core.cache import cache
from asgiref.sync import sync_to_async
from .models import TelegramBot
from .router import pick_db_route, post_json
from .renderer import render_spec
from pxy_langchain.services import LangchainAIService
from .handlers import dream_city_command, start, help_command, handle_location
import logging
logger = logging.getLogger(__name__)
# ---------------------------
# Canonical req.v1 builder
# ---------------------------
def _pick_photo(sizes):
if not sizes:
return None
sizes = sorted(sizes, key=lambda s: (s.get("width", 0) * s.get("height", 0)), reverse=True)
top = sizes[0]
return {
"type": "photo",
"file_id": top.get("file_id"),
"mime": "image/jpeg",
"size_bytes": None,
"width": top.get("width"),
"height": top.get("height"),
}
def _extract_media(msg: Dict[str, Any]) -> Optional[Dict[str, Any]]:
if "photo" in msg:
return _pick_photo(msg.get("photo") or [])
if "voice" in msg:
v = msg["voice"]
return {"type": "voice", "file_id": v.get("file_id"), "mime": v.get("mime_type"),
"size_bytes": v.get("file_size"), "duration": v.get("duration")}
if "audio" in msg:
a = msg["audio"]
return {"type": "audio", "file_id": a.get("file_id"), "mime": a.get("mime_type"),
"size_bytes": a.get("file_size"), "duration": a.get("duration")}
if "video" in msg:
v = msg["video"]
return {"type": "video", "file_id": v.get("file_id"), "mime": v.get("mime_type"),
"size_bytes": v.get("file_size"), "duration": v.get("duration"),
"width": v.get("width"), "height": v.get("height")}
if "video_note" in msg:
v = msg["video_note"]
return {"type": "video_note", "file_id": v.get("file_id"), "mime": None,
"size_bytes": v.get("file_size"), "duration": v.get("duration"),
"length": v.get("length")}
if "animation" in msg:
a = msg["animation"]
return {"type": "animation", "file_id": a.get("file_id"), "mime": a.get("mime_type"),
"size_bytes": a.get("file_size")}
if "document" in msg:
d = msg["document"]
return {"type": "document", "file_id": d.get("file_id"), "mime": d.get("mime_type"),
"size_bytes": d.get("file_size"), "file_name": d.get("file_name")}
return None
def build_req_v1(update: Dict[str, Any], bot_name: str) -> Dict[str, Any]:
"""
Normalize a Telegram update into our canonical req.v1 envelope.
Pure function. No network, no state.
"""
schema_version = "req.v1"
update_id = update.get("update_id")
msg = update.get("message") or update.get("edited_message")
cbq = update.get("callback_query")
if msg:
chat = msg.get("chat") or {}
user = msg.get("from") or {}
message_id = msg.get("message_id")
ts = msg.get("date")
text = msg.get("text")
caption = msg.get("caption")
location = msg.get("location")
media = _extract_media(msg)
trigger = "message"
elif cbq:
m = cbq.get("message") or {}
chat = m.get("chat") or {}
user = cbq.get("from") or {}
message_id = m.get("message_id")
ts = m.get("date") or None
text = None
caption = None
location = None
media = None
trigger = "callback"
else:
chat = {}
user = update.get("from") or {}
message_id = None
ts = None
text = None
caption = None
location = None
media = None
trigger = "unknown"
raw_cmd = None
if text and isinstance(text, str) and text.startswith("/"):
raw_cmd = text.split()[0][1:]
elif caption and isinstance(caption, str) and caption.startswith("/"):
raw_cmd = caption.split()[0][1:]
elif cbq and isinstance(cbq.get("data"), str):
raw_cmd = None # callbacks carry 'data' instead
# --- NEW: pseudo-commands for media so we can route from Admin ---
if not raw_cmd:
if media and isinstance(media, dict):
mtype = (media.get("type") or "").lower()
if mtype in {"voice", "audio", "video", "photo", "document", "animation", "video_note"}:
raw_cmd = f"__{mtype}__"
elif location:
raw_cmd = "__location__"
env = {
"schema_version": schema_version,
"bot": {"username": bot_name},
"chat": {"id": chat.get("id"), "type": chat.get("type")},
"user": {"id": user.get("id"), "language": user.get("language_code")},
"command": {
"name": raw_cmd,
"version": 1,
"trigger": ("text_command" if raw_cmd and trigger == "message"
else ("callback" if trigger == "callback" else trigger)),
},
"input": {
"text": text,
"caption": caption,
"args_raw": text or caption,
"media": media,
"location": ({"lat": location.get("latitude"), "lon": location.get("longitude")} if location else None),
},
"callback": (
{"id": cbq.get("id"), "data": cbq.get("data"),
"origin": {"message_id": message_id, "chat_id": chat.get("id")}}
if cbq else None
),
"context": {
"message_id": message_id,
"update_id": update_id,
"ts": ts,
"idempotency_key": f"tg:{message_id}:{user.get('id')}" if message_id and user.get("id") else None,
},
}
return env
# ---------------------------
# Webhook (thin router)
# ---------------------------
@csrf_exempt
async def telegram_webhook(request, bot_name: str):
async def telegram_webhook(request, bot_name):
"""
Webhook view that handles Telegram updates asynchronously and only uses LangChain.
"""
try:
logger.info("Webhook called for bot=%s", bot_name)
logger.info(f"Webhook called for bot: {bot_name}")
if request.method != "POST":
return HttpResponse(status=405)
# Load bot (sync ORM via sync_to_async)
# Step 1: Fetch the bot instance asynchronously
try:
bot_instance = await sync_to_async(TelegramBot.objects.get)(
name=bot_name, is_active=True
)
bot_instance = await sync_to_async(TelegramBot.objects.get)(name=bot_name, is_active=True)
logger.info(f"Loaded bot configuration: {bot_instance}")
except TelegramBot.DoesNotExist:
logger.error(f"Bot '{bot_name}' not found or inactive.")
return JsonResponse({"error": f"Bot '{bot_name}' not found."}, status=400)
# Parse raw payload
# Step 2: Ensure the bot has a LangChain assistant
if not bot_instance.assistant:
logger.error(f"No assistant configured for bot '{bot_name}'.")
return JsonResponse({"error": "Assistant not configured."}, status=400)
# Step 3: Process POST request from Telegram
if request.method == "POST":
try:
payload = json.loads(request.body.decode("utf-8") or "{}")
except json.JSONDecodeError:
return JsonResponse({"ok": False, "error": "invalid_json"}, status=400)
request_body = json.loads(request.body.decode("utf-8"))
update = Update.de_json(request_body, Bot(token=bot_instance.token))
logger.info(f"Update received: {update}")
except json.JSONDecodeError as e:
logger.error(f"Failed to decode JSON: {e}")
return JsonResponse({"error": "Invalid JSON payload"}, status=400)
# ----- Idempotency / retry guard (drops duplicates for ~90s) -----
upd_id = payload.get("update_id")
cbq = payload.get("callback_query") or {}
cbq_id = cbq.get("id")
msg = payload.get("message") or {}
fallback_msg_id = msg.get("message_id")
fallback_user = (msg.get("from") or {}).get("id")
# Step 4: Route commands to the appropriate handlers
if update.message:
if update.message.text == "/start":
await start(update)
elif update.message.text == "/help":
await help_command(update)
elif update.message.text == "/dream_city":
await dream_city_command(update)
elif update.message.location:
await handle_location(update)
else:
# Step 5: Process AI-generated response using LangChain
assistant_instance = await sync_to_async(LangchainAIService)(bot_instance.assistant)
bot_response = await sync_to_async(assistant_instance.generate_response)(update.message.text)
dedupe_key = None
if upd_id is not None:
dedupe_key = f"tg:update:{upd_id}"
elif cbq_id:
dedupe_key = f"tg:cbq:{cbq_id}"
elif fallback_msg_id and fallback_user:
dedupe_key = f"tg:msg:{fallback_msg_id}:{fallback_user}"
# Step 6: Send the response back to Telegram
await update.message.reply_text(bot_response)
if dedupe_key:
if not cache.add(dedupe_key, "1", timeout=90):
logger.info("tg.idempotent.skip key=%s", dedupe_key)
return JsonResponse({"status": "duplicate_skipped"})
# -----------------------------------------------------------------
return JsonResponse({"status": "ok"})
# Build canonical req.v1
try:
canon = build_req_v1(payload, bot_name)
logger.info("tg.canonical env=%s", json.dumps(canon, ensure_ascii=False))
except Exception as e:
logger.exception("tg.canonical.failed: %s", e)
return JsonResponse({"ok": False, "error": "canonical_failed"}, status=400)
# ----- DB-driven route (Admin) -----
route = pick_db_route(bot_name, canon)
if route:
chat_id = (canon.get("chat") or {}).get("id")
status, body = await sync_to_async(post_json)(
route["url"], canon, route.get("timeout", 4), route.get("headers")
)
logger.info("tg.routed(db) url=%s status=%s", route["url"], status)
spec = None
if isinstance(body, dict) and ("messages" in body or body.get("schema_version") == "render.v1"):
spec = body
elif isinstance(body, dict) and "text" in body:
spec = {"schema_version": "render.v1",
"messages": [{"type": "text", "text": str(body["text"])}]}
if spec and chat_id:
bot = Bot(token=bot_instance.token)
await render_spec(bot=bot, chat_id=chat_id, spec=spec)
return JsonResponse({"status": "ok", "routed": True, "status_code": status})
# Nothing matched → 204 (ack, but no action)
logger.warning(
"tg.no_route bot=%s trigger=%s cmd=%s",
bot_name,
(canon.get("command") or {}).get("trigger"),
(canon.get("command") or {}).get("name"),
)
return JsonResponse({"status": "no_route"}, status=204)
logger.warning("Received non-POST request")
return JsonResponse({"error": "Invalid request method"}, status=400)
except Exception as e:
logger.exception("Error in webhook: %s", e)
logger.error(f"Error in webhook: {e}")
return JsonResponse({"error": f"Unexpected error: {str(e)}"}, status=500)

View File

@ -1,3 +0,0 @@
from django.contrib import admin
# Register your models here.

View File

@ -1,6 +0,0 @@
from django.apps import AppConfig
class PxyBuildingDigitalTwinsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'pxy_building_digital_twins'

View File

@ -1,3 +0,0 @@
from django.db import models
# Create your models here.

View File

@ -1,131 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Building Twin — Random Stadium (Backend)</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" rel="stylesheet" crossorigin="">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<style>
html, body { height:100%; margin:0; background:#0b1220; }
#map { position:fixed; inset:0; }
#legend {
position: fixed; top: 12px; left: 12px; z-index: 1000;
background: #0f172a; color: #e5e7eb; padding: 10px 12px; border-radius: 10px;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
box-shadow: 0 10px 30px rgba(0,0,0,.35); font-size: 12px; line-height: 1.25;
}
.pill{display:inline-block; padding:2px 6px; border-radius:999px; margin-right:6px}
.p-field{background:#16a34a;color:#052e16}
.p-stands{background:#64748b;color:#0b1220}
.p-conc{background:#94a3b8;color:#0b1220}
.k-org{background:#0ea5e9} .k-rec{background:#10b981}
.k-lf{background:#8b5cf6} .k-sp{background:#b45309}
.k-dt{background:#7c3aed}
.btn { background:#1f2937; border:1px solid #334155; color:#e5e7eb;
padding:6px 10px; border-radius:8px; cursor:pointer }
.btn:hover { background:#111827; }
</style>
</head>
<body>
<div id="legend">
<div style="font-weight:600; margin-bottom:6px;">Random Stadium (backend)</div>
<div style="margin-bottom:6px">
<span class="pill p-field">Field</span>
<span class="pill p-stands">Stands</span>
<span class="pill p-conc">Concourse</span>
</div>
<div>Bins:
<span class="pill k-rec">recyclable</span>
<span class="pill k-org">organic</span>
<span class="pill k-lf">landfill</span>
<span class="pill k-sp">special</span>
<span class="pill k-dt">dry_toilet</span>
</div>
<div style="opacity:.7; margin-top:6px">
Tip: add <code>?seed=123</code> to make it deterministic.
</div>
<div style="margin-top:8px">
<button id="btnBabylon" class="btn">Open 3D Banorte Stadium</button>
</div>
</div>
<div id="map"></div>
<script>
// Map (local flat plane)
const map = L.map('map', { crs: L.CRS.Simple, minZoom:-5, maxZoom:5, zoomSnap:0.25 }).setView([0,0], 0);
// Faint grid background
const grid = L.gridLayer();
grid.createTile = function(){
const c = document.createElement('canvas'); c.width=256; c.height=256;
const ctx=c.getContext('2d'); ctx.strokeStyle='#1f2937'; ctx.lineWidth=1;
for(let i=0;i<=256;i+=32){ ctx.beginPath(); ctx.moveTo(i,0); ctx.lineTo(i,256); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0,i); ctx.lineTo(256,i); ctx.stroke(); }
return c;
};
grid.addTo(map);
const spacesLayer = L.geoJSON(null, {
style: f => {
const t = (f.properties?.type || "").toLowerCase();
const fill =
t === "field" ? "#16a34a" :
t === "concourse" ? "#94a3b8" :
t === "stands" ? "#64748b" : "#9ca3af";
return { color:"#0b1220", weight:0.6, fillColor:fill, fillOpacity:0.35 };
}
}).addTo(map);
const binsLayer = L.geoJSON(null, {
pointToLayer: (f, ll) => {
const k = (f.properties?.kind || "").toLowerCase();
const fill =
k==="organic" ? "#0ea5e9" :
k==="recyclable" ? "#10b981" :
k==="landfill" ? "#8b5cf6" :
k==="special" ? "#b45309" :
k==="dry_toilet" ? "#7c3aed" :
"#e5e7eb";
return L.circleMarker(ll, { radius:6, color:"#0b1220", weight:1, fillColor:fill, fillOpacity:0.95 });
},
onEachFeature: (f, layer) => {
const p = f.properties || {};
layer.bindPopup(`<b>${p.kind || "bin"}</b><br/>id: ${p.id || "-"}<br/>capacity: ${p.capacity_l ?? "-"} L`);
}
}).addTo(map);
function fetchStadium() {
const seed = new URLSearchParams(location.search).get("seed") || Date.now();
fetch(`/building/api/random/stadium/?seed=${seed}`)
.then(r => r.json())
.then(data => {
spacesLayer.clearLayers().addData(data.spaces);
binsLayer.clearLayers().addData(data.bins);
const group = L.featureGroup([spacesLayer, binsLayer]);
try { map.fitBounds(group.getBounds().pad(0.12)); } catch(e){}
})
.catch(err => console.error("Failed to fetch stadium:", err));
}
// Auto-generate on load
fetchStadium();
// Open Babylon 3D viewer, preserving current query params
document.getElementById('btnBabylon').onclick = () => {
const params = new URLSearchParams(location.search);
if (!params.has('seed')) params.set('seed', Date.now()); // keep deterministic if you passed one
// Change the path below if your Babylon route differs
location.href = `/building/twin/wire/babylon/?${params.toString()}`;
};
</script>
</body>
</html>

View File

@ -1,262 +0,0 @@
{% load static %}
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Building Twin — Wireframe Stadium (Real Size + Smart Can)</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- A-Frame -->
<script src="https://aframe.io/releases/1.4.1/aframe.min.js"></script>
<style>
html, body { height:100%; margin:0; background:#0b1220; color:#e5e7eb; font-family: system-ui, sans-serif; }
#hud {
position: fixed; top: 10px; left: 10px; z-index: 1000;
background:#0f172a; padding:10px 12px; border-radius:10px; box-shadow:0 10px 30px rgba(0,0,0,.35);
font-size:12px; line-height:1.25; max-width: 560px;
}
code { background:#111827; padding:0 4px; border-radius:4px }
</style>
</head>
<body>
<div id="hud">
<div style="font-weight:600; margin-bottom:6px">Wireframe Stadium (real meters)</div>
<div>Use <code>?scale=2</code> (double), <code>?scale=0.5</code> (half). Field defaults to 105×68 m (override with <code>?field_w=&field_h=</code>).</div>
<div>Smart can params: <code>?can_units=m|cm|mm</code>, <code>?can_scale=1</code>, <code>?can_angle=45</code>, <code>?can_y=0</code>, <code>?can_face_center=1</code>, optional <code>?can_tri=1</code> to download triangles JSON.</div>
<div style="opacity:.75; margin-top:6px">WASD to move, drag to look. Enter VR to feel the 1:1 scale.</div>
</div>
<a-scene background="color: #0b1220"
renderer="antialias:false; powerPreference:high-performance; physicallyCorrectLights:false; logarithmicDepthBuffer:true">
<!-- Assets (GLB fast path; OBJ fallback) -->
<a-assets timeout="30000">
<a-asset-item id="smartcan-glb" src="{% static 'pxy_building_digital_twins/models/smartcan.glb' %}"></a-asset-item>
<a-asset-item id="smartcan-obj" src="{% static 'pxy_building_digital_twins/models/smartcan.obj' %}"></a-asset-item>
</a-assets>
<!-- Lights -->
<a-entity light="type: ambient; intensity: 0.6; color: #ffffff"></a-entity>
<a-entity light="type: directional; intensity: 0.7; color: #ffffff" position="50 80 40"></a-entity>
<!-- Camera rig at human eye height -->
<a-entity id="rig" position="0 1.6 180">
<a-entity camera look-controls wasd-controls="acceleration: 40" position="0 0 0"></a-entity>
</a-entity>
<!-- Ground grid (reference) -->
<a-entity id="grid"></a-entity>
<!-- Stadium parts -->
<a-entity id="stadium"></a-entity>
<a-entity id="bins"></a-entity>
</a-scene>
<script>
// ---------- URL params ----------
const q = new URLSearchParams(location.search);
const SCALE = Math.max(0.1, Math.min(5, parseFloat(q.get('scale') || '1'))); // 0.1x .. 5x
const FIELD_W = parseFloat(q.get('field_w') || '105'); // meters (FIFA)
const FIELD_H = parseFloat(q.get('field_h') || '68');
// ---------- Seeded RNG (deterministic with ?seed=) ----------
function mulberry32(a){ return function(){ var t=a+=0x6D2B79F5; t=Math.imul(t^t>>>15,t|1);
t^=t+Math.imul(t^t>>>7,t|61); return ((t^t>>>14)>>>0)/4294967296; } }
const urlSeed = q.get("seed");
const seedNum = urlSeed ? Array.from(String(urlSeed)).reduce((s,ch)=>s+ch.charCodeAt(0),0) : Math.floor(Math.random()*1e9);
const rand = mulberry32(seedNum);
const randf = (a,b)=> a + (b-a)*rand();
const randi = (a,b)=> Math.floor(randf(a,b+1));
// ---------- A-Frame helpers ----------
AFRAME.registerComponent('wire-ellipse', {
schema: { rx:{type:'number'}, ry:{type:'number'}, y:{type:'number',default:0},
seg:{type:'int',default:256}, color:{type:'color',default:'#93a3b8'}, width:{type:'number',default:1} },
init: function () {
const THREE = AFRAME.THREE, d = this.data;
const geom = new THREE.BufferGeometry(); const pts = [];
for(let i=0;i<d.seg;i++){ const a = i/d.seg * Math.PI*2; pts.push(d.rx*Math.cos(a), d.y, d.ry*Math.sin(a)); }
pts.push(pts[0], pts[1], pts[2]); // close
geom.setAttribute('position', new THREE.BufferAttribute(new Float32Array(pts), 3));
const mat = new THREE.LineBasicMaterial({ color: d.color, linewidth: d.width });
this.el.setObject3D('mesh', new THREE.Line(geom, mat));
},
remove: function(){ const o=this.el.getObject3D('mesh'); if(o){ o.geometry.dispose(); o.material.dispose(); this.el.removeObject3D('mesh'); } }
});
function addWireBox(parent, w, h, t, y, color){ // w=width (x), h=depth (z), t=thickness (y)
const e = document.createElement('a-box');
e.setAttribute('width', w); e.setAttribute('depth', h); e.setAttribute('height', t);
e.setAttribute('position', `0 ${y} 0`);
e.setAttribute('material', `wireframe: true; color: ${color}; opacity: 0.65; transparent: true`);
parent.appendChild(e); return e;
}
function addBin(parent, x, y, z, color='#10b981'){
const s = document.createElement('a-sphere');
s.setAttribute('radius', 0.6 * SCALE); // ~60cm glowing puck
s.setAttribute('position', `${x} ${y} ${z}`);
s.setAttribute('material', `color: ${color}; emissive: ${color}; emissiveIntensity: 0.9`);
parent.appendChild(s); return s;
}
// Smart can loader (GLB first, OBJ fallback)
function addSmartCan(parent, x, y, z, scale=1, rotY=0){
const e = document.createElement('a-entity');
if (document.getElementById('smartcan-glb')) {
e.setAttribute('gltf-model', '#smartcan-glb'); // FAST PATH
} else if (document.getElementById('smartcan-obj')) {
e.setAttribute('obj-model', 'obj: #smartcan-obj'); // fallback (no MTL)
e.setAttribute('material', 'color: #9ca3af; metalness: 0.2; roughness: 0.6');
} else {
// last-resort placeholder
e.setAttribute('geometry','primitive:cylinder; radius:0.35; height:1.0');
e.setAttribute('material','color:#9ca3af');
}
e.setAttribute('position', `${x} ${y} ${z}`);
e.setAttribute('rotation', `0 ${rotY} 0`);
e.setAttribute('scale', `${scale} ${scale} ${scale}`);
parent.appendChild(e);
return e;
}
// Optional: extract triangles from loaded model (works for GLB & OBJ)
AFRAME.registerComponent('extract-triangles', {
schema: { download:{type:'boolean', default:true}, log:{type:'boolean', default:true} },
init() {
this.onLoaded = () => {
const root = this.el.getObject3D('mesh'); if (!root) return;
const tris = [];
root.traverse(n => {
if (!n.isMesh || !n.geometry) return;
let g = n.geometry;
if (g.index) g = g.toNonIndexed();
const pos = g.getAttribute('position'); if (!pos) return;
const a = pos.array;
for (let i=0;i<a.length;i+=9){
tris.push([[a[i],a[i+1],a[i+2]],[a[i+3],a[i+4],a[i+5]],[a[i+6],a[i+7],a[i+8]]]);
}
});
if (this.data.log) console.log(`extract-triangles: ${tris.length} triangles`, tris);
if (this.data.download){
const blob = new Blob([JSON.stringify({model:'smartcan',triangles:tris})],{type:'application/json'});
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement('a'), {href:url, download:'smartcan_triangles.json'});
document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(url), 2000);
}
};
this.el.addEventListener('model-loaded', this.onLoaded);
},
remove(){ this.el.removeEventListener('model-loaded', this.onLoaded); }
});
// Grid in meters
(function makeGrid(){
const e = document.querySelector('#grid');
const size=500*SCALE, step=5*SCALE, y=0;
for(let x=-size; x<=size; x+=step){ const l=document.createElement('a-entity');
l.setAttribute('line', `start: ${x} ${y} ${-size}; end: ${x} ${y} ${size}; color: #1f2937`); e.appendChild(l); }
for(let z=-size; z<=size; z+=step){ const l=document.createElement('a-entity');
l.setAttribute('line', `start: ${-size} ${y} ${z}; end: ${size} ${y} ${z}; color: #1f2937`); e.appendChild(l); }
})();
// ---------- Build at real scale (meters) ----------
(function build(){
// Clean previous geometry if re-running
const stadium = document.querySelector('#stadium');
const bins = document.querySelector('#bins');
while (stadium.firstChild) stadium.removeChild(stadium.firstChild);
while (bins.firstChild) bins.removeChild(bins.firstChild);
// Heights (meters)
const Y_FIELD = 0.20*SCALE; // field mesh lift
const Y_CONC = 8.0*SCALE; // concourse level
const Y_STAND = 22.0*SCALE; // upper stands level (wire)
const EYE_H = 1.6*SCALE; // human eye height
// Field (exact meters)
const fieldW = FIELD_W * SCALE;
const fieldH = FIELD_H * SCALE;
// Derive rings from field size (meters)
const safeX = (FIELD_W/2 + randf(6,10)) * SCALE;
const safeZ = (FIELD_H/2 + randf(6,10)) * SCALE;
const standsInRX = safeX + randf(12,18)*SCALE;
const standsInRZ = safeZ + randf(12,18)*SCALE;
const concInRX = standsInRX + randf(4,6)*SCALE; // concourse corridor inside
const concInRZ = standsInRZ + randf(4,6)*SCALE;
const concOutRX = concInRX + randf(6,10)*SCALE; // concourse corridor outside
const concOutRZ = concInRZ + randf(6,10)*SCALE;
const standsOutRX = standsInRX + randf(35,45)*SCALE;
const standsOutRZ = standsInRZ + randf(35,45)*SCALE;
// Field (wireframe box)
addWireBox(stadium, fieldW, fieldH, 0.4*SCALE, Y_FIELD, '#16a34a');
// Concourse inner/outer
const concInner = document.createElement('a-entity');
concInner.setAttribute('wire-ellipse', { rx: concInRX, ry: concInRZ, y: Y_CONC, color:'#22d3ee' });
const concOuter = document.createElement('a-entity');
concOuter.setAttribute('wire-ellipse', { rx: concOutRX, ry: concOutRZ, y: Y_CONC, color:'#38bdf8' });
stadium.appendChild(concInner); stadium.appendChild(concOuter);
// Stands inner/outer (higher)
const standInner = document.createElement('a-entity');
standInner.setAttribute('wire-ellipse', { rx: standsInRX, ry: standsInRZ, y: Y_STAND, color:'#a78bfa' });
const standOuter = document.createElement('a-entity');
standOuter.setAttribute('wire-ellipse', { rx: standsOutRX, ry: standsOutRZ, y: Y_STAND, color:'#60a5fa' });
stadium.appendChild(standInner); stadium.appendChild(standOuter);
// Ribs
const ribs = randi(14, 22);
for(let i=0;i<ribs;i++){
const a = i/ribs * Math.PI*2 + randf(-0.02,0.02);
const x1 = concOutRX * Math.cos(a), z1 = concOutRZ * Math.sin(a);
const x2 = standsInRX * Math.cos(a), z2 = standsInRZ * Math.sin(a);
const rib = document.createElement('a-entity');
rib.setAttribute('line', `start: ${x1} ${Y_CONC} ${z1}; end: ${x2} ${Y_STAND} ${z2}; color: #14b8a6`);
stadium.appendChild(rib);
}
// Bins around concourse midline
const midRX = (concInRX + concOutRX)/2, midRZ = (concInRZ + concOutRZ)/2;
const binCount = randi(44, 64);
const colors = ['#10b981', '#0ea5e9', '#8b5cf6', '#f59e0b'];
for(let i=0;i<binCount;i++){
const a = i/binCount * Math.PI*2 + randf(-0.03,0.03);
const x = midRX * Math.cos(a), z = midRZ * Math.sin(a);
addBin(bins, x, Y_CONC+0.25*SCALE, z, colors[i % colors.length]);
}
// --- Smart can (GLB fast path; OBJ fallback) ---
const units = (q.get('can_units') || 'm').toLowerCase(); // m|cm|mm
const canScaleUser = parseFloat(q.get('can_scale') || '1');
const canAngleDeg = parseFloat(q.get('can_angle') || (Math.random()*360).toFixed(1));
const canYOffset = parseFloat(q.get('can_y') || '0'); // meters
const faceCenter = (q.get('can_face_center') || '1') !== '0';
const unitToMeter = units==='cm' ? 0.01 : units==='mm' ? 0.001 : 1;
const canScaleFinal = SCALE * unitToMeter * canScaleUser;
const ang = canAngleDeg * Math.PI/180;
const canX = midRX * Math.cos(ang);
const canZ = midRZ * Math.sin(ang);
const rotY = faceCenter ? -canAngleDeg : 0;
const can = addSmartCan(stadium, canX, Y_CONC + canYOffset, canZ, canScaleFinal, rotY);
// Optional triangle dump: add ?can_tri=1 to URL
if (q.get('can_tri') === '1') {
can.setAttribute('extract-triangles', 'download:true; log:true');
}
// Start the rig at eye height just outside the outer stands, facing center
const startZ = Math.max(standsOutRX, standsOutRZ) + 15*SCALE;
document.querySelector('#rig').setAttribute('position', `0 ${EYE_H} ${startZ}`);
})();
</script>
</body>
</html>

View File

@ -1,392 +0,0 @@
{% load static %}
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Building Twin — Babylon Wireframe Stadium + Smart Can</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Babylon core + loaders (for GLB/GLTF/OBJ) -->
<script src="https://cdn.babylonjs.com/babylon.js"></script>
<script src="https://cdn.babylonjs.com/loaders/babylonjs.loaders.min.js"></script>
<style>
html, body { height:100%; margin:0; background:#0b1220; font-family:system-ui, sans-serif; }
#c {
position: fixed;
left: 0; top: 0;
width: 100vw; height: 100vh; /* fill viewport */
display: block;
}
#hud {
position: fixed; bottom: 10px; left: 10px; z-index: 10; color: #e5e7eb;
background:#0f172a; padding:10px 12px; border-radius:10px; box-shadow:0 10px 30px rgba(0,0,0,.35);
font-size:12px; line-height:1.25; width: 300px;
}
.row { display:flex; gap:8px; margin-top:6px; }
.row > * { flex:1; }
label { display:flex; align-items:center; gap:6px; cursor:pointer; }
button { width:100%; background:#1f2937; color:#e5e7eb; border:1px solid #334155;
border-radius:8px; padding:6px 8px; cursor:pointer; }
button:hover { background:#111827; }
.muted { opacity:.75 }
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="hud">
<div style="font-weight:600; margin-bottom:6px;">Wireframe Stadium (Babylon) + Smart Can</div>
<div class="muted">Orbit with mouse/touch. Drag bins on the floor.</div>
<div class="row" style="margin-top:8px;">
<label><input type="checkbox" id="chkL1" checked> L1 Concourse</label>
<label><input type="checkbox" id="chkL2" checked> L2 Mezzanine</label>
</div>
<div class="row">
<label><input type="checkbox" id="chkL3" checked> L3 Upper Stands</label>
<label><input type="checkbox" id="chkBins" checked> Bins</label>
</div>
<div class="row" style="margin-top:8px;">
<button id="btnRegen">Regenerate</button>
<button id="btnResetCam">Reset Camera</button>
<button id="btnViewer">Open 2D Banorte Stadium</button>
</div>
<div class="muted" style="margin-top:6px;">
URL knobs: <code>?seed=123&can_units=m|cm|mm&can_scale=1&can_angle=45&can_y=0</code>
</div>
</div>
<script>
// ---------- Params ----------
const params = new URLSearchParams(location.search);
const canUnits = (params.get('can_units') || 'm').toLowerCase(); // m|cm|mm
const canScaleUser = parseFloat(params.get('can_scale') || '1');
const canAngleDeg = parseFloat(params.get('can_angle') || (Math.random()*360).toFixed(1));
const canYOffset = parseFloat(params.get('can_y') || '0');
const unitToMeter = canUnits === 'cm' ? 0.01 : canUnits === 'mm' ? 0.001 : 1;
const CAN_GLb_URL = "{% static 'pxy_building_digital_twins/models/smartcan.glb' %}";
const CAN_OBJ_URL = "{% static 'pxy_building_digital_twins/models/smartcan.obj' %}";
// ---------- Engine / Scene ----------
const canvas = document.getElementById('c');
const engine = new BABYLON.Engine(canvas, true, { antialias:true, preserveDrawingBuffer:true, stencil:true });
const scene = new BABYLON.Scene(engine);
function fitCanvas(){ canvas.style.width='100vw'; canvas.style.height='100vh'; canvas.width=canvas.clientWidth; canvas.height=canvas.clientHeight; engine.resize(); }
fitCanvas(); window.addEventListener('resize', fitCanvas);
scene.clearColor = new BABYLON.Color4(0.043,0.071,0.125,1.0);
// Camera
const camera = new BABYLON.ArcRotateCamera("cam", Math.PI*1.25, 1.05, 220, new BABYLON.Vector3(0,10,0), scene);
camera.attachControl(canvas, true);
camera.lowerRadiusLimit = 40; camera.upperRadiusLimit = 2000;
// Lights
const hemi = new BABYLON.HemisphericLight("h", new BABYLON.Vector3(0,1,0), scene);
hemi.intensity = 0.9;
const dir = new BABYLON.DirectionalLight("d", new BABYLON.Vector3(-1,-2,-1), scene);
dir.position = new BABYLON.Vector3(120,180,120); dir.intensity = 0.7;
// Axes
const SHOW_AXES = false;
if (SHOW_AXES){
(function axes(len=30){
const mk=(a,b,c,col)=>{const l=BABYLON.MeshBuilder.CreateLines("ax",{points:[a,b,c]},scene); l.color=col; l.isPickable=false; return l;};
mk(new BABYLON.Vector3(0,0,0), new BABYLON.Vector3(len,0,0), new BABYLON.Vector3(len,0,0), new BABYLON.Color3(1,0.2,0.2));
mk(new BABYLON.Vector3(0,0,0), new BABYLON.Vector3(0,len,0), new BABYLON.Vector3(0,len,0), new BABYLON.Color3(0.2,1,0.2));
mk(new BABYLON.Vector3(0,0,0), new BABYLON.Vector3(0,0,len), new BABYLON.Vector3(0,0,len), new BABYLON.Color3(0.3,0.7,1));
})();
}
// Subtle grid
(function grid(){
const size=1000, step=10, y=0, col=new BABYLON.Color3(0.18,0.22,0.29);
for(let x=-size; x<=size; x+=step){
const l=BABYLON.MeshBuilder.CreateLines("gx",{points:[new BABYLON.Vector3(x,y,-size),new BABYLON.Vector3(x,y,size)]},scene);
l.color=col; l.isPickable=false;
}
for(let z=-size; z<=size; z+=step){
const l=BABYLON.MeshBuilder.CreateLines("gz",{points:[new BABYLON.Vector3(-size,y,z),new BABYLON.Vector3(size,y,z)]},scene);
l.color=col; l.isPickable=false;
}
})();
// ---------- RNG ----------
function mulberry32(a){ return function(){ var t=a+=0x6D2B79F5; t=Math.imul(t^t>>>15,t|1); t^=t+Math.imul(t^t>>>7,t|61); return ((t^t>>>14)>>>0)/4294967296; } }
function makeRand(seedStr){
const s = seedStr ? Array.from(String(seedStr)).reduce((acc,ch)=>acc+ch.charCodeAt(0),0) : Math.floor(Math.random()*1e9);
const r = mulberry32(s); return { f:(a,b)=>a+(b-a)*r(), i:(a,b)=>Math.floor(a+(b-a+1)*r()), s };
}
const querySeed = new URLSearchParams(location.search).get("seed");
// ---------- Helpers ----------
function wireRect(name, w, d, y, color3){
const hw = w/2, hd = d/2;
const pts = [
new BABYLON.Vector3(-hw, y, -hd),
new BABYLON.Vector3( hw, y, -hd),
new BABYLON.Vector3( hw, y, hd),
new BABYLON.Vector3(-hw, y, hd),
new BABYLON.Vector3(-hw, y, -hd) // close loop
];
const ln = BABYLON.MeshBuilder.CreateLines(name, { points: pts }, scene);
ln.color = color3; ln.isPickable = false;
return ln;
}
function wireBox(name,w,d,h,y,color3){
const box=BABYLON.MeshBuilder.CreateBox(name,{width:w,depth:d,height:h},scene);
const mat=new BABYLON.StandardMaterial(name+"_m",scene); mat.wireframe=true; mat.diffuseColor=color3; mat.specularColor=BABYLON.Color3.Black();
box.material=mat; box.position.y=y; box.isPickable=false; return box;
}
function wireEllipse(name,rx,rz,y,seg,color3){
const pts=[]; for(let i=0;i<=seg;i++){const a=(i/seg)*Math.PI*2; pts.push(new BABYLON.Vector3(rx*Math.cos(a),y,rz*Math.sin(a))); }
const ln=BABYLON.MeshBuilder.CreateLines(name,{points:pts,updatable:false},scene); ln.color=color3; ln.isPickable=false; return ln;
}
function addRib(name,x1,y1,z1,x2,y2,z2,color3){
const ln=BABYLON.MeshBuilder.CreateLines(name,{points:[new BABYLON.Vector3(x1,y1,z1),new BABYLON.Vector3(x2,y2,z2)]},scene);
ln.color=color3; ln.isPickable=false; return ln;
}
function addBin(name,x,y,z,color3){
const s=BABYLON.MeshBuilder.CreateSphere(name,{diameter:2.8,segments:10},scene);
const mat=new BABYLON.StandardMaterial(name+"_m",scene); mat.emissiveColor=color3; mat.diffuseColor=color3.scale(0.35); s.material=mat;
s.position.set(x,y,z);
const drag=new BABYLON.PointerDragBehavior({dragPlaneNormal:new BABYLON.Vector3(0,1,0)}); s.addBehavior(drag);
return s;
}
// Groups
const L1=new BABYLON.TransformNode("L1_Concourse",scene);
const L2=new BABYLON.TransformNode("L2_Mezzanine",scene);
const L3=new BABYLON.TransformNode("L3_Upper",scene);
const BINS=new BABYLON.TransformNode("Bins",scene);
const CAN_PARENT=new BABYLON.TransformNode("SmartCan",scene);
// Camera framing
function frameCamera(maxRadius,yCenter){ camera.alpha=Math.PI*1.25; camera.beta=1.05; camera.radius=Math.max(120,maxRadius*2.2); camera.setTarget(new BABYLON.Vector3(0,yCenter,0)); }
// ---------- Smart Can loader ----------
function splitURL(u){ const i=u.lastIndexOf('/'); return {root:u.slice(0,i+1), file:u.slice(i+1)}; }
function addMarker(x, y, z) {
// vertical neon line + torus ring
const ring = BABYLON.MeshBuilder.CreateTorus("canRing", {diameter: 8, thickness: 0.6, tessellation: 32}, scene);
const matR = new BABYLON.StandardMaterial("canRingM", scene); matR.emissiveColor = new BABYLON.Color3(1, 0.5, 0.1);
ring.material = matR; ring.position.set(x, y, z);
const line = BABYLON.MeshBuilder.CreateLines("canLine", {points:[
new BABYLON.Vector3(x, y, z),
new BABYLON.Vector3(x, y + 10, z)
]}, scene);
line.color = new BABYLON.Color3(1, 0.8, 0.2);
line.isPickable = false;
return ring;
}
async function loadSmartCanAt(x, y, z, yawDeg, scaleMeters){
const qs = new URLSearchParams(location.search);
const desiredH = parseFloat(qs.get("can_h") || "1.1"); // meters
const wireOn = (qs.get("can_wire") ?? "1") !== "0";
// NEW: manual rotation overrides (degrees)
const rxDeg = parseFloat(qs.get("can_rx") || "90");
const ryDeg = parseFloat(qs.get("can_ry") || "0");
const rzDeg = parseFloat(qs.get("can_rz") || "0");
const urls = [
"{% static 'pxy_building_digital_twins/models/smartcan.glb' %}",
"{% static 'pxy_building_digital_twins/models/smartcan.obj' %}"
];
async function tryUrl(url){
try { return (await BABYLON.SceneLoader.ImportMeshAsync("", "", url, scene)).meshes; }
catch {
const i = url.lastIndexOf("/"); const root = url.slice(0,i+1), file = url.slice(i+1);
try { return (await BABYLON.SceneLoader.ImportMeshAsync("", root, file, scene)).meshes; } catch { return null; }
}
}
let loaded=null;
for (const u of urls){ loaded = await tryUrl(u); if (loaded && loaded.length) break; }
if (!loaded){ console.error("[smartcan] FAILED to load"); return; }
// Parent & visible
loaded.forEach(m => { if (!m.parent) m.parent = CAN_PARENT; if (!m.material){ const t=new BABYLON.StandardMaterial("canWhite",scene); t.emissiveColor=new BABYLON.Color3(0.95,0.95,0.95); t.diffuseColor=t.emissiveColor; m.material=t; } });
// Helper to compute bounds in current world transform
const bounds = () => {
let min=new BABYLON.Vector3(+Infinity,+Infinity,+Infinity),
max=new BABYLON.Vector3(-Infinity,-Infinity,-Infinity);
loaded.forEach(m => {
const bi = m.getBoundingInfo?.(); if (!bi) return;
min = BABYLON.Vector3.Minimize(min, bi.boundingBox.minimumWorld);
max = BABYLON.Vector3.Maximize(max, bi.boundingBox.maximumWorld);
});
return {min, max, size: max.subtract(min)};
};
// Reset transforms
CAN_PARENT.position.set(0,0,0);
CAN_PARENT.rotationQuaternion = BABYLON.Quaternion.Identity();
CAN_PARENT.scaling.setAll(1);
// Auto-orient to make the tallest axis become Y
const cands = [
BABYLON.Quaternion.Identity(),
BABYLON.Quaternion.FromEulerAngles(Math.PI/2, 0, 0),
BABYLON.Quaternion.FromEulerAngles(-Math.PI/2, 0, 0),
BABYLON.Quaternion.FromEulerAngles(0, 0, Math.PI/2),
BABYLON.Quaternion.FromEulerAngles(0, 0, -Math.PI/2),
BABYLON.Quaternion.FromEulerAngles(Math.PI, 0, 0),
BABYLON.Quaternion.FromEulerAngles(0, 0, Math.PI)
];
let bestQ = cands[0], bestH = -1;
for (const q of cands){
CAN_PARENT.rotationQuaternion = q;
const b = bounds();
if (b.size.y > bestH){ bestH = b.size.y; bestQ = q; }
}
CAN_PARENT.rotationQuaternion = bestQ;
// Auto-scale height to desiredH, then apply user scaleMeters
let b0 = bounds();
const scaleAuto = b0.size.y > 0 ? desiredH / b0.size.y : 1;
const finalScale = scaleMeters * scaleAuto;
CAN_PARENT.scaling.setAll(finalScale);
// NEW: apply manual Euler corrections (degrees) BEFORE yaw
const rFix = BABYLON.Quaternion.FromEulerAngles(
BABYLON.Angle.FromDegrees(rxDeg).radians(),
BABYLON.Angle.FromDegrees(ryDeg).radians(),
BABYLON.Angle.FromDegrees(rzDeg).radians()
);
CAN_PARENT.rotationQuaternion = CAN_PARENT.rotationQuaternion.multiply(rFix);
// Apply yaw facing
const yawQ = BABYLON.Quaternion.FromEulerAngles(0, BABYLON.Angle.FromDegrees(yawDeg).radians(), 0);
CAN_PARENT.rotationQuaternion = CAN_PARENT.rotationQuaternion.multiply(yawQ);
// Recompute bounds AFTER all rotations+scaling, then sit the base on the floor
const b1 = bounds();
const lift = -b1.min.y; // already in world units now
CAN_PARENT.position.set(x, y + lift, z);
// Optional wireframe overlay
if (wireOn){
loaded.forEach(m => {
if (!m.geometry) return;
const wf = m.clone(m.name+"_wf");
const mat = new BABYLON.StandardMaterial("wfM", scene);
mat.wireframe = true; mat.emissiveColor = new BABYLON.Color3(0.05,0.05,0.05);
wf.material = mat; wf.isPickable = false;
wf.parent = m.parent;
wf.scaling = m.scaling.multiply(new BABYLON.Vector3(1.001,1.001,1.001));
wf.rotationQuaternion = m.rotationQuaternion?.clone() || BABYLON.Quaternion.Identity();
wf.position = m.position.clone();
});
}
}
// ---------- Builder ----------
function buildStadium(seedStr){
[L1,L2,L3,BINS].forEach(n => n.getChildren().slice().forEach(ch => ch.dispose()));
CAN_PARENT.getChildren().slice().forEach(ch => ch.dispose());
const R=makeRand(seedStr), rf=R.f, ri=R.i;
// Heights
const Y_FIELD=0.2, Y_CONC=2.0, Y_MEZZ=8.0, Y_STAND=14.0;
// Field
const FIELD_W=rf(100,110), FIELD_D=rf(64,72);
wireRect("field", FIELD_W, FIELD_D, Y_FIELD, BABYLON.Color3.FromHexString("#16a34a"));
// Rings
const CONC_RX_IN=rf(62,68), CONC_RZ_IN=rf(56,64);
const CONC_RX_OUT=CONC_RX_IN+rf(6,10), CONC_RZ_OUT=CONC_RZ_IN+rf(6,10);
const MEZZ_RX_IN=CONC_RX_OUT+rf(2,4), MEZZ_RZ_IN=CONC_RZ_OUT+rf(2,4);
const MEZZ_RX_OUT=MEZZ_RX_IN+rf(8,12), MEZZ_RZ_OUT=MEZZ_RZ_IN+rf(8,12);
const STANDS_RX_IN=MEZZ_RX_OUT+rf(3,5), STANDS_RZ_IN=MEZZ_RZ_OUT+rf(3,5);
const STANDS_RX_OUT=STANDS_RX_IN+rf(18,26), STANDS_RZ_OUT=STANDS_RZ_IN+rf(18,26);
wireEllipse("conc_in", CONC_RX_IN, CONC_RZ_IN, Y_CONC, 256, new BABYLON.Color3(0.10,0.94,0.86)).parent=L1;
wireEllipse("conc_out", CONC_RX_OUT, CONC_RZ_OUT, Y_CONC, 256, new BABYLON.Color3(0.00,0.75,1.00)).parent=L1;
wireEllipse("mezz_in", MEZZ_RX_IN, MEZZ_RZ_IN, Y_MEZZ, 256, new BABYLON.Color3(0.98,0.84,0.22)).parent=L2;
wireEllipse("mezz_out", MEZZ_RX_OUT, MEZZ_RZ_OUT, Y_MEZZ, 256, new BABYLON.Color3(1.00,0.66,0.10)).parent=L2;
wireEllipse("stand_in", STANDS_RX_IN, STANDS_RZ_IN, Y_STAND, 256, new BABYLON.Color3(0.80,0.52,1.00)).parent=L3;
wireEllipse("stand_out", STANDS_RX_OUT, STANDS_RZ_OUT, Y_STAND, 256, new BABYLON.Color3(0.62,0.82,1.00)).parent=L3;
// Ribs
const ribCol=new BABYLON.Color3(0.10,0.85,0.75);
const ribs1=ri(12,18);
for(let i=0;i<ribs1;i++){
const a=i/ribs1*Math.PI*2+rf(-0.03,0.03);
const x1=CONC_RX_OUT*Math.cos(a), z1=CONC_RZ_OUT*Math.sin(a);
const x2=MEZZ_RX_IN*Math.cos(a), z2=MEZZ_RZ_IN*Math.sin(a);
const ln=addRib("rib12_"+i, x1,Y_CONC,z1, x2,Y_MEZZ,z2, ribCol); ln.parent=L1;
}
const ribs2=ri(12,18);
for(let i=0;i<ribs2;i++){
const a=i/ribs2*Math.PI*2+rf(-0.03,0.03);
const x1=MEZZ_RX_OUT*Math.cos(a), z1=MEZZ_RZ_OUT*Math.sin(a);
const x2=STANDS_RX_IN*Math.cos(a), z2=STANDS_RZ_IN*Math.sin(a);
const ln=addRib("rib23_"+i, x1,Y_MEZZ,z1, x2,Y_STAND,z2, ribCol); ln.parent=L2;
}
// Bins
const midRX=(CONC_RX_IN+CONC_RX_OUT)/2, midRZ=(CONC_RZ_IN+CONC_RZ_OUT)/2;
const colors=[new BABYLON.Color3(0.20,1.00,0.65), new BABYLON.Color3(0.10,0.80,1.00), new BABYLON.Color3(0.95,0.55,1.00), new BABYLON.Color3(1.00,0.70,0.25)];
const count=ri(40,60);
for(let i=0;i<count;i++){
const a=i/count*Math.PI*2+rf(-0.05,0.05);
const x=midRX*Math.cos(a), z=midRZ*Math.sin(a);
const s=addBin("bin_"+i, x, Y_CONC+0.5, z, colors[i%colors.length]); s.parent=BINS;
}
const angleRad = canAngleDeg * Math.PI/180;
const canX = midRX * Math.cos(angleRad);
const canZ = midRZ * Math.sin(angleRad);
const canScaleMeters = unitToMeter * canScaleUser;
// 🔶 drop a neon marker where the can should be (so you know the spot)
addMarker(canX, Y_CONC + canYOffset, canZ);
// log what were about to do
console.log("[smartcan] pos=", {x: canX, y: Y_CONC + canYOffset, z: canZ},
"yawDeg=", -canAngleDeg, "scaleMeters=", canScaleMeters);
// load the model there
loadSmartCanAt(canX, Y_CONC + canYOffset, canZ, -canAngleDeg, canScaleMeters);
// Frame camera
const maxR=Math.max(STANDS_RX_OUT, STANDS_RZ_OUT);
frameCamera(maxR, (Y_CONC + Y_STAND)/2);
}
// Build + UI
buildStadium(new URLSearchParams(location.search).get("seed"));
const $=q=>document.querySelector(q);
$("#chkL1").onchange=e=>L1.setEnabled(e.target.checked);
$("#chkL2").onchange=e=>L2.setEnabled(e.target.checked);
$("#chkL3").onchange=e=>L3.setEnabled(e.target.checked);
$("#chkBins").onchange=e=>BINS.setEnabled(e.target.checked);
$("#btnRegen").onclick=()=>buildStadium(Math.floor(Math.random()*1e9).toString());
$("#btnResetCam").onclick=()=>frameCamera(200,10);
engine.runRenderLoop(()=>scene.render());
window.addEventListener('resize',()=>engine.resize());
// Open Leaflet viewer, preserving current query params
document.getElementById('btnViewer').onclick = () => {
const params = new URLSearchParams(location.search);
if (!params.has('seed')) params.set('seed', Date.now()); // keep determinism if you provided one
location.href = `/building/twin/viewer/?${params.toString()}`;
};
</script>
</body>
</html>

View File

@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

View File

@ -1,13 +0,0 @@
# pxy_building_digital_twins/urls.py
from django.urls import path
from . import views
# 👇 Register a namespace for reverse()
app_name = "pxy_building_digital_twins"
urlpatterns = [
path("twin/viewer/", views.viewer, name="viewer"),
path("api/random/stadium/", views.api_random_stadium, name="api_random_stadium"),
path("twin/wire/", views.wire_viewer, name="wire_viewer"),
path("twin/wire/babylon/", views.wire_babylon, name="wire_babylon"),
]

Some files were not shown because too many files have changed in this diff Show More