v1.7.0: refactor max_bot to flat structure, add VCF+UserModel+NLP history context, portal pages and proxy fixes
- Refactored max_bot from nested packages to flat module structure - Q2: Extended BotUser model (patronymic, email, org, address, vcf_raw, contact_hash, phone_verified, email_verified, last_interaction, total_conversations, total_tickets) - Q2: VCF parser (FN, N, TEL, EMAIL, ORG, ADR), upsert on re-contact, NLP history context (_get_user_history_context -> YandexGPT) - Q1: Broadcast preview modal with 10s confirmation timer - Q3: CSS var(--white)->var(--bg-card), var(--text)->var(--text-primary) - Q4: bot_settings showNotification(), editable max_bot_id - Q5: Webhook secret passthrough via X-Max-Bot-Api-Secret - Masking sensitive keys, dialog_cleared handler, migrate via _add_column_if_not_exists() - Rate limit (asyncio.sleep 0.5 per 10), dead code removed, conv.intent context in contact.py - Portal pages: bot_consent, bot_kb (edit), bot_settings, bot_test, bot_tickets, portal_settings - Tests: 21/21 passing, added test_yandex_gpt.py, test_email_sender.py - Deploy: deploy_full.sh, schema.sql, seed_knowledge_base.sql
This commit is contained in:
@@ -1,33 +1,270 @@
|
||||
from app.settings_cache import SettingsCache
|
||||
from app.conversation import get_or_create_user, get_open_conversation, get_template_rendered
|
||||
from app.keyboards.inline import build_main_menu_buttons
|
||||
from app.fsm import BotState, FSM
|
||||
import logging
|
||||
import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy import select
|
||||
from app.database import async_session
|
||||
from app.models import BotUser, BotConversation, BotMessage, BotKnowledgeBase, BotCategory
|
||||
from app.max_api import max_api
|
||||
from app.yandex_gpt import yandex_gpt
|
||||
from app.config_reader import config_reader
|
||||
from app.settings_cache import settings_cache
|
||||
from app.keyboards import consent_keyboard, return_to_consent_keyboard
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_start(bot, user_data: dict, db, max_api):
|
||||
max_user_id = user_data["user_id"]
|
||||
first_name = user_data.get("first_name", "")
|
||||
last_name = user_data.get("last_name", "")
|
||||
username = user_data.get("username", "")
|
||||
async def handle_greeting(
|
||||
user_id: int,
|
||||
text: Optional[str] = None,
|
||||
first_name: str = "",
|
||||
last_name: str = "",
|
||||
username: str = "",
|
||||
) -> None:
|
||||
assistant_name = await settings_cache.get("assistant_name", "София")
|
||||
|
||||
user = await get_or_create_user(db, max_user_id, first_name, last_name, username)
|
||||
await db.flush()
|
||||
greeting_text = (
|
||||
f"Здравствуйте! Я {assistant_name}, помощник AegisOne Engineering. "
|
||||
f"Чем я могу быть вам полезна?"
|
||||
)
|
||||
|
||||
await SettingsCache.refresh(db)
|
||||
await max_api.send_message(user_id, greeting_text)
|
||||
|
||||
open_conv = await get_open_conversation(db, user.id)
|
||||
name = SettingsCache.get_assistant_name()
|
||||
async with async_session() as db:
|
||||
existing = await db.execute(
|
||||
select(BotUser).where(BotUser.id == user_id)
|
||||
)
|
||||
user = existing.scalar_one_or_none()
|
||||
|
||||
if open_conv:
|
||||
text = await get_template_rendered(db, "greeting_returning",
|
||||
first_name=user.first_name or first_name,
|
||||
conv_id=open_conv.id)
|
||||
buttons = build_main_menu_buttons()
|
||||
await max_api.send_message_with_keyboard(max_user_id, text, buttons, format="markdown")
|
||||
FSM.set_state(max_user_id, BotState.IDLE)
|
||||
now = datetime.datetime.utcnow()
|
||||
if not user:
|
||||
user = BotUser(
|
||||
id=user_id,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
username=username,
|
||||
last_interaction=now,
|
||||
total_conversations=1,
|
||||
created_at=now,
|
||||
)
|
||||
db.add(user)
|
||||
else:
|
||||
if first_name:
|
||||
user.first_name = first_name
|
||||
if last_name:
|
||||
user.last_name = last_name
|
||||
if username:
|
||||
user.username = username
|
||||
user.last_interaction = now
|
||||
user.total_conversations = (user.total_conversations or 0) + 1
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
conv = BotConversation(
|
||||
user_id=user_id,
|
||||
state="analyzing",
|
||||
created_at=now,
|
||||
)
|
||||
db.add(conv)
|
||||
await db.commit()
|
||||
await db.refresh(conv)
|
||||
|
||||
if text:
|
||||
await analyze_and_respond(user_id, conv.id, text)
|
||||
else:
|
||||
async with async_session() as db:
|
||||
conv.state = "awaiting_input"
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def handle_message(user_id: int, text: str) -> None:
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(BotConversation)
|
||||
.where(BotConversation.user_id == user_id)
|
||||
.order_by(BotConversation.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
conv = result.scalar_one_or_none()
|
||||
|
||||
if not conv:
|
||||
await handle_greeting(user_id, text)
|
||||
return
|
||||
|
||||
msg = BotMessage(
|
||||
conversation_id=conv.id,
|
||||
direction="incoming",
|
||||
text=text,
|
||||
created_at=datetime.datetime.utcnow(),
|
||||
)
|
||||
db.add(msg)
|
||||
await db.commit()
|
||||
|
||||
state = conv.state if conv else "greeting"
|
||||
|
||||
if state in ("greeting", "analyzing", "awaiting_input"):
|
||||
await analyze_and_respond(user_id, conv.id, text)
|
||||
elif state == "awaiting_consent":
|
||||
from app.handlers.consent import handle_consent_response
|
||||
await handle_consent_response(user_id, conv.id, text)
|
||||
elif state == "consent_refused":
|
||||
from app.handlers.consent import handle_refused_again
|
||||
await handle_refused_again(user_id, conv.id, text)
|
||||
elif state == "awaiting_contact":
|
||||
from app.handlers.contact import handle_manual_contact
|
||||
await handle_manual_contact(user_id, conv.id, text)
|
||||
elif state == "awaiting_inquiry":
|
||||
from app.handlers.inquiry import handle_inquiry
|
||||
await handle_inquiry(user_id, conv.id, text)
|
||||
elif state in ("completed", "escalated"):
|
||||
await max_api.send_message(
|
||||
user_id,
|
||||
"Ваше обращение уже передано специалисту. "
|
||||
"Если у вас новый вопрос, напишите его, и я помогу.",
|
||||
)
|
||||
|
||||
|
||||
async def _get_user_history_context(user_id: int) -> str:
|
||||
from app.models import BotTicket
|
||||
async with async_session() as db:
|
||||
recent_msgs = await db.execute(
|
||||
select(BotMessage)
|
||||
.join(BotConversation)
|
||||
.where(BotConversation.user_id == user_id)
|
||||
.order_by(BotMessage.id.desc())
|
||||
.limit(10)
|
||||
)
|
||||
messages = list(reversed(recent_msgs.scalars().all()))
|
||||
|
||||
open_tickets = await db.execute(
|
||||
select(BotTicket)
|
||||
.where(BotTicket.user_id == user_id)
|
||||
.where(BotTicket.status != "Закрыта")
|
||||
.order_by(BotTicket.id.desc())
|
||||
)
|
||||
tickets = open_tickets.scalars().all()
|
||||
|
||||
parts = []
|
||||
if messages:
|
||||
msg_lines = []
|
||||
for m in messages:
|
||||
who = "Клиент" if m.direction == "incoming" else "Бот"
|
||||
msg_lines.append(f"{who}: {m.text or ''}")
|
||||
parts.append("Последние сообщения из прошлых диалогов:\n" + "\n".join(msg_lines))
|
||||
|
||||
if tickets:
|
||||
ticket_lines = []
|
||||
for t in tickets:
|
||||
ticket_lines.append(f" - Заявка #{t.id}: {t.title} (статус: {t.status})")
|
||||
parts.append("Открытые заявки пользователя:\n" + "\n".join(ticket_lines))
|
||||
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
async def analyze_and_respond(user_id: int, conv_id: int, text: str) -> None:
|
||||
gpt_available = await yandex_gpt.is_available()
|
||||
|
||||
if not gpt_available:
|
||||
await _limited_mode(user_id, conv_id)
|
||||
return
|
||||
|
||||
text = await get_template_rendered(db, "greeting", name=name)
|
||||
buttons = build_main_menu_buttons()
|
||||
await max_api.send_message_with_keyboard(max_user_id, text, buttons, format="markdown")
|
||||
FSM.set_state(max_user_id, BotState.IDLE)
|
||||
history_context = await _get_user_history_context(user_id)
|
||||
intent = await yandex_gpt.analyze_intent(text, history_context)
|
||||
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(BotConversation).where(BotConversation.id == conv_id)
|
||||
)
|
||||
conv = result.scalar_one_or_none()
|
||||
if not conv:
|
||||
return
|
||||
conv.intent = intent
|
||||
|
||||
if intent == "question":
|
||||
kb_context = await _get_knowledge_base_context(text)
|
||||
answer = await yandex_gpt.generate_answer(text, kb_context, history_context)
|
||||
if answer:
|
||||
await max_api.send_message(user_id, answer)
|
||||
else:
|
||||
await _send_contacts(user_id)
|
||||
conv.state = "completed"
|
||||
|
||||
elif intent == "ticket":
|
||||
conv.state = "awaiting_consent"
|
||||
consent_text = (
|
||||
"Для обработки Вашего запроса нам нужны ваши контактные данные "
|
||||
"для обратной связи. Даёте согласие на обработку "
|
||||
"персональных данных?"
|
||||
)
|
||||
await max_api.send_message(
|
||||
user_id, consent_text, attachments=consent_keyboard()
|
||||
)
|
||||
|
||||
else:
|
||||
clarification = await yandex_gpt.clarify_intent(text, history_context)
|
||||
await max_api.send_message(user_id, clarification)
|
||||
conv.state = "awaiting_consent"
|
||||
after_text = (
|
||||
"\n\nДля обработки Вашего запроса нам нужны ваши контактные данные "
|
||||
"для обратной связи. Даёте согласие на обработку "
|
||||
"персональных данных?"
|
||||
)
|
||||
await max_api.send_message(
|
||||
user_id, after_text, attachments=consent_keyboard()
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _limited_mode(user_id: int, conv_id: int) -> None:
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(BotConversation).where(BotConversation.id == conv_id)
|
||||
)
|
||||
conv = result.scalar_one_or_none()
|
||||
if conv:
|
||||
conv.state = "completed"
|
||||
await db.commit()
|
||||
|
||||
await _send_contacts(user_id)
|
||||
|
||||
|
||||
async def _send_contacts(user_id: int) -> None:
|
||||
phones = config_reader.get_phones()
|
||||
email = config_reader.get_site_mail()
|
||||
|
||||
phone_text = "\n".join(
|
||||
f"📞 {phone} — [позвонить](tel:{link})"
|
||||
for phone, link in phones
|
||||
)
|
||||
|
||||
message = (
|
||||
"Вы можете связаться с нами:\n\n"
|
||||
f"{phone_text}\n"
|
||||
f"📧 [Написать на почту](mailto:{email})\n\n"
|
||||
f"Или написать на почту: {email}"
|
||||
)
|
||||
|
||||
await max_api.send_message(user_id, message, format="markdown")
|
||||
|
||||
|
||||
async def _get_knowledge_base_context(query: str) -> str:
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(BotKnowledgeBase)
|
||||
.where(BotKnowledgeBase.is_active == True)
|
||||
.order_by(BotKnowledgeBase.sort_order)
|
||||
)
|
||||
cards = result.scalars().all()
|
||||
|
||||
if not cards:
|
||||
return ""
|
||||
|
||||
parts = []
|
||||
for card in cards:
|
||||
keywords = " ".join(card.keywords) if card.keywords else ""
|
||||
answer = card.answer or "Нет ответа"
|
||||
cat_name = card.category.name if card.category else "Общее"
|
||||
parts.append(f"[{cat_name}] {card.question}: {answer} (ключевые слова: {keywords})")
|
||||
|
||||
return "\n\n".join(parts[:20])
|
||||
|
||||
Reference in New Issue
Block a user