Initial commit: VPN Telegram bot (sanitized defaults)
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
"""Автоответы ИИ по тикетам через OpenRouter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from aiogram import Bot
|
||||
|
||||
from config import Settings
|
||||
from services.db import Database
|
||||
from services.faq import FAQ_ITEMS
|
||||
from services.openrouter import (
|
||||
AiDecision,
|
||||
chat_completion,
|
||||
load_openrouter_config,
|
||||
parse_ai_decision,
|
||||
)
|
||||
from services.tickets import (
|
||||
BODY_MAX,
|
||||
clamp_body,
|
||||
notify_user_ticket_reply,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_HTML_RE = re.compile(r"<[^>]+>")
|
||||
|
||||
|
||||
def _strip_html(text: str) -> str:
|
||||
t = _HTML_RE.sub("", text or "")
|
||||
return (
|
||||
t.replace(" ", " ")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("&", "&")
|
||||
.replace(""", '"')
|
||||
.strip()
|
||||
)
|
||||
|
||||
|
||||
def build_faq_context() -> str:
|
||||
lines = ["FAQ сервиса:"]
|
||||
for item in FAQ_ITEMS:
|
||||
lines.append(f"Q: {_strip_html(item.question)}")
|
||||
lines.append(f"A: {_strip_html(item.answer)}")
|
||||
lines.append("")
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
def build_ticket_messages_for_ai(messages: list[dict[str, Any]]) -> list[dict[str, str]]:
|
||||
out: list[dict[str, str]] = []
|
||||
for msg in messages[-20:]:
|
||||
role = str(msg.get("author_role") or "")
|
||||
body = str(msg.get("body") or "").strip()
|
||||
if not body:
|
||||
continue
|
||||
if role == "user":
|
||||
out.append({"role": "user", "content": body})
|
||||
else:
|
||||
prefix = "ИИ" if role == "ai" else "Поддержка"
|
||||
out.append({"role": "assistant", "content": f"[{prefix}] {body}"})
|
||||
return out
|
||||
|
||||
|
||||
async def notify_admins_ai_escalate(
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
*,
|
||||
ticket_id: int,
|
||||
telegram_id: int,
|
||||
subject: str,
|
||||
reason: str,
|
||||
) -> None:
|
||||
from keyboards import admin_ticket_notify_inline
|
||||
|
||||
text = (
|
||||
f"<b>👤 Нужен человек · тикет #{ticket_id}</b>\n"
|
||||
f"От: <code>{telegram_id}</code>\n"
|
||||
f"Тема: <b>{html.escape(subject)}</b>\n\n"
|
||||
f"ИИ эскалировал: {html.escape((reason or 'без причины')[:400])}"
|
||||
)
|
||||
kb = admin_ticket_notify_inline(ticket_id)
|
||||
for admin_id in settings.admin_ids:
|
||||
try:
|
||||
await bot.send_message(admin_id, text, reply_markup=kb, parse_mode="HTML")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("notify admin escalate %s: %s", admin_id, exc)
|
||||
|
||||
|
||||
async def try_ai_ticket_reply(
|
||||
*,
|
||||
db: Database,
|
||||
bot: Bot | None,
|
||||
settings: Settings,
|
||||
ticket_id: int,
|
||||
notify_user: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Пытается ответить на тикет через OpenRouter.
|
||||
Возвращает dict: ok, action, detail, model?
|
||||
"""
|
||||
cfg = load_openrouter_config(db)
|
||||
if not cfg.ready or not cfg.auto_reply:
|
||||
return {"ok": False, "action": "skip", "detail": "ai disabled"}
|
||||
|
||||
ticket = db.get_ticket(ticket_id)
|
||||
if not ticket:
|
||||
return {"ok": False, "action": "skip", "detail": "ticket not found"}
|
||||
if str(ticket.get("status")) == "closed":
|
||||
return {"ok": False, "action": "skip", "detail": "closed"}
|
||||
|
||||
messages = db.list_ticket_messages(ticket_id)
|
||||
if not messages:
|
||||
return {"ok": False, "action": "skip", "detail": "no messages"}
|
||||
last = messages[-1]
|
||||
if str(last.get("author_role")) != "user":
|
||||
return {"ok": False, "action": "skip", "detail": "last not user"}
|
||||
|
||||
subject = str(ticket.get("subject") or "")
|
||||
system = cfg.system_prompt.strip()
|
||||
faq = build_faq_context()
|
||||
system_full = (
|
||||
f"{system}\n\n{faq}\n\n"
|
||||
f"Текущий тикет #{ticket_id}, тема: {subject}"
|
||||
)
|
||||
chat_messages = [
|
||||
{"role": "system", "content": system_full},
|
||||
*build_ticket_messages_for_ai(messages),
|
||||
]
|
||||
|
||||
referer = (settings.webapp_url or "").rstrip("/") or "https://bot.example.com"
|
||||
try:
|
||||
raw, used_model = await chat_completion(
|
||||
api_key=cfg.api_key,
|
||||
model=cfg.model,
|
||||
models=cfg.models_for_request(),
|
||||
messages=chat_messages,
|
||||
referer=referer,
|
||||
)
|
||||
decision = parse_ai_decision(raw, used_model=used_model)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("OpenRouter ticket #%s failed", ticket_id)
|
||||
if bot is not None:
|
||||
await notify_admins_ai_escalate(
|
||||
bot,
|
||||
settings,
|
||||
ticket_id=ticket_id,
|
||||
telegram_id=int(ticket["telegram_id"]),
|
||||
subject=subject,
|
||||
reason=f"Ошибка ИИ: {exc}",
|
||||
)
|
||||
return {"ok": False, "action": "error", "detail": str(exc)}
|
||||
|
||||
return await _apply_decision(
|
||||
db=db,
|
||||
bot=bot,
|
||||
settings=settings,
|
||||
ticket=ticket,
|
||||
decision=decision,
|
||||
notify_user=notify_user,
|
||||
)
|
||||
|
||||
|
||||
async def _apply_decision(
|
||||
*,
|
||||
db: Database,
|
||||
bot: Bot | None,
|
||||
settings: Settings,
|
||||
ticket: dict[str, Any],
|
||||
decision: AiDecision,
|
||||
notify_user: bool,
|
||||
) -> dict[str, Any]:
|
||||
ticket_id = int(ticket["id"])
|
||||
subject = str(ticket.get("subject") or "")
|
||||
telegram_id = int(ticket["telegram_id"])
|
||||
|
||||
if decision.action == "answer" and decision.reply.strip():
|
||||
reply = clamp_body(decision.reply)
|
||||
if len(reply) > BODY_MAX:
|
||||
reply = reply[: BODY_MAX - 1] + "…"
|
||||
try:
|
||||
db.add_ticket_message(
|
||||
ticket_id,
|
||||
author_role="ai",
|
||||
author_id=None,
|
||||
body=reply,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "action": "error", "detail": str(exc)}
|
||||
|
||||
if notify_user and bot is not None:
|
||||
await notify_user_ticket_reply(
|
||||
bot,
|
||||
telegram_id=telegram_id,
|
||||
ticket_id=ticket_id,
|
||||
subject=subject,
|
||||
body=reply,
|
||||
is_ai=True,
|
||||
)
|
||||
logger.info(
|
||||
"AI answered ticket #%s model=%s",
|
||||
ticket_id,
|
||||
decision.model or "?",
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"action": "answer",
|
||||
"detail": "ok",
|
||||
"model": decision.model,
|
||||
"reply": reply,
|
||||
}
|
||||
|
||||
reason = decision.reason or "Нужен оператор"
|
||||
if bot is not None:
|
||||
await notify_admins_ai_escalate(
|
||||
bot,
|
||||
settings,
|
||||
ticket_id=ticket_id,
|
||||
telegram_id=telegram_id,
|
||||
subject=subject,
|
||||
reason=reason,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"action": "escalate",
|
||||
"detail": reason,
|
||||
"model": decision.model,
|
||||
}
|
||||
Reference in New Issue
Block a user