Bot Reply template
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Ekaropolus 2025-09-17 00:20:26 -06:00
parent 1a80d6be24
commit 7e135e92ba
6 changed files with 214 additions and 3 deletions

View File

@ -67,3 +67,14 @@ class TelegramMessageAdmin(admin.ModelAdmin):
list_filter = ("direction",)
search_fields = ("content",)
def short(self, obj): return (obj.content or "")[:60]
from django.contrib import admin
from .models import BotReplyTemplate
@admin.register(BotReplyTemplate)
class BotReplyTemplateAdmin(admin.ModelAdmin):
list_display = ("bot", "trigger", "command_name", "enabled", "priority")
list_filter = ("bot", "trigger", "enabled", "parse_mode")
search_fields = ("command_name", "text_template", "note")
ordering = ("bot", "priority", "id")

View File

@ -1,7 +1,9 @@
# pxy_bots/api/urls.py
from django.urls import path
from .views import echo_render, health # health if you already have it
from . import views
urlpatterns = [
path("bots/echo_render", echo_render, name="pxy_bots_echo_render"),
path("bots/health/", health, name="pxy_bots_health"), # optional
path("bots/health/", views.health, name="pxy_bots_health"), # (if you have it)
path("bots/echo_render", views.echo_render, name="pxy_bots_echo_render"), # (you already had)
path("bots/template_reply", views.template_reply, name="pxy_bots_template_reply"), # <-- NEW
]

View File

@ -25,3 +25,93 @@ def echo_render(request):
],
}
return JsonResponse(spec)
# pxy_bots/api/views.py
import json, string
from django.http import JsonResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
from pxy_bots.models import TelegramBot, BotReplyTemplate
def _ctx_from_req(req):
inp = (req.get("input") or {})
loc = inp.get("location") or {}
args_raw = (inp.get("args_raw") or "") or (inp.get("text") or "") or ""
# Strip /cmd if present for ${args}
if args_raw.startswith("/"):
parts = args_raw.split(" ", 1)
args_raw = parts[1] if len(parts) > 1 else ""
return {
"user_id": ((req.get("user") or {}).get("id")),
"chat_id": ((req.get("chat") or {}).get("id")),
"cmd": ((req.get("command") or {}).get("name")),
"trigger": ((req.get("command") or {}).get("trigger")),
"text": (inp.get("text") or ""),
"caption": (inp.get("caption") or ""),
"args": args_raw,
"lat": loc.get("lat"),
"lon": loc.get("lon"),
}
def _choose_template(bot, trigger, cmd):
qs = (BotReplyTemplate.objects
.filter(bot=bot, enabled=True, trigger=trigger)
.order_by("priority", "id"))
t = (qs.filter(command_name=(cmd or None)).first()
or qs.filter(command_name__isnull=True).first()
or qs.filter(command_name="").first())
return t
@csrf_exempt
def template_reply(request):
if request.method != "POST":
return HttpResponseBadRequest("POST only")
try:
req = json.loads(request.body.decode("utf-8") or "{}")
except Exception:
return HttpResponseBadRequest("invalid json")
bot_name = ((req.get("bot") or {}).get("username"))
if not bot_name:
return HttpResponseBadRequest("missing bot.username")
bot = TelegramBot.objects.filter(name=bot_name, is_active=True).first() \
or TelegramBot.objects.filter(username=bot_name, is_active=True).first()
if not bot:
return HttpResponseBadRequest("bot not found")
trigger = ((req.get("command") or {}).get("trigger")) or "message"
cmd = ((req.get("command") or {}).get("name") or "")
cmd = cmd.strip().lstrip("/").lower() or None
tpl = _choose_template(bot, trigger, cmd)
if not tpl:
# Soft fallback to help you see wiring issues
return JsonResponse({
"schema_version": "render.v1",
"messages": [{"type": "text",
"text": f"(no template) bot={bot_name} trigger={trigger} cmd={cmd or '(default)'}"}]
})
ctx = _ctx_from_req(req)
text = string.Template(tpl.text_template or "").safe_substitute(**{k:("" if v is None else v) for k,v in ctx.items()})
msg_list = []
if tpl.media_url:
# If media present, put text in caption; otherwise plain text message
if text.strip():
msg_list.append({"type": "photo", "media_url": tpl.media_url,
"caption": text, "parse_mode": tpl.parse_mode.upper() if tpl.parse_mode!="none" else None})
else:
msg_list.append({"type": "photo", "media_url": tpl.media_url})
if (not tpl.media_url) and text.strip():
msg = {"type": "text", "text": text}
if tpl.parse_mode != BotReplyTemplate.PARSE_NONE:
msg["parse_mode"] = tpl.parse_mode.upper()
msg_list.append(msg)
spec = {"schema_version": "render.v1", "messages": msg_list}
btns = tpl.buttons()
if btns:
spec["buttons"] = btns
return JsonResponse(spec)

