Telegram Bot configurable with handlers
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Ekaropolus 2025-09-16 22:23:05 -06:00
parent ab7b9e0240
commit 8f10aebfa2
8 changed files with 449 additions and 138 deletions

View File

@ -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]

View File

@ -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
]

View File

@ -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)

View File

@ -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')],
},
),
]

View File

@ -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()

View File

@ -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:

View File

@ -1,7 +1,6 @@
from django.urls import path
from .views import telegram_webhook, echo_render
from .views import telegram_webhook
urlpatterns = [
path('webhook/<str:bot_name>/', telegram_webhook, name='telegram_webhook'),
path("bots/echo_render", echo_render, name="pxy_bots_echo_render"),
]

View File

@ -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)