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 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)
|
@admin.register(TelegramBot)
|
||||||
class TelegramBotAdmin(admin.ModelAdmin):
|
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")
|
search_fields = ("name", "username")
|
||||||
list_filter = ("is_active",)
|
inlines = [CommandRouteInline]
|
||||||
actions = ["set_webhooks"]
|
readonly_fields = ("created_at", "updated_at")
|
||||||
|
|
||||||
@admin.action(description="Set webhooks for selected bots")
|
def token_preview(self, obj):
|
||||||
def set_webhooks(self, request, queryset):
|
return f"{obj.token[:8]}…{obj.token[-4:]}" if obj.token else "—"
|
||||||
base_url = f"{request.scheme}://{request.get_host()}"
|
token_preview.short_description = "Token"
|
||||||
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 get_assistant_name(self, obj):
|
# ---- (Optional) conversation/message logs, already simple ----
|
||||||
"""Show the name of the assistant linked to the bot."""
|
@admin.register(TelegramConversation)
|
||||||
return obj.assistant.name if obj.assistant else "None"
|
class TelegramConversationAdmin(admin.ModelAdmin):
|
||||||
get_assistant_name.short_description = "Assistant Name"
|
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 django.urls import path
|
||||||
from . import views
|
from .views import echo_render, health # health if you already have it
|
||||||
|
|
||||||
urlpatterns = [
|
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):
|
def health(request):
|
||||||
return JsonResponse({"ok": True, "service": "pxy_bots", "schema_ready": ["req.v1", "render.v1"]})
|
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
|
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):
|
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').")
|
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').")
|
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.")
|
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(
|
assistant = models.ForeignKey(
|
||||||
AIAssistant,
|
AIAssistant,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name="telegram_bots",
|
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):
|
def __str__(self):
|
||||||
return f"{self.name} (@{self.username})"
|
return f"{self.name} (@{self.username})"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_bot_token(bot_name):
|
def get_bot_token(bot_name: str) -> str:
|
||||||
"""Retrieve the token for the given bot name."""
|
|
||||||
try:
|
try:
|
||||||
bot = TelegramBot.objects.get(name=bot_name, is_active=True)
|
bot = TelegramBot.objects.get(name=bot_name, is_active=True)
|
||||||
return bot.token
|
return bot.token
|
||||||
except TelegramBot.DoesNotExist:
|
except TelegramBot.DoesNotExist:
|
||||||
raise ValueError(f"Bot with name '{bot_name}' not found or inactive.")
|
raise ValueError(f"Bot with name '{bot_name}' not found or inactive.")
|
||||||
|
|
||||||
def set_webhook(self, base_url):
|
def set_webhook(self, base_url: str) -> dict:
|
||||||
"""
|
|
||||||
Set the webhook for this bot dynamically based on the server's base URL.
|
|
||||||
"""
|
|
||||||
if not self.is_active:
|
if not self.is_active:
|
||||||
raise ValueError(f"Bot '{self.name}' is inactive. Activate it before setting the webhook.")
|
raise ValueError(f"Bot '{self.name}' is inactive. Activate it before setting the webhook.")
|
||||||
|
webhook_url = f"{base_url.rstrip('/')}/bots/webhook/{self.name}/"
|
||||||
webhook_url = f"{base_url}/bots/webhook/{self.name}/"
|
resp = requests.post(
|
||||||
response = requests.post(
|
|
||||||
f"https://api.telegram.org/bot{self.token}/setWebhook",
|
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):
|
class TelegramConversation(models.Model):
|
||||||
bot = models.ForeignKey(
|
bot = models.ForeignKey(TelegramBot, on_delete=models.CASCADE, related_name='conversations')
|
||||||
'TelegramBot',
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name='conversations'
|
|
||||||
)
|
|
||||||
user_id = models.CharField(max_length=64)
|
user_id = models.CharField(max_length=64)
|
||||||
started_at = models.DateTimeField(auto_now_add=True)
|
started_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-started_at"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.user_id} @ {self.started_at:%Y-%m-%d %H:%M}"
|
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(
|
class TelegramMessage(models.Model):
|
||||||
TelegramConversation,
|
IN = 'in'
|
||||||
on_delete=models.CASCADE,
|
OUT = 'out'
|
||||||
related_name='messages'
|
DIRECTION_CHOICES = [(IN, 'In'), (OUT, 'Out')]
|
||||||
)
|
|
||||||
direction = models.CharField(max_length=4, choices=DIRECTION_CHOICES)
|
conversation = models.ForeignKey(TelegramConversation, on_delete=models.CASCADE, related_name='messages')
|
||||||
content = models.TextField()
|
direction = models.CharField(max_length=4, choices=DIRECTION_CHOICES)
|
||||||
timestamp = models.DateTimeField(auto_now_add=True)
|
content = models.TextField()
|
||||||
|
timestamp = models.DateTimeField(auto_now_add=True)
|
||||||
response_time_ms = models.IntegerField(null=True, blank=True)
|
response_time_ms = models.IntegerField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["timestamp", "id"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"[{self.direction}] {self.content[:30]}…"
|
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__)
|
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"}
|
ALLOWED_FORWARD_HOSTS = {"127.0.0.1", "localhost", "app.polisplexity.tech"}
|
||||||
|
|
||||||
# --- minimal route map (in-memory) ---
|
# --- in-memory ROUTE_MAP (kept) ---
|
||||||
# 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]] = {
|
ROUTE_MAP: Dict[str, Dict[str, str]] = {
|
||||||
# Example: use the local echo endpoint to validate the full loop
|
|
||||||
"PolisplexityBot": {
|
"PolisplexityBot": {
|
||||||
"_default": "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",
|
"_callback": "http://127.0.0.1:8000/api/bots/echo_render",
|
||||||
"report_trash": "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",
|
"_default": "http://127.0.0.1:8000/api/bots/echo_render",
|
||||||
"_callback": "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
|
_HAS_REQUESTS = False
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Helpers (kept + new)
|
||||||
|
# -----------------------------
|
||||||
|
|
||||||
def _allowed(url: str) -> Tuple[bool, Optional[str]]:
|
def _allowed(url: str) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""Allowlist check for in-memory routes (ROUTE_MAP)."""
|
||||||
try:
|
try:
|
||||||
p = urlparse(url)
|
p = urlparse(url)
|
||||||
host = (p.hostname or "").lower()
|
host = (p.hostname or "").lower()
|
||||||
@ -51,8 +49,30 @@ def _allowed(url: str) -> Tuple[bool, Optional[str]]:
|
|||||||
return False, f"invalid_url:{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]:
|
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 {}
|
bot_routes = ROUTE_MAP.get(bot_name) or ROUTE_MAP.get("*") or {}
|
||||||
trigger = ((canon.get("command") or {}).get("trigger")) or "message"
|
trigger = ((canon.get("command") or {}).get("trigger")) or "message"
|
||||||
cmd = ((canon.get("command") or {}).get("name")) or ""
|
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")
|
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)."""
|
# NEW: DB routing (Admin)
|
||||||
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}
|
|
||||||
|
|
||||||
|
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")
|
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||||
headers = {"Content-Type": "application/json"}
|
|
||||||
|
|
||||||
if _HAS_REQUESTS:
|
if _HAS_REQUESTS:
|
||||||
try:
|
try:
|
||||||
r = requests.post(url, data=data, headers=headers, timeout=timeout)
|
r = requests.post(url, data=data, headers=hdrs, timeout=timeout)
|
||||||
try:
|
try:
|
||||||
body = r.json()
|
body = r.json()
|
||||||
except Exception:
|
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__}"}
|
return 502, {"ok": False, "error": f"requests_failed:{e.__class__.__name__}"}
|
||||||
else:
|
else:
|
||||||
try:
|
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
|
with urllib.request.urlopen(req, timeout=timeout) as resp: # nosec
|
||||||
raw = resp.read(65536)
|
raw = resp.read(65536)
|
||||||
try:
|
try:
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from .views import telegram_webhook, echo_render
|
from .views import telegram_webhook
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('webhook/<str:bot_name>/', telegram_webhook, name='telegram_webhook'),
|
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.http import JsonResponse, HttpResponse
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from asgiref.sync import sync_to_async
|
|
||||||
|
|
||||||
from .models import TelegramBot
|
from .models import TelegramBot
|
||||||
from pxy_langchain.services import LangchainAIService
|
from pxy_langchain.services import LangchainAIService
|
||||||
@ -19,13 +19,15 @@ from .handlers import (
|
|||||||
next_route, complete_stop, missed_stop, city_eco_score,
|
next_route, complete_stop, missed_stop, city_eco_score,
|
||||||
available_jobs, accept_job, next_pickup, complete_pickup, private_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 .renderer import render_spec
|
||||||
from .router import pick_url, post_json # <-- add this
|
|
||||||
from asgiref.sync import sync_to_async
|
from asgiref.sync import sync_to_async
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
openai.api_key = os.getenv("OPENAI_API_KEY")
|
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))
|
logger.info("tg.canonical env=%s", json.dumps(canon, ensure_ascii=False))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("tg.canonical.failed: %s", 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 routing via in-memory map. If a URL exists, post req.v1 and render the response.
|
||||||
try:
|
try:
|
||||||
@ -391,32 +416,4 @@ async def telegram_webhook(request, bot_name: str):
|
|||||||
return JsonResponse({"error": f"Unexpected error: {str(e)}"}, status=500)
|
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