Telegram Bot configurable with handlers
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
ab7b9e0240
commit
8f10aebfa2
@ -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]
|
||||
|
@ -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
|
||||
]
|
||||
|
@ -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)
|
@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
@ -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()
|
||||
|
@ -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:
|
||||
|
@ -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"),
|
||||
]
|
||||
|
@ -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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user