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:
2026-05-29 02:30:30 +03:00
parent 493e0b37a1
commit 72b6879f4b
234 changed files with 26768 additions and 6240 deletions
+117 -111
View File
@@ -1,122 +1,128 @@
from app.settings_cache import SettingsCache
from app.conversation import (
get_or_create_user, get_open_conversation, create_conversation,
add_message, auto_categorize_text, get_template_rendered
)
from app.integrations.yandex_gpt import categorize_with_gpt
from app.keyboards.inline import build_category_buttons
from app.fsm import BotState, FSM
import datetime
import logging
from sqlalchemy import select
from app.database import async_session
from app.models import BotUser, BotConversation, BotMessage, BotTicket, BotTicketMessage, BotTicketStatus
from app.max_api import max_api
from app.settings_cache import settings_cache
from app.config_reader import config_reader
from app.email_sender import email_sender
logger = logging.getLogger(__name__)
async def handle_inquiry(bot, user_data: dict, db, max_api):
max_user_id = user_data["user_id"]
text = user_data.get("text", "").strip()
user = await get_or_create_user(db, max_user_id)
async def handle_inquiry(user_id: int, conv_id: int, text: str) -> 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 not conv:
return
if not text:
await max_api.send_message(max_user_id, "Пожалуйста, опишите ваш вопрос.")
return
user_result = await db.execute(select(BotUser).where(BotUser.id == user_id))
user = user_result.scalar_one_or_none()
conv = await get_open_conversation(db, user.id)
if not conv:
conv = await create_conversation(db, user.id)
conv.inquiry_text = text
conv.state = "escalated"
await db.commit()
conv.inquiry_text = text
has_attachment = user_data.get("has_attachment", False)
attachment_type = user_data.get("attachment_type", "")
attachment_path_str = user_data.get("attachment_path", "")
if has_attachment:
conv.has_attachment = True
conv.attachment_type = attachment_type
conv.attachment_path = attachment_path_str
await add_message(db, conv.id, "in", text, has_attachment, attachment_type, attachment_path_str)
if has_attachment and attachment_path_str and max_api:
try:
file_url = await max_api.get_file_url(attachment_path_str)
if file_url:
import os
import aiohttp
from app.config import get_settings
from app.models.models import FileSyncQueue
settings = get_settings()
local_dir = settings.UPLOADS_DIR
os.makedirs(local_dir, exist_ok=True)
local_filename = f"conv_{conv.id}_{int(datetime.now().timestamp())}_{attachment_type}"
local_path = os.path.join(local_dir, local_filename)
async with aiohttp.ClientSession() as session:
async with session.get(file_url) as resp:
if resp.status == 200:
with open(local_path, "wb") as f:
f.write(await resp.read())
sync_entry = FileSyncQueue(
local_path=local_path,
remote_path=f"/AegisOne_Service/bot/{local_filename}",
entity_type="conversation",
entity_id=conv.id,
status="pending",
)
db.add(sync_entry)
except Exception:
pass
gpt_key = SettingsCache.get("yandex_gpt_key")
if gpt_key:
cat_name = await categorize_with_gpt(text)
if cat_name:
from sqlalchemy import select
from app.models.models import BotCategory
result = await db.execute(
select(BotCategory).where(
BotCategory.name.ilike(f"%{cat_name}%"),
BotCategory.active == True
).limit(1)
)
cat = result.scalar_one_or_none()
if cat:
conv.inquiry_type = cat.id
await db.flush()
confirm_text = await get_template_rendered(db, "auto_categorize_confirm", category=cat.name)
from app.keyboards.inline import build_category_buttons
buttons = build_category_buttons()
await max_api.send_message_with_keyboard(max_user_id, confirm_text, buttons)
FSM.set_state(max_user_id, BotState.CATEGORY_SELECT)
return
else:
cat = await auto_categorize_text(db, text)
if cat:
conv.inquiry_type = cat.id
await db.flush()
await db.flush()
ooh = not SettingsCache.is_work_hours()
if ooh:
start = SettingsCache.get_int("work_hours_start", 9)
end = SettingsCache.get_int("work_hours_end", 18)
ooh_text = await get_template_rendered(db, "out_of_hours",
work_hours_start=start, work_hours_end=end)
await max_api.send_message(max_user_id, ooh_text)
await _confirm_inquiry(bot, user_data, db, max_api, conv, ooh)
await _finalize_ticket(user_id, conv, user, text)
async def _confirm_inquiry(bot, user_data, db, max_api, conv, is_ooh: bool):
max_user_id = user_data["user_id"]
user = await get_or_create_user(db, max_user_id)
async def _finalize_ticket(
user_id: int,
conv: BotConversation,
user: BotUser,
inquiry_text: str,
) -> None:
async with async_session() as db:
user_name = user.first_name or user.last_name or ""
ticket_title = f"Обращение от {user_name or 'клиента'} ({user_id})"
if len(inquiry_text) > 100:
ticket_title = inquiry_text[:97] + "..."
if is_ooh:
text = await get_template_rendered(db, "confirmation_ooh",
first_name=user.first_name, conv_id=conv.id)
else:
text = await get_template_rendered(db, "confirmation",
first_name=user.first_name, conv_id=conv.id)
ticket = BotTicket(
user_id=user_id,
title=ticket_title,
description=inquiry_text,
priority="normal",
status="Новая",
created_by="bot",
)
db.add(ticket)
await db.commit()
await db.refresh(ticket)
await max_api.send_message(max_user_id, text, format="markdown")
msg = BotTicketMessage(
ticket_id=ticket.id,
direction="incoming",
text=inquiry_text,
)
db.add(msg)
from app.integrations import _1c_unf as ic_integration
await ic_integration.send_to_1c(db, conv)
status_log = BotTicketStatus(
ticket_id=ticket.id,
new_status="Новая",
changed_by="bot",
)
db.add(status_log)
FSM.set_state(max_user_id, BotState.CONFIRMATION)
conv_result = await db.execute(
select(BotConversation).where(BotConversation.id == conv.id)
)
conv_obj = conv_result.scalar_one_or_none()
if conv_obj:
conv_obj.state = "completed"
await db.commit()
async with async_session() as db2:
user_obj = await db2.get(BotUser, user_id)
if user_obj:
user_obj.total_tickets = (user_obj.total_tickets or 0) + 1
await db2.commit()
user_info = f"ID: {user_id}\nИмя: {user.first_name or 'не указано'} {user.last_name or ''}"
contact = user.phone or "не указан"
history = await _get_conversation_history(conv.id)
support_email = await settings_cache.get("support_email", "zakaz@aegisone.ru")
email_subject = await settings_cache.get(
"email_subject",
"Обращение через Виртуального помощника AegisOne Engineering",
)
email_sender.send_ticket_escalation(
to=support_email,
subject=email_subject,
user_info=user_info,
contact=contact,
inquiry=inquiry_text,
history=history,
)
await max_api.send_message(
user_id,
f"Зафиксировала: {inquiry_text}\n\n"
f"Ваша заявка отправлена инженеру. В ближайшее время он обязательно "
f"свяжется с вами.",
)
async def _get_conversation_history(conv_id: int) -> str:
async with async_session() as db:
result = await db.execute(
select(BotMessage)
.where(BotMessage.conversation_id == conv_id)
.order_by(BotMessage.id)
)
messages = result.scalars().all()
lines = []
for msg in messages:
direction = "Клиент" if msg.direction == "incoming" else "Бот"
time_str = msg.created_at.strftime("%d.%m.%Y %H:%M") if msg.created_at else ""
lines.append(f"[{time_str}] {direction}: {msg.text or ''}")
return "\n".join(lines)