View File

@ -0,0 +1,38 @@
# Generated by Django 5.0.3 on 2025-09-17 06:20
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', '0007_connection_alter_telegrambot_options_and_more'),
]
operations = [
migrations.CreateModel(
name='BotReplyTemplate',
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 '/'. Blank = default for this trigger.", max_length=80, null=True)),
('text_template', models.TextField(blank=True, default='', help_text='Use ${args}, ${user_id}, ${lat}, ${lon}, etc.')),
('parse_mode', models.CharField(choices=[('none', 'None'), ('markdown', 'Markdown'), ('html', 'HTML')], default='markdown', max_length=20)),
('media_url', models.CharField(blank=True, default='', help_text='Optional image/video URL', max_length=600)),
('buttons_json', models.TextField(blank=True, default='', help_text='Optional JSON: [{"label":"Open","kind":"open_url","url":"https://..."}]')),
('enabled', models.BooleanField(default=True)),
('priority', models.PositiveIntegerField(default=100, help_text='Lower runs first', validators=[django.core.validators.MinValueValidator(1)])),
('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='reply_templates', to='pxy_bots.telegrambot')),
],
options={
'ordering': ['priority', 'id'],
'indexes': [models.Index(fields=['bot', 'trigger', 'command_name', 'enabled', 'priority'], name='pxy_bots_bo_bot_id_19f2c3_idx')],
},
),
]

View File

@ -181,3 +181,67 @@ class CommandRoute(models.Model):
def clean(self):
if self.command_name:
self.command_name = self.command_name.strip().lstrip("/").lower()
# --- Admin-configurable reply templates --------------------------------------
from django.core.validators import MinValueValidator
from django.utils import timezone
import json
class BotReplyTemplate(models.Model):
PARSE_NONE = "none"
PARSE_MD = "markdown"
PARSE_HTML = "html"
PARSE_CHOICES = [
(PARSE_NONE, "None"),
(PARSE_MD, "Markdown"),
(PARSE_HTML, "HTML"),
]
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="reply_templates")
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 '/'. Blank = default for this trigger.")
# Content
text_template = models.TextField(blank=True, default="", help_text="Use ${args}, ${user_id}, ${lat}, ${lon}, etc.")
parse_mode = models.CharField(max_length=20, choices=PARSE_CHOICES, default=PARSE_MD)
media_url = models.CharField(max_length=600, blank=True, default="", help_text="Optional image/video URL")
buttons_json = models.TextField(blank=True, default="",
help_text='Optional JSON: [{"label":"Open","kind":"open_url","url":"https://..."}]')
enabled = models.BooleanField(default=True)
priority = models.PositiveIntegerField(default=100, validators=[MinValueValidator(1)], 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}"
def clean(self):
if self.command_name:
self.command_name = self.command_name.strip().lstrip("/").lower()
# helpers
def buttons(self):
if not self.buttons_json:
return None
try:
obj = json.loads(self.buttons_json)
return obj if isinstance(obj, list) else None
except Exception:
return None

View File

@ -95,6 +95,12 @@ EXEMPT_URLS += [ re.compile(r"^api/bots/health/?$") ]
EXEMPT_URLS += [ re.compile(r"^api/bots/echo_render$") ]
# pxy_dashboard/middleware.py (append to EXEMPT_URLS)
EXEMPT_URLS += [
re.compile(r"^api/bots/template_reply$"),
]
class LoginRequiredMiddleware(MiddlewareMixin):
def process_request(self, request):