From 8f10aebfa2dd2f0e3ef553b4a14ea448b43b90a5 Mon Sep 17 00:00:00 2001 From: Ekaropolus Date: Tue, 16 Sep 2025 22:23:05 -0600 Subject: [PATCH] Telegram Bot configurable with handlers --- pxy_bots/admin.py | 101 ++++++---- pxy_bots/api/urls.py | 6 +- pxy_bots/api/views.py | 29 +++ ...tion_alter_telegrambot_options_and_more.py | 89 +++++++++ pxy_bots/models.py | 186 +++++++++++++----- pxy_bots/router.py | 114 +++++++++-- pxy_bots/urls.py | 3 +- pxy_bots/views.py | 59 +++--- 8 files changed, 449 insertions(+), 138 deletions(-) create mode 100644 pxy_bots/migrations/0007_connection_alter_telegrambot_options_and_more.py diff --git a/pxy_bots/admin.py b/pxy_bots/admin.py index 0b3ea93..30465db 100644 --- a/pxy_bots/admin.py +++ b/pxy_bots/admin.py @@ -1,46 +1,69 @@ +# pxy_bots/admin.py from django.contrib import admin -from .models import TelegramBot +from .models import TelegramBot, TelegramConversation, TelegramMessage, Connection, CommandRoute +# ---- 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", "is_active", "get_assistant_name") + list_display = ("name", "username", "token_preview", "is_active", "created_at") + list_filter = ("is_active",) search_fields = ("name", "username") - list_filter = ("is_active",) - actions = ["set_webhooks"] + inlines = [CommandRouteInline] + readonly_fields = ("created_at", "updated_at") - @admin.action(description="Set webhooks for selected bots") - def set_webhooks(self, request, queryset): - base_url = f"{request.scheme}://{request.get_host()}" - for bot in queryset: - if bot.is_active: - try: - if not bot.assistant: - self.message_user( - request, - f"Bot {bot.name} has no assistant configured.", - level="warning", - ) - continue - 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", - ) - else: - self.message_user( - request, - f"Skipped inactive bot: {bot.name}", - level="warning", - ) + def token_preview(self, obj): + return f"{obj.token[:8]}…{obj.token[-4:]}" if obj.token else "β€”" + token_preview.short_description = "Token" - 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" - get_assistant_name.short_description = "Assistant Name" +# ---- (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",) + +@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] diff --git a/pxy_bots/api/urls.py b/pxy_bots/api/urls.py index 687dfe9..7e1b64d 100644 --- a/pxy_bots/api/urls.py +++ b/pxy_bots/api/urls.py @@ -1,7 +1,7 @@ -# pxy_bots/api/urls.py from django.urls import path -from . import views +from .views import echo_render, health # health if you already have it urlpatterns = [ - path("bots/health/", views.health, name="pxy_bots_health"), + path("bots/echo_render", echo_render, name="pxy_bots_echo_render"), + path("bots/health/", health, name="pxy_bots_health"), # optional ] diff --git a/pxy_bots/api/views.py b/pxy_bots/api/views.py index 9ba32b3..b220262 100644 --- a/pxy_bots/api/views.py +++ b/pxy_bots/api/views.py @@ -6,3 +6,32 @@ from django.views.decorators.csrf import csrf_exempt def health(request): return JsonResponse({"ok": True, "service": "pxy_bots", "schema_ready": ["req.v1", "render.v1"]}) +# 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) \ No newline at end of file diff --git a/pxy_bots/migrations/0007_connection_alter_telegrambot_options_and_more.py b/pxy_bots/migrations/0007_connection_alter_telegrambot_options_and_more.py new file mode 100644 index 0000000..33e789a --- /dev/null +++ b/pxy_bots/migrations/0007_connection_alter_telegrambot_options_and_more.py @@ -0,0 +1,89 @@ +# 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')], + }, + ), + ] diff --git a/pxy_bots/models.py b/pxy_bots/models.py index c6c23d7..e5bf0ec 100644 --- a/pxy_bots/models.py +++ b/pxy_bots/models.py @@ -1,85 +1,183 @@ +import json import requests -from django.db import models -from pxy_langchain.models import AIAssistant # Now referencing LangChain AI assistants +from django.db import models +from django.core.validators import MinValueValidator +from django.utils import timezone + +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="Indicates if this bot is active.") + is_active = models.BooleanField(default=True, help_text="If off, webhook can be refused.") assistant = models.ForeignKey( AIAssistant, on_delete=models.CASCADE, related_name="telegram_bots", - help_text="The LangChain AI assistant associated with this Telegram bot.", + help_text="LangChain AI assistant associated with this 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): - """Retrieve the token for the given bot name.""" + def get_bot_token(bot_name: str) -> str: 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): - """ - Set the webhook for this bot dynamically based on the server's base URL. - """ + def set_webhook(self, base_url: str) -> dict: if not self.is_active: raise ValueError(f"Bot '{self.name}' is inactive. Activate it before setting the webhook.") - - webhook_url = f"{base_url}/bots/webhook/{self.name}/" - response = requests.post( + webhook_url = f"{base_url.rstrip('/')}/bots/webhook/{self.name}/" + resp = requests.post( f"https://api.telegram.org/bot{self.token}/setWebhook", - data={"url": webhook_url} + data={"url": webhook_url}, + timeout=5, ) + resp.raise_for_status() + return resp.json() - if response.status_code == 200: - return response.json() - else: - raise ValueError(f"Failed to set webhook for {self.name}: {response.json()}") - - -from django.db import models class TelegramConversation(models.Model): - bot = models.ForeignKey( - 'TelegramBot', - on_delete=models.CASCADE, - related_name='conversations' - ) + 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) +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() diff --git a/pxy_bots/router.py b/pxy_bots/router.py index d0bc944..bc8ccf1 100644 --- a/pxy_bots/router.py +++ b/pxy_bots/router.py @@ -6,23 +6,16 @@ from urllib.parse import urlparse logger = logging.getLogger(__name__) -# --- allowlist of outbound hosts (adjust as needed) --- +# --- allowlist of outbound hosts for in-memory routes (kept) --- 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 +# --- in-memory ROUTE_MAP (kept) --- 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", @@ -38,7 +31,12 @@ except Exception: _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() @@ -51,8 +49,30 @@ def _allowed(url: str) -> Tuple[bool, Optional[str]]: 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.""" + """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 "" @@ -66,19 +86,75 @@ def pick_url(bot_name: str, canon: Dict) -> Optional[str]: 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} +# ----------------------------- +# 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") - headers = {"Content-Type": "application/json"} if _HAS_REQUESTS: try: - r = requests.post(url, data=data, headers=headers, timeout=timeout) + r = requests.post(url, data=data, headers=hdrs, timeout=timeout) try: body = r.json() except Exception: @@ -89,7 +165,7 @@ def post_json(url: str, payload: Dict, timeout: float = 4.0) -> Tuple[int, Dict] return 502, {"ok": False, "error": f"requests_failed:{e.__class__.__name__}"} else: try: - req = urllib.request.Request(url, data=data, headers=headers, method="POST") + 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: diff --git a/pxy_bots/urls.py b/pxy_bots/urls.py index 5801785..da3336f 100644 --- a/pxy_bots/urls.py +++ b/pxy_bots/urls.py @@ -1,7 +1,6 @@ from django.urls import path -from .views import telegram_webhook, echo_render +from .views import telegram_webhook 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 4d7fdd4..7f8d869 100644 --- a/pxy_bots/views.py +++ b/pxy_bots/views.py @@ -9,7 +9,7 @@ from telegram import Update, Bot from django.http import JsonResponse, HttpResponse 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 pxy_langchain.services import LangchainAIService @@ -19,13 +19,15 @@ from .handlers import ( next_route, complete_stop, missed_stop, city_eco_score, available_jobs, accept_job, next_pickup, complete_pickup, private_eco_score ) -from .renderer import render_spec + +from .router import pick_db_route, pick_url, post_json 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") @@ -292,6 +294,29 @@ async def telegram_webhook(request, bot_name: str): logger.info("tg.canonical env=%s", json.dumps(canon, ensure_ascii=False)) except Exception as e: logger.exception("tg.canonical.failed: %s", e) + canon = {} + + # ----- DB-driven route (Admin) ----- + route = pick_db_route(bot_name, canon) if 'canon' in locals() else None + 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}) + # ----------------------------------- + # Try routing via in-memory map. If a URL exists, post req.v1 and render the response. try: @@ -391,32 +416,4 @@ async def telegram_webhook(request, bot_name: str): 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)