179 lines
6.2 KiB
Python
179 lines
6.2 KiB
Python
# 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 for in-memory routes (kept) ---
|
|
ALLOWED_FORWARD_HOSTS = {"127.0.0.1", "localhost", "app.polisplexity.tech"}
|
|
|
|
# --- in-memory ROUTE_MAP (kept) ---
|
|
ROUTE_MAP: Dict[str, Dict[str, str]] = {
|
|
"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",
|
|
},
|
|
"*": {
|
|
"_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
|
|
|
|
|
|
# -----------------------------
|
|
# Helpers (kept + new)
|
|
# -----------------------------
|
|
|
|
def _allowed(url: str) -> Tuple[bool, Optional[str]]:
|
|
"""Allowlist check for in-memory routes (ROUTE_MAP)."""
|
|
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 _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
|
|
|
|
|
|
# -----------------------------
|
|
# In-memory routing (kept)
|
|
# -----------------------------
|
|
|
|
def pick_url(bot_name: str, canon: Dict) -> Optional[str]:
|
|
"""Decide target URL from bot + command/trigger using ROUTE_MAP."""
|
|
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")
|
|
|
|
|
|
# -----------------------------
|
|
# NEW: DB routing (Admin)
|
|
# -----------------------------
|
|
|
|
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:
|
|
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
|
|
|
|
qs = (
|
|
CommandRoute.objects
|
|
.select_related("connection")
|
|
.filter(bot=bot, enabled=True, connection__is_active=True, trigger=trigger)
|
|
.order_by("priority", "id")
|
|
)
|
|
|
|
# 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:
|
|
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", url, why, conn.allowed_host_set())
|
|
return None
|
|
|
|
headers = {}
|
|
headers.update(conn.auth_headers())
|
|
headers.update(conn.extra_headers())
|
|
|
|
return {"url": url, "headers": headers, "timeout": conn.timeout_s}
|
|
except Exception as e:
|
|
logger.exception("router.db.error: %s", e)
|
|
return None
|
|
|
|
|
|
# -----------------------------
|
|
# HTTP POST (extended)
|
|
# -----------------------------
|
|
|
|
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; in-memory callers continue to work.
|
|
"""
|
|
hdrs = {"Content-Type": "application/json", **(headers or {})}
|
|
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
|
|
if _HAS_REQUESTS:
|
|
try:
|
|
r = requests.post(url, data=data, headers=hdrs, 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=hdrs, 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__}"}
|