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
+97 -50
View File
@@ -1,60 +1,107 @@
from app.settings_cache import SettingsCache
from app.conversation import get_or_create_user, log_consent, get_template_rendered
from app.keyboards.inline import build_consent_buttons, build_contact_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
from app.max_api import max_api
from app.config_reader import config_reader
from app.keyboards import contact_keyboard, consent_keyboard, return_to_consent_keyboard
logger = logging.getLogger(__name__)
async def handle_consent_request(bot, user_data: dict, db, max_api):
max_user_id = user_data["user_id"]
user = await get_or_create_user(db, max_user_id,
user_data.get("first_name", ""),
user_data.get("last_name", ""),
user_data.get("username", ""))
await db.flush()
await SettingsCache.refresh(db)
policy_url = SettingsCache.get("consent_policy_url", "https://aegisone.ru/politica.php")
text = await get_template_rendered(db, "consent_request", policy_url=policy_url)
buttons = build_consent_buttons()
await max_api.send_message_with_keyboard(max_user_id, text, buttons, format="markdown")
FSM.set_state(max_user_id, BotState.CONSENT_REQUEST)
async def handle_consent_callback(user_id: int, conv_id: int, callback_id: str) -> None:
if callback_id == "consent_yes":
await handle_consent_yes(user_id, conv_id)
elif callback_id == "consent_no":
await handle_consent_no(user_id, conv_id)
async def handle_consent_given(bot, user_data: dict, db, max_api):
max_user_id = user_data["user_id"]
user = await get_or_create_user(db, max_user_id)
async def handle_consent_response(user_id: int, conv_id: int, text: str) -> None:
text_lower = text.strip().lower()
consent_words = ["да", "даю", "согласен", "согласна", "yes", "ok", "хорошо", "даю согласие"]
refuse_words = ["нет", "не даю", "отказ", "no", "не согласен", "не согласна"]
user.consent_given = True
from datetime import datetime
user.consent_timestamp = datetime.now()
user.consent_method = "button"
await log_consent(db, user.id, "given", "button", user_data.get("ip", ""))
await db.flush()
text = await get_template_rendered(db, "contact_request")
buttons = build_contact_buttons()
await max_api.send_message_with_keyboard(max_user_id, text, buttons)
FSM.set_state(max_user_id, BotState.CONTACT_REQUEST)
if any(w in text_lower for w in consent_words):
await handle_consent_yes(user_id, conv_id)
elif any(w in text_lower for w in refuse_words):
await handle_consent_no(user_id, conv_id)
else:
await max_api.send_message(
user_id,
"Пожалуйста, дайте чёткий ответ — даёте ли вы согласие на обработку "
"персональных данных? Это необходимо для обработки вашего запроса.",
attachments=consent_keyboard(),
)
async def handle_consent_refused(bot, user_data: dict, db, max_api):
max_user_id = user_data["user_id"]
user = await get_or_create_user(db, max_user_id)
async def handle_consent_yes(user_id: int, conv_id: int) -> None:
async with async_session() as db:
result = await db.execute(select(BotUser).where(BotUser.id == user_id))
user = result.scalar_one_or_none()
if user:
user.consent_given = True
user.consent_date = datetime.datetime.utcnow()
await log_consent(db, user.id, "revoked", "button", user_data.get("ip", ""))
await db.flush()
result = await db.execute(
select(BotConversation).where(BotConversation.id == conv_id)
)
conv = result.scalar_one_or_none()
if conv:
conv.state = "awaiting_contact"
await SettingsCache.refresh(db)
phone_1 = SettingsCache.get("phone_1")
phone_2 = SettingsCache.get("phone_2")
email = SettingsCache.get("support_email")
await db.commit()
text = await get_template_rendered(db, "consent_refused",
phone_1=phone_1, phone_2=phone_2,
support_email=email)
from app.keyboards.inline import build_phone_email_buttons
subject = SettingsCache.get("email_subject")
buttons = build_phone_email_buttons(phone_1, phone_2, email, subject)
await max_api.send_message_with_keyboard(max_user_id, text, buttons, format="markdown")
FSM.reset(max_user_id)
await max_api.send_message(
user_id,
"Спасибо! Поделитесь, пожалуйста, вашим контактом, чтобы мы могли "
"связаться с вами.\n\n"
"Нажмите кнопку «Поделиться контактом» или введите номер телефона / email вручную.",
attachments=contact_keyboard(),
)
async def handle_consent_no(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 = "consent_refused"
await db.commit()
phones = config_reader.get_phones()
email = config_reader.get_site_mail()
phone_text = "\n".join(
f"📞 {phone}" for phone, _ in phones
)
await max_api.send_message(
user_id,
f"Без вашего согласия мы не можем обработать обращение.\n\n"
f"Вы можете позвонить нам:\n{phone_text}\n\n"
f"Или написать на почту: {email}\n\n"
f"Если передумаете и захотите дать согласие — просто напишите об этом.",
)
async def handle_refused_again(user_id: int, conv_id: int, text: str) -> None:
text_lower = text.strip().lower()
consent_words = ["да", "даю", "согласен", "согласна", "yes", "ok", "хорошо", "даю согласие", "передумал", "согласен дать"]
if any(w in text_lower for w in consent_words):
await handle_consent_yes(user_id, conv_id)
else:
phones = config_reader.get_phones()
email = config_reader.get_site_mail()
phone_text = ", ".join(phone for phone, _ in phones)
await max_api.send_message(
user_id,
f"Связаться с нами можно только по телефону {phone_text} "
f"или по почте {email}. "
f"Если хотите дать согласие на обработку данных — напишите «Даю согласие».",
attachments=return_to_consent_keyboard(),
)
+66 -32
View File
@@ -1,43 +1,77 @@
import datetime
import re
from app.settings_cache import SettingsCache
from app.conversation import get_or_create_user, get_template_rendered
from app.fsm import BotState, FSM
import logging
from sqlalchemy import select
from app.database import async_session
from app.models import BotUser, BotConversation, BotMessage
from app.max_api import max_api
logger = logging.getLogger(__name__)
PHONE_PATTERN = re.compile(r"^(\+7|8|7)?[\s\-]?\(?\d{3}\)?[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}$")
EMAIL_PATTERN = re.compile(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")
async def handle_contact_provided(bot, user_data: dict, db, max_api):
max_user_id = user_data["user_id"]
user = await get_or_create_user(db, max_user_id)
async def handle_contact_received(user_id: int, conv_id: int, contact_text: str) -> None:
async with async_session() as db:
result = await db.execute(select(BotUser).where(BotUser.id == user_id))
user = result.scalar_one_or_none()
if user:
if PHONE_PATTERN.match(contact_text):
user.phone = contact_text
elif EMAIL_PATTERN.match(contact_text):
user.phone = contact_text
phone = user_data.get("phone", "")
email = user_data.get("email", "")
result = await db.execute(
select(BotConversation).where(BotConversation.id == conv_id)
)
conv = result.scalar_one_or_none()
if conv:
conv.state = "awaiting_inquiry"
if conv.intent == "unknown" or not conv.intent:
await _ask_inquiry(user_id, conv)
else:
await _ask_inquiry_with_context(user_id, conv)
if phone:
user.phone = phone
if email:
user.email = email
await db.flush()
text = await get_template_rendered(db, "inquiry_request")
await max_api.send_message(max_user_id, text, format="markdown")
FSM.set_state(max_user_id, BotState.INQUIRY_REQUEST)
await db.commit()
async def handle_contact_text(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_manual_contact(user_id: int, conv_id: int, text: str) -> None:
text = text.strip()
phone_match = re.search(r'(\+?7[\s\-\(]?\d{3}[\s\-\)]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2})', text)
email_match = re.search(r'([\w.-]+@[\w.-]+\.\w+)', text)
async with async_session() as db:
msg = BotMessage(
conversation_id=conv_id,
direction="incoming",
text=text,
created_at=datetime.datetime.utcnow(),
)
db.add(msg)
await db.commit()
if phone_match:
user.phone = phone_match.group(1)
if email_match:
user.email = email_match.group(1)
if PHONE_PATTERN.match(text) or EMAIL_PATTERN.match(text):
await handle_contact_received(user_id, conv_id, text)
else:
await max_api.send_message(
user_id,
"Пожалуйста, введите корректный номер телефона (например, +7 (861) 203-33-30) "
"или адрес электронной почты.",
)
if phone_match or email_match:
await db.flush()
await handle_contact_provided(bot, user_data, db, max_api)
return
await max_api.send_message(max_user_id, "Пожалуйста, укажите номер телефона или email, чтобы мы могли с вами связаться.")
async def _ask_inquiry(user_id: int, conv: BotConversation) -> None:
await max_api.send_message(
user_id,
"Опишите, пожалуйста, ваш вопрос или задачу. "
"Расскажите подробнее, чем мы можем вам помочь.",
)
async def _ask_inquiry_with_context(user_id: int, conv: BotConversation) -> None:
intent_label = "вопрос" if conv.intent == "question" else "заявку на услугу" if conv.intent == "ticket" else "обращение"
await max_api.send_message(
user_id,
f"Ранее вы упомянули, что хотите оставить {intent_label}. "
"Опишите подробнее суть вашего обращения, чтобы я могла передать "
"информацию специалисту.",
)
@@ -1,105 +0,0 @@
from datetime import datetime
from app.settings_cache import SettingsCache
from app.conversation import get_or_create_user, get_template_rendered
from app.fsm import BotState, FSM
from app.keyboards.inline import build_main_menu_buttons
_DATE_KEY = "appt_date"
_SLOT_KEY = "appt_slot"
async def handle_appointment(bot, user_data: dict, db, max_api):
max_user_id = user_data["user_id"]
text = user_data.get("text", "").strip()
payload = user_data.get("payload", "")
state = FSM.get_state(max_user_id)
if state != BotState.FEATURE_APPOINTMENT:
FSM.set_state(max_user_id, BotState.FEATURE_APPOINTMENT)
FSM.set_temp(max_user_id, {})
await max_api.send_message(
max_user_id,
"📅 **Запись на визит**\n\nУкажите желаемую дату (ДД.ММ.ГГГГ):"
)
return
tmp = FSM.get_temp(max_user_id)
if _DATE_KEY not in tmp:
try:
parsed = datetime.strptime(text.strip(), "%d.%m.%Y")
tmp[_DATE_KEY] = parsed.strftime("%Y-%m-%d")
FSM.set_temp(max_user_id, tmp)
slots = _generate_slots()
msg_lines = [f"📅 **{parsed.strftime('%d.%m.%Y')}**\n\nДоступные слоты:\n"]
for i, slot in enumerate(slots, 1):
msg_lines.append(f"{i}. {slot}")
msg_lines.append("\nВведите номер слота (1-{}):".format(len(slots)))
await max_api.send_message(max_user_id, "\n".join(msg_lines), format="markdown")
except Exception:
await max_api.send_message(max_user_id, "Неверный формат даты. Укажите дату в формате ДД.ММ.ГГГГ")
return
if _SLOT_KEY not in tmp:
slots = _generate_slots()
try:
idx = int(text.strip()) - 1
if 0 <= idx < len(slots):
tmp[_SLOT_KEY] = slots[idx]
FSM.set_temp(max_user_id, tmp)
await _confirm_appointment(bot, user_data, db, max_api, tmp)
return
except (ValueError, IndexError):
pass
await max_api.send_message(max_user_id, f"Укажите номер слота (1-{len(slots)}):")
return
await _confirm_appointment(bot, user_data, db, max_api, tmp)
def _generate_slots() -> list:
start_h = SettingsCache.get_int("work_hours_start", 9)
end_h = SettingsCache.get_int("work_hours_end", 18)
slots = []
for h in range(start_h, end_h):
slots.append(f"{h:02d}:00-{h + 1:02d}:00")
return slots
async def _confirm_appointment(bot, user_data, db, max_api, tmp):
max_user_id = user_data["user_id"]
date_str = tmp.get(_DATE_KEY, "")
slot_str = tmp.get(_SLOT_KEY, "")
user = await get_or_create_user(db, max_user_id)
text = await get_template_rendered(db, "appointment_confirm",
date=f"{date_str} {slot_str}",
engineer="будет назначен")
await max_api.send_message(max_user_id, text, format="markdown")
conv = await _create_appointment_conv(db, user.id, f"{date_str} {slot_str}")
from app.integrations import _1c_unf as ic_integration
await ic_integration.send_to_1c(db, conv)
FSM.clear_temp(max_user_id)
FSM.transition(max_user_id, "done")
buttons = build_main_menu_buttons()
menu_text = await get_template_rendered(db, "main_menu")
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
async def _create_appointment_conv(db, user_id, details):
from app.conversation import create_conversation, add_message
from sqlalchemy import select
from app.models.models import BotCategory
result = await db.execute(
select(BotCategory).where(BotCategory.name == "Обслуживание").limit(1)
)
cat = result.scalar_one_or_none()
conv = await create_conversation(db, user_id)
conv.inquiry_text = f"Запись на визит: {details}"
conv.inquiry_type = cat.id if cat else None
conv.priority = "normal"
await db.flush()
await add_message(db, conv.id, "in", details)
return conv
@@ -1,44 +0,0 @@
from datetime import datetime
from app.settings_cache import SettingsCache
from app.conversation import get_or_create_user, get_template_rendered
from app.fsm import BotState, FSM
async def handle_broadcast_send(bot, user_data: dict, db, max_api):
from app.models.models import BotUser
from sqlalchemy import select
text = user_data.get("text", "").strip()
if not text:
await max_api.send_message(
user_data["user_id"],
"Укажите текст рассылки в параметре broadcast_text."
)
return
result = await db.execute(select(BotUser).where(BotUser.consent_given == True))
users = result.scalars().all()
sent = 0
failed = 0
sent_ids = []
for u in users:
try:
await max_api.send_message(user_id=u.max_user_id, text=text, format="markdown")
sent += 1
sent_ids.append(u.max_user_id)
except Exception:
failed += 1
from app.models.models import BotBroadcast
bc = BotBroadcast(
text=text,
sent_at=datetime.now(),
recipient_count=len(users),
recipient_ids=sent_ids,
status="sent",
)
db.add(bc)
await db.flush()
result = f"📨 Рассылка завершена.\nОтправлено: {sent}\nОшибок: {failed}"
await max_api.send_message(user_data["user_id"], result)
-101
View File
@@ -1,101 +0,0 @@
from app.settings_cache import SettingsCache
from app.conversation import get_or_create_user, get_template_rendered
from app.fsm import BotState, FSM
from app.keyboards.inline import build_main_menu_buttons
_STEPS = ["service_type", "scope", "address", "comment"]
_STEP_LABELS = {
"service_type": "Тип услуги (Видеонаблюдение, СКУД, Пожарная сигнализация, IT-инфраструктура, Обслуживание, Другое)",
"scope": "Объём работ (краткое описание)",
"address": "Адрес объекта",
"comment": "Дополнительные пожелания (или отправьте «-» для пропуска)",
}
_COLLECT_KEY = "commercial_data"
_STEP_KEY = "commercial_step"
async def handle_commercial(bot, user_data: dict, db, max_api):
max_user_id = user_data["user_id"]
text = user_data.get("text", "").strip()
state = FSM.get_state(max_user_id)
if state != BotState.FEATURE_COMMERCIAL:
FSM.set_state(max_user_id, BotState.FEATURE_COMMERCIAL)
FSM.set_temp(max_user_id, {_STEP_KEY: 0, _COLLECT_KEY: {}})
await max_api.send_message(
max_user_id,
f"📄 **Коммерческое предложение**\n\nШаг 1/{len(_STEPS)}:\n{_STEP_LABELS['service_type']}"
)
return
tmp = FSM.get_temp(max_user_id)
step = tmp.get(_STEP_KEY, 0)
data = tmp.get(_COLLECT_KEY, {})
if step >= len(_STEPS):
await _finish_commercial(bot, user_data, db, max_api, data)
return
step_key = _STEPS[step]
if not text and step_key != "comment":
await max_api.send_message(max_user_id, f"Пожалуйста, заполните поле:\n{_STEP_LABELS[step_key]}")
return
data[step_key] = text if text else ""
step += 1
if step < len(_STEPS):
FSM.set_temp(max_user_id, {_STEP_KEY: step, _COLLECT_KEY: data})
next_key = _STEPS[step]
await max_api.send_message(
max_user_id,
f"📄 Шаг {step + 1}/{len(_STEPS)}:\n{_STEP_LABELS[next_key]}"
)
else:
await _finish_commercial(bot, user_data, db, max_api, data)
async def _finish_commercial(bot, user_data, db, max_api, data):
max_user_id = user_data["user_id"]
user = await get_or_create_user(db, max_user_id)
summary = (
f"📄 **Коммерческое предложение**\n\n"
f"Услуга: {data.get('service_type', '')}\n"
f"Объём: {data.get('scope', '')}\n"
f"Адрес: {data.get('address', '')}\n"
f"Комментарий: {data.get('comment', '')}\n\n"
f"Спасибо, {user.first_name}! Мы подготовим КП и свяжемся с вами."
)
await max_api.send_message(max_user_id, summary, format="markdown")
conv_text = (
f"Коммерческое предложение: услуга={data.get('service_type', '')}, "
f"объём={data.get('scope', '')}, адрес={data.get('address', '')}, "
f"комментарий={data.get('comment', '')}"
)
conv = await _create_commercial_conv(db, user.id, conv_text)
from app.integrations import _1c_unf as ic_integration
await ic_integration.send_to_1c(db, conv)
FSM.clear_temp(max_user_id)
FSM.transition(max_user_id, "done")
buttons = build_main_menu_buttons()
menu_text = await get_template_rendered(db, "main_menu")
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
async def _create_commercial_conv(db, user_id, details):
from app.conversation import create_conversation, add_message
from sqlalchemy import select
from app.models.models import BotCategory
result = await db.execute(
select(BotCategory).where(BotCategory.name == "Консультация").limit(1)
)
cat = result.scalar_one_or_none()
conv = await create_conversation(db, user_id)
conv.inquiry_text = details
conv.inquiry_type = cat.id if cat else None
await db.flush()
await add_message(db, conv.id, "in", details)
return conv
@@ -1,39 +0,0 @@
from app.settings_cache import SettingsCache
from app.conversation import get_or_create_user, get_template_rendered
from app.fsm import BotState, FSM
from app.keyboards.inline import build_main_menu_buttons
async def handle_my_objects(bot, user_data: dict, db, max_api):
max_user_id = user_data["user_id"]
text = user_data.get("text", "").strip()
if FSM.get_state(max_user_id) != BotState.FEATURE_MY_OBJECTS:
FSM.set_state(max_user_id, BotState.FEATURE_MY_OBJECTS)
user = await get_or_create_user(db, max_user_id)
await max_api.send_message(max_user_id,
"🏢 **Мои объекты**\n\n"
"Укажите название вашей компании для поиска объектов."
)
return
if not text:
await max_api.send_message(max_user_id, "Укажите название компании.")
return
from app.integrations.portal_db import get_objects_for_customer
objects = await get_objects_for_customer(db, text)
if not objects:
await max_api.send_message(max_user_id, "Объекты не найдены. Проверьте название компании.")
else:
lines = [f"🏢 **{obj.name}** — {obj.status}" for obj in objects[:10]]
msg = "Ваши объекты:\n\n" + "\n".join(lines)
if len(objects) > 10:
msg += f"\n\n...и ещё {len(objects) - 10} объектов"
await max_api.send_message(max_user_id, msg, format="markdown")
FSM.transition(max_user_id, "done")
buttons = build_main_menu_buttons()
menu_text = await get_template_rendered(db, "main_menu")
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
@@ -1,68 +0,0 @@
from app.settings_cache import SettingsCache
from app.conversation import get_or_create_user, get_template_rendered
from app.fsm import BotState, FSM
from app.keyboards.inline import build_main_menu_buttons
async def handle_notifications(bot, user_data: dict, db, max_api):
max_user_id = user_data["user_id"]
payload = user_data.get("payload", "")
if FSM.get_state(max_user_id) != BotState.FEATURE_NOTIFICATIONS:
FSM.set_state(max_user_id, BotState.FEATURE_NOTIFICATIONS)
user = await get_or_create_user(db, max_user_id)
from sqlalchemy import select
from app.models.models import BotNotificationSubscription
result = await db.execute(
select(BotNotificationSubscription).where(
BotNotificationSubscription.user_id == user.id
).limit(1)
)
sub = result.scalar_one_or_none()
status_change = "" if sub and sub.notify_on_status_change else ""
reply = "" if sub and sub.notify_on_reply else ""
broadcast = "" if sub and sub.notify_on_broadcast else ""
text = (
"🔔 **Уведомления**\n\n"
"Выберите, о чём уведомлять:\n\n"
f"{status_change} /status — Смена статуса обращения\n"
f"{reply} /reply — Ответ оператора\n"
f"{broadcast} /broadcast — Новости и рассылки\n\n"
"Нажмите на пункт, чтобы переключить."
)
await max_api.send_message(max_user_id, text, format="markdown")
return
user = await get_or_create_user(db, max_user_id)
from sqlalchemy import select
from app.models.models import BotNotificationSubscription
result = await db.execute(
select(BotNotificationSubscription).where(
BotNotificationSubscription.user_id == user.id
).limit(1)
)
sub = result.scalar_one_or_none()
if not sub:
sub = BotNotificationSubscription(user_id=user.id)
db.add(sub)
await db.flush()
toggle_map = {
"/status": "notify_on_status_change",
"/reply": "notify_on_reply",
"/broadcast": "notify_on_broadcast",
}
key = payload if payload.startswith("/") else (payload if payload in toggle_map else user_data.get("text", "").strip())
if key in toggle_map:
col = toggle_map[key]
current = getattr(sub, col)
setattr(sub, col, not current)
await db.flush()
await max_api.send_message(max_user_id, f"Настройка «{key}» изменена.")
else:
await max_api.send_message(max_user_id, "Нажмите /status, /reply или /broadcast для переключения.")
FSM.transition(max_user_id, "done")
buttons = build_main_menu_buttons()
menu_text = await get_template_rendered(db, "main_menu")
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
@@ -1,69 +0,0 @@
from app.settings_cache import SettingsCache
from app.conversation import get_or_create_user, get_open_conversation, create_conversation, add_message, get_template_rendered
from app.fsm import BotState, FSM
from app.keyboards.inline import build_main_menu_buttons
async def handle_photo_report(bot, user_data: dict, db, max_api):
max_user_id = user_data["user_id"]
text = user_data.get("text", "").strip()
if FSM.get_state(max_user_id) != BotState.FEATURE_PHOTO_REPORT:
FSM.set_state(max_user_id, BotState.FEATURE_PHOTO_REPORT)
await max_api.send_message(max_user_id,
"📸 **Фото-отчёт**\n\n"
"Отправьте фото проблемы и опишите, что произошло.\n"
"Вы можете прикрепить документ (PDF, DOCX, XLSX, TXT)."
)
return
user = await get_or_create_user(db, max_user_id)
conv = await get_open_conversation(db, user.id)
if not conv:
conv = await create_conversation(db, user.id)
has_attachment = user_data.get("has_attachment", False)
attachment_type = user_data.get("attachment_type", "")
attachment_path = user_data.get("attachment_path", "")
conv.inquiry_text = text or "Фото-отчёт"
conv.has_attachment = has_attachment
conv.attachment_type = attachment_type
conv.attachment_path = attachment_path
conv.priority = "high"
await db.flush()
await add_message(db, conv.id, "in", text, has_attachment, attachment_type, attachment_path)
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)
text = await get_template_rendered(db, "confirmation_ooh" if ooh else "confirmation",
first_name=user.first_name, conv_id=conv.id)
await max_api.send_message(max_user_id, text, format="markdown")
from app.integrations import _1c_unf as ic_integration
await ic_integration.send_to_1c(db, conv)
from app.models.models import Incident
incident = Incident(
object_id=0,
reported_by=None,
title=f"Фото-отчёт от {user.first_name or 'клиента'}",
description=text or "Фото-отчёт через бота",
severity="P3",
status="open",
)
db.add(incident)
await db.flush()
conv.attachment_path = f"incident_{incident.id}"
FSM.transition(max_user_id, "done")
buttons = build_main_menu_buttons()
menu_text = await get_template_rendered(db, "main_menu")
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
@@ -1,35 +0,0 @@
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_quality_buttons, build_main_menu_buttons
from app.fsm import BotState, FSM
async def handle_quality_rate(bot, user_data: dict, db, max_api):
max_user_id = user_data["user_id"]
payload = user_data.get("payload", "")
if FSM.get_state(max_user_id) != BotState.FEATURE_QUALITY_RATE:
FSM.set_state(max_user_id, BotState.FEATURE_QUALITY_RATE)
conv = await get_open_conversation(db, (await get_or_create_user(db, max_user_id)).id)
conv_id = conv.id if conv else ""
text = await get_template_rendered(db, "quality_request", conv_id=conv_id)
buttons = build_quality_buttons()
await max_api.send_message_with_keyboard(max_user_id, text, buttons)
return
if payload.startswith("rate_"):
try:
rating = int(payload.replace("rate_", ""))
except ValueError:
return
await max_api.send_message(max_user_id, f"Спасибо за оценку: {rating}/5! Ваш отзыв важен для нас.")
FSM.transition(max_user_id, "done")
buttons = build_main_menu_buttons()
menu_text = await get_template_rendered(db, "main_menu")
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
return
FSM.transition(max_user_id, "done")
buttons = build_main_menu_buttons()
menu_text = await get_template_rendered(db, "main_menu")
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
@@ -1,48 +0,0 @@
from app.settings_cache import SettingsCache
from app.conversation import get_or_create_user, get_template_rendered
from app.fsm import BotState, FSM
from app.keyboards.inline import build_main_menu_buttons
async def handle_reminders(bot, user_data: dict, db, max_api):
max_user_id = user_data["user_id"]
text = user_data.get("text", "").strip()
if FSM.get_state(max_user_id) != BotState.FEATURE_REMINDERS:
FSM.set_state(max_user_id, BotState.FEATURE_REMINDERS)
user = await get_or_create_user(db, max_user_id)
phone = user.phone or ""
from sqlalchemy import select, func
from app.models.models import BotConversation, BotTicket, Customer
open_convs = await db.execute(
select(func.count(BotConversation.id))
.where(BotConversation.status.in_(["open", "handoff"]))
)
total_open = open_convs.scalar() or 0
open_tickets = await db.execute(
select(func.count(BotTicket.id))
.where(BotTicket.status.in_(["open", "in_progress"]))
)
total_tickets = open_tickets.scalar() or 0
my_convs = await db.execute(
select(func.count(BotConversation.id))
.where(BotConversation.user_id == user.id, BotConversation.status.in_(["open", "handoff"]))
)
my_open = my_convs.scalar() or 0
text = (
"🔔 **Напоминания**\n\n"
f"У вас открыто обращений: {my_open}\n"
f"Всего в системе: {total_open} обращений, {total_tickets} заявок\n\n"
"Напоминания о плановом ТО настраиваются в портале."
)
await max_api.send_message(max_user_id, text, format="markdown")
FSM.transition(max_user_id, "done")
buttons = build_main_menu_buttons()
menu_text = await get_template_rendered(db, "main_menu")
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
return
-100
View File
@@ -1,100 +0,0 @@
from sqlalchemy import select
from app.settings_cache import SettingsCache
from app.conversation import get_or_create_user, get_template_rendered
from app.fsm import BotState, FSM
from app.keyboards.inline import build_main_menu_buttons
_ANSWERS_KEY = "risk_answers"
_STEP_KEY = "risk_step"
async def _load_questions(db):
from app.models.models import BotRiskQuestion
result = await db.execute(
select(BotRiskQuestion)
.where(BotRiskQuestion.is_active == True)
.order_by(BotRiskQuestion.sort_order)
)
return result.scalars().all()
async def handle_risk_score(bot, user_data: dict, db, max_api):
max_user_id = user_data["user_id"]
text = user_data.get("text", "").strip()
state = FSM.get_state(max_user_id)
questions = await _load_questions(db)
if not questions:
await max_api.send_message(max_user_id, "Анкета риск-инжиниринга временно недоступна.")
FSM.transition(max_user_id, "cancel")
buttons = build_main_menu_buttons()
menu_text = await get_template_rendered(db, "main_menu")
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
return
if state != BotState.FEATURE_RISK_SCORE:
FSM.set_state(max_user_id, BotState.FEATURE_RISK_SCORE)
FSM.set_temp(max_user_id, {_STEP_KEY: 0, _ANSWERS_KEY: {}})
q = questions[0]
await max_api.send_message(
max_user_id,
f"📊 **Risk Score — анкета**\n\nВопрос 1/{len(questions)}:\n{q.question}\n\nОтветьте «да» или «нет»."
)
return
tmp = FSM.get_temp(max_user_id)
step = tmp.get(_STEP_KEY, 0)
answers = tmp.get(_ANSWERS_KEY, {})
if step >= len(questions):
await _finish_risk_score(max_user_id, answers, questions, db, max_api)
return
if text.lower() in ("да", "yes", "+", "1"):
answers[questions[step].question_key] = True
elif text.lower() in ("нет", "no", "-", "0"):
answers[questions[step].question_key] = False
else:
await max_api.send_message(max_user_id, "Пожалуйста, ответьте «да» или «нет».")
return
step += 1
if step < len(questions):
q = questions[step]
FSM.set_temp(max_user_id, {_STEP_KEY: step, _ANSWERS_KEY: answers})
await max_api.send_message(
max_user_id,
f"📊 Вопрос {step + 1}/{len(questions)}:\n{q.question}\n\n«да» или «нет»?"
)
else:
await _finish_risk_score(max_user_id, answers, questions, db, max_api)
async def _finish_risk_score(max_user_id, answers, questions, db, max_api):
score = 0
for q in questions:
if answers.get(q.question_key):
score += q.weight
score = min(score, 100)
if score < 20:
label = "Низкий"
rec = "Поддерживайте текущий уровень обслуживания"
elif score < 50:
label = "Средний"
rec = "Рекомендуем провести аудит систем"
elif score < 75:
label = "Высокий"
rec = "Необходимо срочное обслуживание"
else:
label = "Критический"
rec = "Требуется немедленное вмешательство"
tmpl = await get_template_rendered(db, "risk_result", score=score, label=label, recommendation=rec)
await max_api.send_message(max_user_id, tmpl, format="markdown")
FSM.clear_temp(max_user_id)
FSM.transition(max_user_id, "done")
buttons = build_main_menu_buttons()
menu_text = await get_template_rendered(db, "main_menu")
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
@@ -1,54 +0,0 @@
from app.settings_cache import SettingsCache
from app.conversation import get_or_create_user, get_template_rendered
from app.fsm import BotState, FSM
from app.keyboards.inline import build_main_menu_buttons
async def handle_sla_status(bot, user_data: dict, db, max_api):
max_user_id = user_data["user_id"]
text = user_data.get("text", "").strip()
if FSM.get_state(max_user_id) != BotState.FEATURE_SLA_STATUS:
FSM.set_state(max_user_id, BotState.FEATURE_SLA_STATUS)
await max_api.send_message(max_user_id,
"🔍 **Проверка SLA статуса**\n\n"
"Укажите номер или название объекта."
)
return
if not text:
await max_api.send_message(max_user_id, "Укажите номер или название объекта.")
return
from app.integrations.portal_db import get_object_by_name_or_id, get_sla_for_object, get_last_incident_for_object, get_active_tasks_for_object
obj = await get_object_by_name_or_id(db, text)
if not obj:
await max_api.send_message(max_user_id, "Объект не найден.")
FSM.transition(max_user_id, "done")
buttons = build_main_menu_buttons()
menu_text = await get_template_rendered(db, "main_menu")
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
return
sla = await get_sla_for_object(db, obj.id)
if not sla:
text = await get_template_rendered(db, "sla_not_found")
await max_api.send_message(max_user_id, text)
else:
level_map = {"start": "Базовый", "business": "Оптимальный", "enterprise": "Максимальный"}
last_incident = await get_last_incident_for_object(db, obj.id)
open_tasks = await get_active_tasks_for_object(db, obj.id)
last_visit = last_incident.created_at.strftime("%d.%m.%Y") if last_incident else ""
tasks_count = len(open_tasks)
text = await get_template_rendered(db, "sla_found",
object_name=obj.name,
service_level=level_map.get(sla.service_level, sla.service_level),
status=sla.status,
price=sla.sla_price_monthly or 0)
text += f"\n📅 Последний визит: {last_visit}\n📋 Открытых задач: {tasks_count}"
await max_api.send_message(max_user_id, text, format="markdown")
FSM.transition(max_user_id, "done")
buttons = build_main_menu_buttons()
menu_text = await get_template_rendered(db, "main_menu")
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
-155
View File
@@ -1,155 +0,0 @@
from app.settings_cache import SettingsCache
from app.conversation import get_or_create_user, get_template_rendered
from app.fsm import BotState, FSM
from app.keyboards.inline import build_main_menu_buttons
async def handle_sla_ticket(bot, user_data: dict, db, max_api):
max_user_id = user_data["user_id"]
text = user_data.get("text", "").strip()
if FSM.get_state(max_user_id) != BotState.SLA_TICKET_CREATE:
FSM.set_state(max_user_id, BotState.SLA_TICKET_CREATE)
await max_api.send_message(max_user_id, "🔧 **Заявка по SLA**\n\nПроверяю ваш SLA контракт...")
user = await get_or_create_user(db, max_user_id)
phone = user.phone or ""
if not phone:
await max_api.send_message(max_user_id, "Не могу найти ваш номер телефона. Укажите его, пожалуйста.")
return
from sqlalchemy import select
from app.models.models import Customer, Object, SLAContract
result = await db.execute(
select(Customer).where(Customer.contact_phone.ilike(f"%{phone[-10:]}%")).limit(1)
)
customer = result.scalar_one_or_none()
if not customer:
await _send_no_sla(bot, user_data, db, max_api)
return
result = await db.execute(
select(Object).where(Object.customer_id == customer.id, Object.status == "active").limit(1)
)
obj = result.scalar_one_or_none()
if not obj:
await _send_no_sla(bot, user_data, db, max_api)
return
result = await db.execute(
select(SLAContract).where(
SLAContract.object_id == obj.id,
SLAContract.status == "active"
).order_by(SLAContract.created_at.desc()).limit(1)
)
sla = result.scalar_one_or_none()
if not sla:
await _send_no_sla(bot, user_data, db, max_api)
return
level_map = {"start": "Базовый", "business": "Оптимальный", "enterprise": "Максимальный"}
text = await get_template_rendered(db, "sla_ticket_found",
object_name=obj.name,
service_level=level_map.get(sla.service_level, sla.service_level))
await max_api.send_message(max_user_id, text, format="markdown")
return
if not text:
await max_api.send_message(max_user_id, "Опишите неисправность. Вы можете прикрепить фото или документ.")
return
user = await get_or_create_user(db, max_user_id)
from sqlalchemy import select, func
from app.models.models import Customer, Object, SLAContract, BotTicket
result = await db.execute(
select(Customer).where(Customer.contact_phone.ilike(f"%{user.phone[-10:]}%")).limit(1)
)
customer = result.scalar_one_or_none()
obj = None
sla = None
if customer:
result = await db.execute(
select(Object).where(Object.customer_id == customer.id, Object.status == "active").limit(1)
)
obj = result.scalar_one_or_none()
if obj:
result = await db.execute(
select(SLAContract).where(
SLAContract.object_id == obj.id, SLAContract.status == "active"
).order_by(SLAContract.created_at.desc()).limit(1)
)
sla = result.scalar_one_or_none()
priority_map = {"start": "low", "business": "normal", "enterprise": "high"}
response_map = {"start": "4 часа", "business": "2 часа", "enterprise": "30 минут"}
sla_level = sla.service_level if sla else "start"
priority = priority_map.get(sla_level, "normal")
result = await db.execute(select(func.count(BotTicket.id)))
ticket_num = f"T-{result.scalar() + 1:04d}"
from app.models.models import BotTicket, BotTicketMessage, BotTicketStatus
ticket = BotTicket(
ticket_number=ticket_num,
user_id=user.id,
object_id=obj.id if obj else None,
customer_id=customer.id if customer else None,
title=text[:100],
description=text,
priority=priority,
status="open",
created_by="bot",
sla_contract_id=sla.id if sla else None,
has_attachment=user_data.get("has_attachment", False),
attachment_type=user_data.get("attachment_type", ""),
attachment_path=user_data.get("attachment_path", ""),
)
db.add(ticket)
await db.flush()
db.add(BotTicketMessage(ticket_id=ticket.id, direction="in", text=text, sender="client"))
db.add(BotTicketStatus(ticket_id=ticket.id, old_status="", new_status="open", changed_by="bot"))
await db.flush()
has_attachment = user_data.get("has_attachment", False)
attachment_type = user_data.get("attachment_type", "")
attachment_path = user_data.get("attachment_path", "")
if has_attachment:
ticket.has_attachment = True
ticket.attachment_type = attachment_type
ticket.attachment_path = attachment_path
await db.flush()
text = await get_template_rendered(db, "sla_ticket_created",
ticket_number=ticket_num,
priority=priority,
response_time=response_map.get(sla_level, "2 часа"))
await max_api.send_message(max_user_id, text, format="markdown")
from app.max_notifications import notify_ticket_created as nt
await nt(db, ticket, max_api)
FSM.transition(max_user_id, "done")
buttons = build_main_menu_buttons()
menu_text = await get_template_rendered(db, "main_menu")
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
async def _send_no_sla(bot, user_data, db, max_api):
max_user_id = user_data["user_id"]
await SettingsCache.refresh(db)
phone_1 = SettingsCache.get("phone_1")
phone_2 = SettingsCache.get("phone_2")
email = SettingsCache.get("support_email")
text = await get_template_rendered(db, "sla_ticket_not_found",
phone_1=phone_1, phone_2=phone_2,
support_email=email)
from app.keyboards.inline import build_phone_email_buttons
subject = SettingsCache.get("email_subject")
buttons = build_phone_email_buttons(phone_1, phone_2, email, subject)
await max_api.send_message_with_keyboard(max_user_id, text, buttons, format="markdown")
FSM.transition(max_user_id, "done")
buttons = build_main_menu_buttons()
menu_text = await get_template_rendered(db, "main_menu")
await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons)
+262 -25
View File
@@ -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])
+86 -26
View File
@@ -1,32 +1,92 @@
from app.settings_cache import SettingsCache
from app.conversation import get_or_create_user, get_open_conversation, get_template_rendered
from app.fsm import BotState, FSM
import datetime
import logging
from sqlalchemy import select
from app.database import async_session
from app.models import BotConversation, BotMessage
from app.max_api import max_api
from app.keyboards import consent_keyboard
logger = logging.getLogger(__name__)
async def handle_handoff_request(bot, user_data: dict, db, max_api):
max_user_id = user_data["user_id"]
user = await get_or_create_user(db, max_user_id)
async def handle_callback(user_id: int, callback_id: str, callback_data: dict) -> 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 SettingsCache.is_work_hours():
from app.handlers.out_of_hours import handle_out_of_hours
await handle_out_of_hours(bot, user_data, db, max_api)
return
conv_id = conv.id if conv else 0
text = await get_template_rendered(db, "handoff")
await max_api.send_message(max_user_id, text)
conv = await get_open_conversation(db, user.id)
if conv:
conv.status = "handoff"
conv.priority = "high"
await db.flush()
from app.integrations import _1c_unf as ic_integration
await ic_integration.send_to_1c(db, conv)
FSM.set_state(max_user_id, BotState.HANDOFF_REQUEST)
if callback_id.startswith("consent_"):
from app.handlers.consent import handle_consent_callback
await handle_consent_callback(user_id, conv_id, callback_id)
else:
await max_api.send_message(
user_id,
"Извините, я не распознала действие. Пожалуйста, воспользуйтесь кнопками.",
)
async def handle_inquiry_fallback(bot, user_data, db, max_api):
from app.handlers.inquiry import handle_inquiry
await handle_inquiry(bot, user_data, db, max_api)
async def handle_contact_share(
user_id: int,
conv_id: int,
phone: str,
vcf_info: str = "",
vcf_data: dict = None,
contact_hash: str = "",
) -> None:
if vcf_data is None:
vcf_data = {}
async with async_session() as db:
from app.models import BotUser
user_result = await db.execute(select(BotUser).where(BotUser.id == user_id))
user = user_result.scalar_one_or_none()
if user:
user.phone = phone or user.phone
if vcf_data.get("first_name"):
user.first_name = vcf_data["first_name"]
if vcf_data.get("last_name"):
user.last_name = vcf_data["last_name"]
if vcf_data.get("patronymic"):
user.patronymic = vcf_data["patronymic"]
if vcf_data.get("email"):
user.email = vcf_data["email"]
if vcf_data.get("organization"):
user.organization = vcf_data["organization"]
if vcf_data.get("address"):
user.address = vcf_data["address"]
if vcf_info:
user.vcf_raw = vcf_info
if contact_hash:
user.contact_hash = contact_hash
user.phone_verified = True
if phone:
user.phone_verified = True
msg = BotMessage(
conversation_id=conv_id,
direction="incoming",
text=f"[Контакт поделен] {phone or vcf_data.get('phone', '')}",
created_at=datetime.datetime.utcnow(),
)
db.add(msg)
conv_result = await db.execute(
select(BotConversation).where(BotConversation.id == conv_id)
)
conv = conv_result.scalar_one_or_none()
if conv:
conv.state = "awaiting_inquiry"
await db.commit()
await max_api.send_message(
user_id,
"Спасибо! Ваш контакт получен.\n\n"
"Опишите, пожалуйста, суть вашего обращения — "
"расскажите подробнее, чем мы можем вам помочь.",
)
+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)
-103
View File
@@ -1,103 +0,0 @@
from app.settings_cache import SettingsCache
from app.conversation import get_or_create_user, get_template_rendered
from app.keyboards.inline import build_main_menu_buttons
from app.fsm import BotState, FSM
async def handle_main_menu(bot, user_data: dict, db, max_api):
max_user_id = user_data["user_id"]
payload = user_data.get("payload", "")
if payload == "new_inquiry":
user = await get_or_create_user(db, max_user_id)
if user.consent_given:
from app.handlers.contact import handle_contact_provided
await handle_contact_provided(bot, user_data, db, max_api)
else:
from app.handlers.consent import handle_consent_request
await handle_consent_request(bot, user_data, db, max_api)
return
if payload == "handoff":
from app.handlers.handoff import handle_handoff_request
await handle_handoff_request(bot, user_data, db, max_api)
return
if payload == "kb_search":
FSM.set_state(max_user_id, BotState.KB_SEARCH)
await max_api.send_message(max_user_id, "Задайте ваш вопрос, и я постараюсь найти ответ.")
return
if payload.startswith("feature_"):
feature_key = payload.replace("feature_", "")
if SettingsCache.is_feature_enabled(feature_key):
await _handle_feature(bot, user_data, db, max_api, feature_key)
else:
await max_api.send_message(max_user_id, "Этот функционал временно недоступен.")
return
if payload.startswith("cat_"):
await _handle_category_select(bot, user_data, db, max_api, payload)
return
if payload == "cancel":
FSM.reset(max_user_id)
text = await get_template_rendered(db, "main_menu")
buttons = build_main_menu_buttons()
await max_api.send_message_with_keyboard(max_user_id, text, buttons)
return
FSM.reset(max_user_id)
text = await get_template_rendered(db, "main_menu")
buttons = build_main_menu_buttons()
await max_api.send_message_with_keyboard(max_user_id, text, buttons)
async def _handle_feature(bot, user_data, db, max_api, feature_key: str):
feature_handlers = {
"risk_score": ("app.handlers.features.risk_score", "handle_risk_score"),
"sla_status": ("app.handlers.features.sla_status", "handle_sla_status"),
"photo_report": ("app.handlers.features.photo_report", "handle_photo_report"),
"appointment": ("app.handlers.features.appointment", "handle_appointment"),
"quality_rate": ("app.handlers.features.quality_rate", "handle_quality_rate"),
"commercial_offer": ("app.handlers.features.commercial", "handle_commercial"),
"my_objects": ("app.handlers.features.my_objects", "handle_my_objects"),
"reminders": ("app.handlers.features.reminders", "handle_reminders"),
"sla_ticket": ("app.handlers.features.sla_ticket", "handle_sla_ticket"),
"notifications": ("app.handlers.features.notifications", "handle_notifications"),
"broadcasts": ("app.handlers.features.broadcasts", "handle_broadcast_send"),
}
handler = feature_handlers.get(feature_key)
if handler:
import importlib
mod = importlib.import_module(handler[0])
func = getattr(mod, handler[1])
await func(bot, user_data, db, max_api)
else:
await max_api.send_message(max_user_id, "Функция в разработке.")
async def _handle_category_select(bot, user_data, db, max_api, payload: str):
max_user_id = user_data["user_id"]
try:
cat_id = int(payload.replace("cat_", ""))
except ValueError:
return
from sqlalchemy import select
from app.models.models import BotCategory
result = await db.execute(select(BotCategory).where(BotCategory.id == cat_id))
cat = result.scalar_one_or_none()
if not cat:
return
from app.conversation import get_open_conversation, get_or_create_user
user = await get_or_create_user(db, max_user_id)
conv = await get_open_conversation(db, user.id)
if conv:
conv.inquiry_type = cat.id
await db.flush()
ooh = not SettingsCache.is_work_hours()
from app.handlers.inquiry import _confirm_inquiry
await _confirm_inquiry(bot, user_data, db, max_api, conv, ooh)
-43
View File
@@ -1,43 +0,0 @@
from app.settings_cache import SettingsCache
from app.conversation import get_template_rendered
from app.keyboards.inline import build_cancel_button
from app.fsm import BotState, FSM
from app.handlers.main_menu import handle_main_menu
async def handle_out_of_hours(bot, user_data: dict, db, max_api):
max_user_id = user_data["user_id"]
await SettingsCache.refresh(db)
start = SettingsCache.get_int("work_hours_start", 9)
end = SettingsCache.get_int("work_hours_end", 18)
hours_str = SettingsCache.get_work_hours_str()
text = (
f"Сейчас мы не работаем. Наше рабочее время: Пн-Пт {hours_str} (МСК).\n\n"
f"Оставьте сообщение — мы ответим как можно быстрее."
)
buttons = [
[{"type": "message", "text": "✉️ Оставить сообщение", "payload": "leave_message"}],
[{"type": "message", "text": "📞 Заказать звонок", "payload": "callback"}],
]
await max_api.send_message_with_keyboard(max_user_id, text, buttons)
FSM.set_state(max_user_id, BotState.OUT_OF_HOURS)
async def handle_out_of_hours_callback(bot, user_data: dict, db, max_api, payload: str):
max_user_id = user_data["user_id"]
if payload == "leave_message":
FSM.set_state(max_user_id, BotState.INQUIRY_REQUEST)
from app.handlers.inquiry import handle_inquiry
await handle_inquiry(bot, user_data, db, max_api)
return
if payload == "callback":
from app.handlers.consent import handle_consent_request
FSM.set_state(max_user_id, BotState.NAME_REQUEST)
await max_api.send_message(max_user_id, "Как к вам обращаться?")
return
await handle_main_menu(bot, user_data, db, max_api)