248 lines
9.6 KiB
Python
248 lines
9.6 KiB
Python
import json
|
|
import requests
|
|
|
|
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):
|
|
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="If off, webhook can be refused.")
|
|
assistant = models.ForeignKey(
|
|
AIAssistant,
|
|
on_delete=models.CASCADE,
|
|
related_name="telegram_bots",
|
|
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: 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: 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.rstrip('/')}/bots/webhook/{self.name}/"
|
|
resp = requests.post(
|
|
f"https://api.telegram.org/bot{self.token}/setWebhook",
|
|
data={"url": webhook_url},
|
|
timeout=5,
|
|
)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
|
|
class TelegramConversation(models.Model):
|
|
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)
|
|
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()
|
|
|
|
# --- 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
|