v1.6.0: max_bot fixes — feature keys, flush→commit, test-run, categories, broadcast page, proxy error handling, deploy scripts

This commit is contained in:
2026-05-24 07:50:38 +03:00
parent bd048ea23d
commit 493e0b37a1
127 changed files with 6082 additions and 65 deletions
View File
+60
View File
@@ -0,0 +1,60 @@
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
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_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)
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)
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)
await log_consent(db, user.id, "revoked", "button", user_data.get("ip", ""))
await db.flush()
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, "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)
+43
View File
@@ -0,0 +1,43 @@
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
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)
phone = user_data.get("phone", "")
email = user_data.get("email", "")
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)
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)
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)
if phone_match:
user.phone = phone_match.group(1)
if email_match:
user.email = email_match.group(1)
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, чтобы мы могли с вами связаться.")
@@ -0,0 +1,105 @@
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
@@ -0,0 +1,44 @@
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
@@ -0,0 +1,101 @@
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
@@ -0,0 +1,39 @@
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)
@@ -0,0 +1,68 @@
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)
@@ -0,0 +1,69 @@
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)
@@ -0,0 +1,35 @@
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)
@@ -0,0 +1,48 @@
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
@@ -0,0 +1,100 @@
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)
@@ -0,0 +1,54 @@
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
@@ -0,0 +1,155 @@
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)
+33
View File
@@ -0,0 +1,33 @@
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
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", "")
user = await get_or_create_user(db, max_user_id, first_name, last_name, username)
await db.flush()
await SettingsCache.refresh(db)
open_conv = await get_open_conversation(db, user.id)
name = SettingsCache.get_assistant_name()
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)
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)
+32
View File
@@ -0,0 +1,32 @@
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
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)
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
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)
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)
+122
View File
@@ -0,0 +1,122 @@
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
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)
if not text:
await max_api.send_message(max_user_id, "Пожалуйста, опишите ваш вопрос.")
return
conv = await get_open_conversation(db, user.id)
if not conv:
conv = await create_conversation(db, user.id)
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)
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)
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)
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)
FSM.set_state(max_user_id, BotState.CONFIRMATION)
+103
View File
@@ -0,0 +1,103 @@
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
@@ -0,0 +1,43 @@
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)