From 7e135e92ba5c345ac62c60af80fe15b486e5319b Mon Sep 17 00:00:00 2001 From: Ekaropolus Date: Wed, 17 Sep 2025 00:20:26 -0600 Subject: [PATCH] Bot Reply template --- pxy_bots/admin.py | 11 +++ pxy_bots/api/urls.py | 8 +- pxy_bots/api/views.py | 90 ++++++++++++++++++++ pxy_bots/migrations/0008_botreplytemplate.py | 38 +++++++++ pxy_bots/models.py | 64 ++++++++++++++ pxy_dashboard/middleware.py | 6 ++ 6 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 pxy_bots/migrations/0008_botreplytemplate.py diff --git a/pxy_bots/admin.py b/pxy_bots/admin.py index 30465db..16dfc39 100644 --- a/pxy_bots/admin.py +++ b/pxy_bots/admin.py @@ -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") diff --git a/pxy_bots/api/urls.py b/pxy_bots/api/urls.py index 7e1b64d..6b370a1 100644 --- a/pxy_bots/api/urls.py +++ b/pxy_bots/api/urls.py @@ -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 ] diff --git a/pxy_bots/api/views.py b/pxy_bots/api/views.py index 2c14ab6..3f8e3a4 100644 --- a/pxy_bots/api/views.py +++ b/pxy_bots/api/views.py @@ -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) + diff --git a/pxy_bots/migrations/0008_botreplytemplate.py b/pxy_bots/migrations/0008_botreplytemplate.py new file mode 100644 index 0000000..c573447 --- /dev/null +++ b/pxy_bots/migrations/0008_botreplytemplate.py @@ -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')], + }, + ), + ] diff --git a/pxy_bots/models.py b/pxy_bots/models.py index e5bf0ec..2832c07 100644 --- a/pxy_bots/models.py +++ b/pxy_bots/models.py @@ -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 diff --git a/pxy_dashboard/middleware.py b/pxy_dashboard/middleware.py index 08d5cb6..825c126 100644 --- a/pxy_dashboard/middleware.py +++ b/pxy_dashboard/middleware.py @@ -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):