diff --git a/pxy_bots/router.py b/pxy_bots/router.py new file mode 100644 index 0000000..d0bc944 --- /dev/null +++ b/pxy_bots/router.py @@ -0,0 +1,102 @@ +# pxy_bots/router.py +import json +import logging +from typing import Dict, Optional, Tuple +from urllib.parse import urlparse + +logger = logging.getLogger(__name__) + +# --- allowlist of outbound hosts (adjust as needed) --- +ALLOWED_FORWARD_HOSTS = {"127.0.0.1", "localhost", "app.polisplexity.tech"} + +# --- minimal route map (in-memory) --- +# Per-bot mapping. Keys inside are command names WITHOUT leading slash. +# Special keys: +# "_default" β†’ used when no command name detected (plain text) +# "_callback" β†’ used for callback_query events +ROUTE_MAP: Dict[str, Dict[str, str]] = { + # Example: use the local echo endpoint to validate the full loop + "PolisplexityBot": { + "_default": "http://127.0.0.1:8000/api/bots/echo_render", + "_callback": "http://127.0.0.1:8000/api/bots/echo_render", + "report_trash": "http://127.0.0.1:8000/api/bots/echo_render", + # add more commands here… + }, + # Wildcard bot (applies to any) β€” optional: + "*": { + "_default": "http://127.0.0.1:8000/api/bots/echo_render", + "_callback": "http://127.0.0.1:8000/api/bots/echo_render", + }, +} + +# Try to use requests; fallback to urllib +try: + import requests # type: ignore + _HAS_REQUESTS = True +except Exception: + import urllib.request # type: ignore + _HAS_REQUESTS = False + + +def _allowed(url: str) -> Tuple[bool, Optional[str]]: + try: + p = urlparse(url) + host = (p.hostname or "").lower() + if p.scheme not in {"http", "https"}: + return False, "bad_scheme" + if host not in ALLOWED_FORWARD_HOSTS: + return False, f"host_not_allowed:{host}" + return True, None + except Exception as e: + return False, f"invalid_url:{e}" + + +def pick_url(bot_name: str, canon: Dict) -> Optional[str]: + """Decide target URL from bot + command/trigger.""" + bot_routes = ROUTE_MAP.get(bot_name) or ROUTE_MAP.get("*") or {} + trigger = ((canon.get("command") or {}).get("trigger")) or "message" + cmd = ((canon.get("command") or {}).get("name")) or "" + + if trigger == "callback": + return bot_routes.get("_callback") or bot_routes.get("_default") + + if cmd: + return bot_routes.get(cmd) or bot_routes.get("_default") + + return bot_routes.get("_default") + + +def post_json(url: str, payload: Dict, timeout: float = 4.0) -> Tuple[int, Dict]: + """Blocking POST JSON; never raises; returns (status, body_json_or_wrapper).""" + ok, why = _allowed(url) + if not ok: + logger.warning("router.reject url=%s reason=%s", url, why) + return 400, {"ok": False, "error": f"forward_rejected:{why}", "url": url} + + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + headers = {"Content-Type": "application/json"} + + if _HAS_REQUESTS: + try: + r = requests.post(url, data=data, headers=headers, timeout=timeout) + try: + body = r.json() + except Exception: + body = {"text": r.text[:2000]} + return r.status_code, body + except Exception as e: + logger.exception("router.requests_failed url=%s", url) + return 502, {"ok": False, "error": f"requests_failed:{e.__class__.__name__}"} + else: + try: + req = urllib.request.Request(url, data=data, headers=headers, method="POST") + with urllib.request.urlopen(req, timeout=timeout) as resp: # nosec + raw = resp.read(65536) + try: + body = json.loads(raw.decode("utf-8")) + except Exception: + body = {"text": raw.decode("utf-8", errors="replace")[:2000]} + return getattr(resp, "status", 200), body + except Exception as e: + logger.exception("router.urllib_failed url=%s", url) + return 502, {"ok": False, "error": f"urllib_failed:{e.__class__.__name__}"} diff --git a/pxy_bots/urls.py b/pxy_bots/urls.py index da3336f..5801785 100644 --- a/pxy_bots/urls.py +++ b/pxy_bots/urls.py @@ -1,6 +1,7 @@ from django.urls import path -from .views import telegram_webhook +from .views import telegram_webhook, echo_render urlpatterns = [ path('webhook//', telegram_webhook, name='telegram_webhook'), + path("bots/echo_render", echo_render, name="pxy_bots_echo_render"), ] diff --git a/pxy_bots/views.py b/pxy_bots/views.py index e98f508..4d7fdd4 100644 --- a/pxy_bots/views.py +++ b/pxy_bots/views.py @@ -21,6 +21,11 @@ from .handlers import ( ) from .renderer import render_spec +from .renderer import render_spec +from .router import pick_url, post_json # <-- add this +from asgiref.sync import sync_to_async + + logger = logging.getLogger(__name__) openai.api_key = os.getenv("OPENAI_API_KEY") @@ -288,6 +293,36 @@ async def telegram_webhook(request, bot_name: str): except Exception as e: logger.exception("tg.canonical.failed: %s", e) + # Try routing via in-memory map. If a URL exists, post req.v1 and render the response. + try: + route_url = pick_url(bot_name, canon) + except Exception: + route_url = None + + if route_url: + # do blocking HTTP off the event loop + status, body = await sync_to_async(post_json)(route_url, canon) + logger.info("tg.routed url=%s status=%s", route_url, status) + + # Accept either a full render_spec or any dict with a "messages" array + 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: + # lenient: wrap plain text into a render_spec + spec = {"schema_version": "render.v1", "messages": [{"type": "text", "text": str(body["text"])}]} + + if spec: + chat_id = (canon.get("chat") or {}).get("id") + if 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}) + + # If no spec, fall through to legacy handlers, but return routing status for visibility + logger.warning("tg.routed.no_spec url=%s body_keys=%s", route_url, list(body.keys()) if isinstance(body, dict) else type(body)) + + # Convert to telegram.Update update = Update.de_json(payload, Bot(token=bot_instance.token)) @@ -354,3 +389,34 @@ async def telegram_webhook(request, bot_name: str): except Exception as e: logger.exception("Error in webhook: %s", e) return JsonResponse({"error": f"Unexpected error: {str(e)}"}, status=500) + + +# pxy_bots/api/views.py +import json +from django.http import JsonResponse + +def echo_render(request): + """ + Accepts req.v1 and returns a simple render_spec so you can validate the router. + """ + 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}"}, + ], + "buttons": [ + {"label": "Abrir Dashboard", "kind": "open_url", "url": "https://app.polisplexity.tech/"}, + {"label": "Re-ejecutar 10’", "kind": "callback_api", "action": "rerun", "params": {"minutes": 10}}, + ], + } + return JsonResponse(spec)