diff --git a/max_bot/.env.example b/max_bot/.env.example new file mode 100644 index 0000000..51f4cad --- /dev/null +++ b/max_bot/.env.example @@ -0,0 +1,5 @@ +DATABASE_URL=postgresql+asyncpg://aegisone:aegisone_pass@localhost:5432/aegisone +APP_ENV=production +LOG_LEVEL=info +WEBHOOK_SECRET=change-me-to-random-secret +WEBHOOK_PATH=/webhook diff --git a/max_bot/CHANGELOG.md b/max_bot/CHANGELOG.md new file mode 100644 index 0000000..5c5cfa8 --- /dev/null +++ b/max_bot/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +## v1.6.0 (2026-05-24) + +### Bug Fixes +- **Features**: Исправлен feature_key `auto_notifications` → `notifications` (фича теперь отображается в меню) +- **Features**: Исправлен feature_key `commercial_offer` → `commercial` (унификация с кодом) +- **Заявки**: Исправлена критическая ошибка — `db.flush()` вместо `db.commit()` во всех write-эндпоинтах (данные не сохранялись) +- **Заявки**: Кнопка "Создать" теперь корректно сохраняет заявку в БД +- **Тест-прогон**: Исправлено — тестовые сообщения бота теперь содержат `role: "bot"`, отображаются в результате +- **Router proxy**: Добавлен `try/except` во все прокси-эндпоинты (500 → 503 с сообщением) +- **Mojibake**: Исправлена кириллица на всех страницах бота (добавлен `page_name` в `ctx()`) +- **Broadcast**: Добавлена колонка `recipient_ids` в таблицу `bot_broadcasts` + +### New Features +- **Рассылки**: Добавлена отдельная страница **Рассылки** в меню Чат-бот Max (роут + шаблон + sidebar) +- **Категории**: Полностью обновлены категории обращений (7 новых с иконками и описаниями) +- **Permissions**: Добавлены `bot_*` права для роли `engineer` в `role_menu_permissions` +- **Deploy**: Улучшен `max_bot_deploy.sh` — теперь использует `docker exec -i` pipe вместо `docker cp` +- **Deploy**: Главный `deploy.sh` теперь включает развёртывание max_bot + +### Infrastructure +- **Database**: `get_db()` теперь корректно вызывает `session.commit()` при выходе (аналогично py_service) +- **Permission config**: Добавлены все пропущенные маршруты в `ROUTE_PERMISSIONS` diff --git a/max_bot/Dockerfile b/max_bot/Dockerfile new file mode 100644 index 0000000..c8e21e8 --- /dev/null +++ b/max_bot/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir --only-binary :all: -r requirements.txt + +COPY . . + +RUN mkdir -p /app/uploads/bot + +RUN python -m pytest tests/ -v --tb=short || true + +EXPOSE 8002 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8002", "--workers", "2"] diff --git a/max_bot/app/__init__.py b/max_bot/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/max_bot/app/__pycache__/__init__.cpython-314.pyc b/max_bot/app/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..4feef11 Binary files /dev/null and b/max_bot/app/__pycache__/__init__.cpython-314.pyc differ diff --git a/max_bot/app/__pycache__/bot_engine.cpython-314.pyc b/max_bot/app/__pycache__/bot_engine.cpython-314.pyc new file mode 100644 index 0000000..35b6fc1 Binary files /dev/null and b/max_bot/app/__pycache__/bot_engine.cpython-314.pyc differ diff --git a/max_bot/app/__pycache__/bot_engine.cpython-38.pyc b/max_bot/app/__pycache__/bot_engine.cpython-38.pyc new file mode 100644 index 0000000..d0c30c4 Binary files /dev/null and b/max_bot/app/__pycache__/bot_engine.cpython-38.pyc differ diff --git a/max_bot/app/__pycache__/config.cpython-314.pyc b/max_bot/app/__pycache__/config.cpython-314.pyc new file mode 100644 index 0000000..911d7b3 Binary files /dev/null and b/max_bot/app/__pycache__/config.cpython-314.pyc differ diff --git a/max_bot/app/__pycache__/config.cpython-38.pyc b/max_bot/app/__pycache__/config.cpython-38.pyc new file mode 100644 index 0000000..f64ce37 Binary files /dev/null and b/max_bot/app/__pycache__/config.cpython-38.pyc differ diff --git a/max_bot/app/__pycache__/conversation.cpython-314.pyc b/max_bot/app/__pycache__/conversation.cpython-314.pyc new file mode 100644 index 0000000..ec9ee8b Binary files /dev/null and b/max_bot/app/__pycache__/conversation.cpython-314.pyc differ diff --git a/max_bot/app/__pycache__/conversation.cpython-38.pyc b/max_bot/app/__pycache__/conversation.cpython-38.pyc new file mode 100644 index 0000000..d897b70 Binary files /dev/null and b/max_bot/app/__pycache__/conversation.cpython-38.pyc differ diff --git a/max_bot/app/__pycache__/database.cpython-314.pyc b/max_bot/app/__pycache__/database.cpython-314.pyc new file mode 100644 index 0000000..f7a2e86 Binary files /dev/null and b/max_bot/app/__pycache__/database.cpython-314.pyc differ diff --git a/max_bot/app/__pycache__/database.cpython-38.pyc b/max_bot/app/__pycache__/database.cpython-38.pyc new file mode 100644 index 0000000..24b0f37 Binary files /dev/null and b/max_bot/app/__pycache__/database.cpython-38.pyc differ diff --git a/max_bot/app/__pycache__/file_storage.cpython-38.pyc b/max_bot/app/__pycache__/file_storage.cpython-38.pyc new file mode 100644 index 0000000..eb1697b Binary files /dev/null and b/max_bot/app/__pycache__/file_storage.cpython-38.pyc differ diff --git a/max_bot/app/__pycache__/fsm.cpython-314.pyc b/max_bot/app/__pycache__/fsm.cpython-314.pyc new file mode 100644 index 0000000..96817af Binary files /dev/null and b/max_bot/app/__pycache__/fsm.cpython-314.pyc differ diff --git a/max_bot/app/__pycache__/fsm.cpython-38.pyc b/max_bot/app/__pycache__/fsm.cpython-38.pyc new file mode 100644 index 0000000..2d55afe Binary files /dev/null and b/max_bot/app/__pycache__/fsm.cpython-38.pyc differ diff --git a/max_bot/app/__pycache__/main.cpython-314.pyc b/max_bot/app/__pycache__/main.cpython-314.pyc new file mode 100644 index 0000000..8d25139 Binary files /dev/null and b/max_bot/app/__pycache__/main.cpython-314.pyc differ diff --git a/max_bot/app/__pycache__/main.cpython-38.pyc b/max_bot/app/__pycache__/main.cpython-38.pyc new file mode 100644 index 0000000..2012949 Binary files /dev/null and b/max_bot/app/__pycache__/main.cpython-38.pyc differ diff --git a/max_bot/app/__pycache__/max_api.cpython-314.pyc b/max_bot/app/__pycache__/max_api.cpython-314.pyc new file mode 100644 index 0000000..13ccfa1 Binary files /dev/null and b/max_bot/app/__pycache__/max_api.cpython-314.pyc differ diff --git a/max_bot/app/__pycache__/max_api.cpython-38.pyc b/max_bot/app/__pycache__/max_api.cpython-38.pyc new file mode 100644 index 0000000..3f7e43f Binary files /dev/null and b/max_bot/app/__pycache__/max_api.cpython-38.pyc differ diff --git a/max_bot/app/__pycache__/settings_cache.cpython-314.pyc b/max_bot/app/__pycache__/settings_cache.cpython-314.pyc new file mode 100644 index 0000000..f884a77 Binary files /dev/null and b/max_bot/app/__pycache__/settings_cache.cpython-314.pyc differ diff --git a/max_bot/app/__pycache__/settings_cache.cpython-38.pyc b/max_bot/app/__pycache__/settings_cache.cpython-38.pyc new file mode 100644 index 0000000..2ed8b77 Binary files /dev/null and b/max_bot/app/__pycache__/settings_cache.cpython-38.pyc differ diff --git a/max_bot/app/__pycache__/text_renderer.cpython-314.pyc b/max_bot/app/__pycache__/text_renderer.cpython-314.pyc new file mode 100644 index 0000000..0c798ed Binary files /dev/null and b/max_bot/app/__pycache__/text_renderer.cpython-314.pyc differ diff --git a/max_bot/app/__pycache__/text_renderer.cpython-38.pyc b/max_bot/app/__pycache__/text_renderer.cpython-38.pyc new file mode 100644 index 0000000..bff0513 Binary files /dev/null and b/max_bot/app/__pycache__/text_renderer.cpython-38.pyc differ diff --git a/max_bot/app/bot_engine.py b/max_bot/app/bot_engine.py new file mode 100644 index 0000000..3b629d6 --- /dev/null +++ b/max_bot/app/bot_engine.py @@ -0,0 +1,268 @@ +import json +import logging +from typing import Optional +from app.max_api import MaxAPIClient +from app.settings_cache import SettingsCache +from app.fsm import BotState, FSM +from app.conversation import ( + get_or_create_user, add_message, + get_template_rendered, find_in_knowledge_base +) +from app.utils.emotion_detector import detect_emotion + +logger = logging.getLogger(__name__) + + +async def handle_update(bot, update: dict, db, max_api: MaxAPIClient): + update_type = update.get("update_type", "") + user_data = update.get("user", {}) + max_user_id = user_data.get("user_id") + + if not max_user_id: + return + + await SettingsCache.refresh(db) + + if update_type == "bot_started": + await _handle_bot_started(bot, user_data, db, max_api) + return + + if update_type == "message_created": + await _handle_message_created(bot, user_data, update, db, max_api) + return + + if update_type == "message_callback": + await _handle_callback(bot, user_data, update, db, max_api) + return + + +async def _handle_bot_started(bot, user_data, db, max_api): + await SettingsCache.refresh(db) + 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 + from app.handlers.greeting import handle_start + await handle_start(bot, user_data, db, max_api) + + +async def _handle_message_created(bot, user_data, update, db, max_api): + max_user_id = user_data["user_id"] + message = update.get("message", {}) + body = message.get("body", {}) + text = body.get("text", "").strip() + attachments = body.get("attachments", []) + + user_data["text"] = text + user_data["ip"] = update.get("ip", "") + + has_attachment = len(attachments) > 0 + attachment_type = "" + attachment_path = "" + + if has_attachment: + att = attachments[0] + attachment_type = att.get("type", "") + if attachment_type == "contact": + payload = att.get("payload", {}) + vcf_info = payload.get("vcf_info", "") + import re + phone_match = re.search(r'TEL[^:]*:(\+?\d+)', vcf_info) + name_match = re.search(r'FN:(.+)', vcf_info) + if phone_match: + user_data["phone"] = phone_match.group(1) + if name_match: + user_data["first_name"] = name_match.group(1).strip() + await _handle_contact_shared(bot, user_data, db, max_api) + return + attachment_path = att.get("url", "") + + user_data["has_attachment"] = has_attachment + user_data["attachment_type"] = attachment_type + user_data["attachment_path"] = attachment_path + + state = FSM.get_state(max_user_id) + + if state == BotState.IDLE: + if text.lower() in ("/start", "/help"): + 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 + from app.handlers.greeting import handle_start + await handle_start(bot, user_data, db, max_api) + return + + 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 + + emotion = await detect_emotion(text) + if emotion == "negative": + tmpl = await get_template_rendered(db, "emotion_escalate") + await max_api.send_message(max_user_id, tmpl) + from app.handlers.handoff import handle_handoff_request + await handle_handoff_request(bot, user_data, db, max_api) + from app.max_notifications import notify_handoff_escalation + user = await get_or_create_user(db, max_user_id) + conv = None + try: + from app.conversation import get_open_conversation + conv = await get_open_conversation(db, user.id) + except Exception: + pass + await notify_handoff_escalation( + db, max_api, user, + conv.id if conv else 0, text + ) + return + + kb_entry = await find_in_knowledge_base(db, text) + if kb_entry: + await max_api.send_message(max_user_id, kb_entry.answer, format="markdown") + return + + from app.conversation import get_or_create_user + user = await get_or_create_user(db, max_user_id) + if not user.consent_given: + from app.handlers.greeting import handle_start + await handle_start(bot, user_data, db, max_api) + return + + from app.handlers.main_menu import handle_main_menu + user_data["payload"] = "new_inquiry" + await handle_main_menu(bot, user_data, db, max_api) + return + + if state == BotState.NAME_REQUEST: + if not text: + await max_api.send_message(max_user_id, "Как к вам обращаться?") + return + user = await get_or_create_user(db, max_user_id) + user.first_name = text + await db.flush() + from app.handlers.consent import handle_consent_request + await handle_consent_request(bot, user_data, db, max_api) + return + + if state == BotState.CONSENT_REQUEST: + if text.lower() in ("да", "даю согласие", "согласен", "yes"): + from app.handlers.consent import handle_consent_given + await handle_consent_given(bot, user_data, db, max_api) + elif text.lower() in ("нет", "не даю", "не согласен", "no"): + from app.handlers.consent import handle_consent_refused + await handle_consent_refused(bot, user_data, db, max_api) + else: + from app.handlers.main_menu import handle_main_menu + user_data["payload"] = text.lower() + await handle_main_menu(bot, user_data, db, max_api) + return + + if state == BotState.CONTACT_REQUEST: + from app.handlers.contact import handle_contact_text + await handle_contact_text(bot, user_data, db, max_api) + return + + if state == BotState.INQUIRY_REQUEST: + from app.handlers.inquiry import handle_inquiry + await handle_inquiry(bot, user_data, db, max_api) + return + + if state == BotState.CATEGORY_SELECT: + from app.handlers.inquiry import handle_inquiry + await handle_inquiry(bot, user_data, db, max_api) + return + + if state == BotState.OUT_OF_HOURS: + from app.handlers.out_of_hours import handle_out_of_hours_callback + await handle_out_of_hours_callback(bot, user_data, db, max_api, text.lower()) + return + + _feature_handlers = { + BotState.FEATURE_RISK_SCORE: ("app.handlers.features.risk_score", "handle_risk_score"), + BotState.FEATURE_SLA_STATUS: ("app.handlers.features.sla_status", "handle_sla_status"), + BotState.FEATURE_PHOTO_REPORT: ("app.handlers.features.photo_report", "handle_photo_report"), + BotState.FEATURE_APPOINTMENT: ("app.handlers.features.appointment", "handle_appointment"), + BotState.FEATURE_COMMERCIAL: ("app.handlers.features.commercial", "handle_commercial"), + BotState.FEATURE_MY_OBJECTS: ("app.handlers.features.my_objects", "handle_my_objects"), + BotState.FEATURE_REMINDERS: ("app.handlers.features.reminders", "handle_reminders"), + BotState.FEATURE_QUALITY_RATE: ("app.handlers.features.quality_rate", "handle_quality_rate"), + BotState.FEATURE_NOTIFICATIONS: ("app.handlers.features.notifications", "handle_notifications"), + BotState.FEATURE_BROADCAST: ("app.handlers.features.broadcasts", "handle_broadcast_send"), + BotState.SLA_TICKET_CREATE: ("app.handlers.features.sla_ticket", "handle_sla_ticket"), + } + if state in _feature_handlers: + mod_path, func_name = _feature_handlers[state] + import importlib + mod = importlib.import_module(mod_path) + func = getattr(mod, func_name) + await func(bot, user_data, db, max_api) + return + + if state == BotState.KB_SEARCH: + kb_entry = await find_in_knowledge_base(db, text) + if kb_entry: + await max_api.send_message(max_user_id, kb_entry.answer, format="markdown") + FSM.transition(max_user_id, "found") + else: + await max_api.send_message(max_user_id, + "К сожалению, не нашёл ответа на ваш вопрос. " + "Опишите подробнее или нажмите «Оставить заявку» для создания обращения.") + FSM.transition(max_user_id, "not_found") + from app.keyboards.inline import build_main_menu_buttons + 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 + + from app.handlers.main_menu import handle_main_menu + user_data["payload"] = text.lower() + await handle_main_menu(bot, user_data, db, max_api) + + +async def _handle_callback(bot, user_data, update, db, max_api): + max_user_id = user_data["user_id"] + callback = update.get("callback", {}) + payload = callback.get("payload", "") + callback_id = callback.get("id", "") + + user_data["payload"] = payload + + if payload == "cancel": + FSM.reset(max_user_id) + from app.keyboards.inline import build_main_menu_buttons + 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) + await max_api.answer_callback(callback_id) + return + + if payload.startswith("rate_"): + from app.handlers.features.quality_rate import handle_quality_rate + await handle_quality_rate(bot, user_data, db, max_api) + await max_api.answer_callback(callback_id) + return + + from app.handlers.main_menu import handle_main_menu + await handle_main_menu(bot, user_data, db, max_api) + await max_api.answer_callback(callback_id) + + +async def _handle_contact_shared(bot, user_data, db, max_api): + max_user_id = user_data["user_id"] + user = await get_or_create_user(db, max_user_id) + if user_data.get("phone"): + user.phone = user_data["phone"] + if user_data.get("first_name"): + user.first_name = user_data["first_name"] + await db.flush() + + state = FSM.get_state(max_user_id) + if state == BotState.CONTACT_REQUEST: + from app.handlers.contact import handle_contact_provided + await handle_contact_provided(bot, user_data, db, max_api) + else: + from app.handlers.main_menu import handle_main_menu + user_data["payload"] = "new_inquiry" + await handle_main_menu(bot, user_data, db, max_api) diff --git a/max_bot/app/config.py b/max_bot/app/config.py new file mode 100644 index 0000000..e4a4130 --- /dev/null +++ b/max_bot/app/config.py @@ -0,0 +1,24 @@ +import os +from pathlib import Path +from pydantic_settings import BaseSettings +from functools import lru_cache + + +class Settings(BaseSettings): + DATABASE_URL: str = "postgresql+asyncpg://aegisone:aegisone_pass@localhost:5432/aegisone" + APP_ENV: str = "development" + LOG_LEVEL: str = "debug" + WEBHOOK_PATH: str = "/webhook" + WEBHOOK_SECRET: str = "change-me-to-random-secret" + UPLOADS_DIR: str = str(Path(__file__).parent.parent / "uploads" / "bot") + YANDEX_DISK_TOKEN: str = "" + YANDEX_DISK_ROOT: str = "/AegisOne_Service" + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + + +@lru_cache() +def get_settings() -> Settings: + return Settings() diff --git a/max_bot/app/conversation.py b/max_bot/app/conversation.py new file mode 100644 index 0000000..739cf7b --- /dev/null +++ b/max_bot/app/conversation.py @@ -0,0 +1,142 @@ +from datetime import datetime +from typing import Optional +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.models import ( + BotUser, BotConversation, BotMessage, BotConsentLog, + BotCategory, BotSetting, BotFeature, BotResponseTemplate, BotKnowledgeBase +) +from app.settings_cache import SettingsCache +from app.text_renderer import render_template + + +async def get_or_create_user(db: AsyncSession, max_user_id: int, + first_name: str = "", last_name: str = "", + username: str = "") -> BotUser: + result = await db.execute(select(BotUser).where(BotUser.max_user_id == max_user_id)) + user = result.scalar_one_or_none() + if user: + user.last_active_at = datetime.now() + if first_name and not user.first_name: + user.first_name = first_name + if last_name and not user.last_name: + user.last_name = last_name + if username and not user.username: + user.username = username + return user + user = BotUser( + max_user_id=max_user_id, first_name=first_name, + last_name=last_name, username=username + ) + db.add(user) + await db.flush() + return user + + +async def get_open_conversation(db: AsyncSession, user_id: int) -> Optional[BotConversation]: + result = await db.execute( + select(BotConversation).where( + BotConversation.user_id == user_id, + BotConversation.status == "open" + ).order_by(BotConversation.created_at.desc()).limit(1) + ) + return result.scalar_one_or_none() + + +async def create_conversation(db: AsyncSession, user_id: int) -> BotConversation: + conv = BotConversation(user_id=user_id, status="open") + db.add(conv) + await db.flush() + return conv + + +async def add_message(db: AsyncSession, conversation_id: int, direction: str, + text: str = "", has_attachment: bool = False, + attachment_type: str = "", attachment_path: str = "", + max_message_id: int = None) -> BotMessage: + msg = BotMessage( + conversation_id=conversation_id, direction=direction, text=text, + has_attachment=has_attachment, attachment_type=attachment_type, + attachment_path=attachment_path, max_message_id=max_message_id + ) + db.add(msg) + await db.flush() + return msg + + +async def log_consent(db: AsyncSession, user_id: int, action: str, method: str = "", ip: str = ""): + log = BotConsentLog(user_id=user_id, action=action, method=method, ip_address=ip) + db.add(log) + await db.flush() + + +async def get_template(db: AsyncSession, key: str) -> str: + await SettingsCache.refresh(db) + return SettingsCache.get_template(key) + + +async def get_template_rendered(db: AsyncSession, key: str, **kwargs) -> str: + tmpl = await get_template(db, key) + return render_template(tmpl, **kwargs) + + +async def find_in_knowledge_base(db: AsyncSession, query: str) -> Optional[BotKnowledgeBase]: + query_lower = query.lower() + result = await db.execute( + select(BotKnowledgeBase).where(BotKnowledgeBase.active == True) + ) + entries = result.scalars().all() + for entry in entries: + if query_lower in entry.question.lower(): + return entry + keywords = entry.keywords or [] + for kw in keywords: + if kw.lower() in query_lower: + return entry + return None + + +async def auto_categorize_text(db: AsyncSession, text: str) -> Optional[BotCategory]: + text_lower = text.lower() + categories = SettingsCache.get_categories() + best_match = None + best_score = 0 + for cat in categories: + keywords = cat.keywords or [] + score = sum(1 for kw in keywords if kw.lower() in text_lower) + if score > best_score: + best_score = score + best_match = cat + return best_match if best_score > 0 else None + + +async def close_conversation(db: AsyncSession, conversation_id: int): + conv = await db.execute( + select(BotConversation).where(BotConversation.id == conversation_id) + ) + conv = conv.scalar_one_or_none() + if conv: + conv.status = "closed" + conv.closed_at = datetime.now() + await db.flush() + + +async def update_conversation_status(db: AsyncSession, conversation_id: int, status: str): + conv = await db.execute( + select(BotConversation).where(BotConversation.id == conversation_id) + ) + conv = conv.scalar_one_or_none() + if conv: + conv.status = status + await db.flush() + + +async def detect_negative_emotion(text: str) -> bool: + negative_words = [ + "ужасно", "плохо", "долго", "кошмар", "отвратительно", "невыносимо", + "бесполезно", "халтура", "безобразие", "позор", "разочарован", "злюсь", + "раздражён", "недоволен", "жалоба", "претензия", "скандал", "суд", + "верните деньги", "мошенники", "обман" + ] + text_lower = text.lower() + return any(word in text_lower for word in negative_words) diff --git a/max_bot/app/database.py b/max_bot/app/database.py new file mode 100644 index 0000000..7fe217b --- /dev/null +++ b/max_bot/app/database.py @@ -0,0 +1,22 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from app.config import get_settings + +settings = get_settings() + +engine_kwargs = {"echo": False} +if "postgresql" in settings.DATABASE_URL: + engine_kwargs["pool_size"] = 10 + engine_kwargs["max_overflow"] = 20 +engine = create_async_engine(settings.DATABASE_URL, **engine_kwargs) + +async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def get_db(): + async with async_session() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise diff --git a/max_bot/app/file_storage.py b/max_bot/app/file_storage.py new file mode 100644 index 0000000..729649c --- /dev/null +++ b/max_bot/app/file_storage.py @@ -0,0 +1,77 @@ +import uuid +import json +import logging +from pathlib import Path +from datetime import datetime + +ALLOWED_EXTENSIONS = {".pdf", ".docx", ".xlsx", ".txt", ".jpg", ".jpeg", ".png", ".gif"} +logger = logging.getLogger(__name__) + + +class FileStorage: + def __init__(self, base_dir: str = "uploads/bot"): + self.base_dir = Path(base_dir) + self.base_dir.mkdir(parents=True, exist_ok=True) + + async def save_file(self, file_bytes: bytes, filename: str, conversation_id: int, + file_type: str = "document", db=None) -> str: + ext = Path(filename).suffix.lower() + if ext not in ALLOWED_EXTENSIONS: + ext = ".bin" + safe_name = f"{uuid.uuid4().hex}{ext}" + conv_dir = self.base_dir / str(conversation_id) + conv_dir.mkdir(parents=True, exist_ok=True) + file_path = conv_dir / safe_name + file_path.write_bytes(file_bytes) + + meta = { + "type": "conversation", + "id": conversation_id, + "filename": filename, + "file_type": file_type, + "timestamp": datetime.now().isoformat(), + "local_path": str(file_path), + } + meta_path = conv_dir / "metadata.json" + if meta_path.exists(): + try: + existing = json.loads(meta_path.read_text()) + if "attachments" not in existing: + existing["attachments"] = [] + existing["attachments"].append({"filename": safe_name, "original": filename}) + meta_path.write_text(json.dumps(existing, ensure_ascii=False, indent=2)) + except Exception: + meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2)) + else: + meta["attachments"] = [{"filename": safe_name, "original": filename}] + meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2)) + + # Queue for Yandex Disk sync (service portal picks this up) + if db is not None: + try: + from app.models.models import FileSyncQueue + remote_path = f"bot/conversations/{conversation_id}/{safe_name}" + queue = FileSyncQueue( + local_path=str(file_path), + remote_path=remote_path, + entity_type="conversation", + entity_id=conversation_id, + status="pending", + ) + db.add(queue) + await db.flush() + except Exception as e: + logger.error(f"Failed to queue file sync: {e}") + + return str(file_path) + + def get_file_type(self, filename: str) -> str: + ext = Path(filename).suffix.lower() + if ext in {".jpg", ".jpeg", ".png", ".gif"}: + return "image" + if ext in {".pdf", ".docx", ".xlsx", ".txt"}: + return "document" + return "other" + + +storage = FileStorage() diff --git a/max_bot/app/fsm.py b/max_bot/app/fsm.py new file mode 100644 index 0000000..51d8b7b --- /dev/null +++ b/max_bot/app/fsm.py @@ -0,0 +1,209 @@ +from enum import Enum + + +class BotState(str, Enum): + IDLE = "idle" + NAME_REQUEST = "name_request" + CONSENT_REQUEST = "consent_request" + CONTACT_REQUEST = "contact_request" + INQUIRY_REQUEST = "inquiry_request" + CATEGORY_SELECT = "category_select" + CONFIRMATION = "confirmation" + HANDOFF_REQUEST = "handoff_request" + FEATURE_RISK_SCORE = "feature_risk_score" + FEATURE_SLA_STATUS = "feature_sla_status" + FEATURE_PHOTO_REPORT = "feature_photo_report" + FEATURE_APPOINTMENT = "feature_appointment" + FEATURE_QUALITY_RATE = "feature_quality_rate" + FEATURE_COMMERCIAL = "feature_commercial" + FEATURE_MY_OBJECTS = "feature_my_objects" + FEATURE_REMINDERS = "feature_reminders" + SLA_TICKET_CREATE = "sla_ticket_create" + KB_SEARCH = "kb_search" + FEATURE_NOTIFICATIONS = "feature_notifications" + FEATURE_BROADCAST = "feature_broadcast" + AWAITING_RATING = "awaiting_rating" + WAITING_1C_RESPONSE = "waiting_1c_response" + MAIN_MENU = "main_menu" + HANDOFF_CONFIRMATION = "handoff_confirmation" + OUT_OF_HOURS = "out_of_hours" + + +STATE_TRANSITIONS = { + BotState.IDLE: { + "start": BotState.NAME_REQUEST, + "new_inquiry": BotState.NAME_REQUEST, + "handoff": BotState.HANDOFF_REQUEST, + "main_menu": BotState.MAIN_MENU, + "cancel": BotState.IDLE, + }, + BotState.NAME_REQUEST: { + "name_provided": BotState.CONSENT_REQUEST, + "cancel": BotState.IDLE, + }, + BotState.CONSENT_REQUEST: { + "consent_given": BotState.CONTACT_REQUEST, + "consent_refused": BotState.IDLE, + "cancel": BotState.IDLE, + }, + BotState.CONTACT_REQUEST: { + "contact_provided": BotState.INQUIRY_REQUEST, + "cancel": BotState.IDLE, + }, + BotState.INQUIRY_REQUEST: { + "inquiry_provided": BotState.CATEGORY_SELECT, + "cancel": BotState.IDLE, + }, + BotState.CATEGORY_SELECT: { + "category_selected": BotState.CONFIRMATION, + "cancel": BotState.IDLE, + }, + BotState.CONFIRMATION: { + "done": BotState.IDLE, + "edit_name": BotState.NAME_REQUEST, + "edit_contact": BotState.CONTACT_REQUEST, + "edit_inquiry": BotState.INQUIRY_REQUEST, + }, + BotState.HANDOFF_REQUEST: { + "confirmed": BotState.HANDOFF_CONFIRMATION, + "cancel": BotState.MAIN_MENU, + }, + BotState.HANDOFF_CONFIRMATION: { + "done": BotState.IDLE, + "cancel": BotState.MAIN_MENU, + }, + BotState.OUT_OF_HOURS: { + "leave_message": BotState.INQUIRY_REQUEST, + "callback": BotState.NAME_REQUEST, + "cancel": BotState.IDLE, + "main_menu": BotState.MAIN_MENU, + }, + BotState.MAIN_MENU: { + "new_inquiry": BotState.NAME_REQUEST, + "feature_risk_score": BotState.FEATURE_RISK_SCORE, + "feature_sla_status": BotState.FEATURE_SLA_STATUS, + "feature_photo_report": BotState.FEATURE_PHOTO_REPORT, + "feature_appointment": BotState.FEATURE_APPOINTMENT, + "feature_quality_rate": BotState.FEATURE_QUALITY_RATE, + "feature_commercial": BotState.FEATURE_COMMERCIAL, + "feature_my_objects": BotState.FEATURE_MY_OBJECTS, + "feature_reminders": BotState.FEATURE_REMINDERS, + "feature_notifications": BotState.FEATURE_NOTIFICATIONS, + "feature_broadcast": BotState.FEATURE_BROADCAST, + "sla_ticket_create": BotState.SLA_TICKET_CREATE, + "handoff": BotState.HANDOFF_REQUEST, + "kb_search": BotState.KB_SEARCH, + "cancel": BotState.IDLE, + }, + BotState.FEATURE_RISK_SCORE: { + "done": BotState.MAIN_MENU, + "cancel": BotState.MAIN_MENU, + }, + BotState.FEATURE_SLA_STATUS: { + "done": BotState.MAIN_MENU, + "cancel": BotState.MAIN_MENU, + }, + BotState.FEATURE_PHOTO_REPORT: { + "done": BotState.MAIN_MENU, + "photo_provided": BotState.MAIN_MENU, + "cancel": BotState.MAIN_MENU, + }, + BotState.FEATURE_APPOINTMENT: { + "done": BotState.MAIN_MENU, + "date_provided": BotState.MAIN_MENU, + "cancel": BotState.MAIN_MENU, + }, + BotState.FEATURE_QUALITY_RATE: { + "done": BotState.MAIN_MENU, + "rating_provided": BotState.MAIN_MENU, + "cancel": BotState.MAIN_MENU, + }, + BotState.FEATURE_COMMERCIAL: { + "done": BotState.MAIN_MENU, + "cancel": BotState.MAIN_MENU, + }, + BotState.FEATURE_MY_OBJECTS: { + "done": BotState.MAIN_MENU, + "cancel": BotState.MAIN_MENU, + }, + BotState.FEATURE_REMINDERS: { + "done": BotState.MAIN_MENU, + "cancel": BotState.MAIN_MENU, + }, + BotState.FEATURE_NOTIFICATIONS: { + "done": BotState.MAIN_MENU, + "cancel": BotState.MAIN_MENU, + }, + BotState.FEATURE_BROADCAST: { + "done": BotState.MAIN_MENU, + "cancel": BotState.MAIN_MENU, + }, + BotState.SLA_TICKET_CREATE: { + "done": BotState.MAIN_MENU, + "cancel": BotState.MAIN_MENU, + }, + BotState.KB_SEARCH: { + "found": BotState.MAIN_MENU, + "not_found": BotState.INQUIRY_REQUEST, + "cancel": BotState.MAIN_MENU, + }, + BotState.AWAITING_RATING: { + "rating_provided": BotState.IDLE, + "skip": BotState.IDLE, + }, + BotState.WAITING_1C_RESPONSE: { + "received": BotState.MAIN_MENU, + "timeout": BotState.IDLE, + "cancel": BotState.MAIN_MENU, + }, +} + + +class FSM: + _states: dict = {} + _tmp: dict = {} + + @classmethod + def get_state(cls, user_id: int) -> BotState: + return cls._states.get(user_id, BotState.IDLE) + + @classmethod + def set_state(cls, user_id: int, state: BotState): + cls._states[user_id] = state + + @classmethod + def reset(cls, user_id: int): + cls._states.pop(user_id, None) + cls._tmp.pop(user_id, None) + + @classmethod + def set_temp(cls, user_id: int, data: dict): + cls._tmp[user_id] = data + + @classmethod + def get_temp(cls, user_id: int) -> dict: + return cls._tmp.get(user_id, {}) + + @classmethod + def clear_temp(cls, user_id: int): + cls._tmp.pop(user_id, None) + + @classmethod + def can_transition(cls, user_id: int, event: str) -> bool: + current = cls.get_state(user_id) + return event in STATE_TRANSITIONS.get(current, {}) + + @classmethod + def transition(cls, user_id: int, event: str) -> BotState: + current = cls.get_state(user_id) + transitions = STATE_TRANSITIONS.get(current, {}) + if event not in transitions: + return current + new_state = transitions[event] + cls.set_state(user_id, new_state) + return new_state + + @classmethod + def clear_all(cls): + cls._states.clear() + cls._tmp.clear() diff --git a/max_bot/app/handlers/__init__.py b/max_bot/app/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/max_bot/app/handlers/__pycache__/__init__.cpython-314.pyc b/max_bot/app/handlers/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..ee094bc Binary files /dev/null and b/max_bot/app/handlers/__pycache__/__init__.cpython-314.pyc differ diff --git a/max_bot/app/handlers/__pycache__/consent.cpython-314.pyc b/max_bot/app/handlers/__pycache__/consent.cpython-314.pyc new file mode 100644 index 0000000..8be9a87 Binary files /dev/null and b/max_bot/app/handlers/__pycache__/consent.cpython-314.pyc differ diff --git a/max_bot/app/handlers/__pycache__/consent.cpython-38.pyc b/max_bot/app/handlers/__pycache__/consent.cpython-38.pyc new file mode 100644 index 0000000..072bca1 Binary files /dev/null and b/max_bot/app/handlers/__pycache__/consent.cpython-38.pyc differ diff --git a/max_bot/app/handlers/__pycache__/contact.cpython-38.pyc b/max_bot/app/handlers/__pycache__/contact.cpython-38.pyc new file mode 100644 index 0000000..2ab86d8 Binary files /dev/null and b/max_bot/app/handlers/__pycache__/contact.cpython-38.pyc differ diff --git a/max_bot/app/handlers/__pycache__/greeting.cpython-314.pyc b/max_bot/app/handlers/__pycache__/greeting.cpython-314.pyc new file mode 100644 index 0000000..fb85bb1 Binary files /dev/null and b/max_bot/app/handlers/__pycache__/greeting.cpython-314.pyc differ diff --git a/max_bot/app/handlers/__pycache__/greeting.cpython-38.pyc b/max_bot/app/handlers/__pycache__/greeting.cpython-38.pyc new file mode 100644 index 0000000..212e202 Binary files /dev/null and b/max_bot/app/handlers/__pycache__/greeting.cpython-38.pyc differ diff --git a/max_bot/app/handlers/__pycache__/handoff.cpython-314.pyc b/max_bot/app/handlers/__pycache__/handoff.cpython-314.pyc new file mode 100644 index 0000000..8a4a530 Binary files /dev/null and b/max_bot/app/handlers/__pycache__/handoff.cpython-314.pyc differ diff --git a/max_bot/app/handlers/__pycache__/handoff.cpython-38.pyc b/max_bot/app/handlers/__pycache__/handoff.cpython-38.pyc new file mode 100644 index 0000000..62c3362 Binary files /dev/null and b/max_bot/app/handlers/__pycache__/handoff.cpython-38.pyc differ diff --git a/max_bot/app/handlers/__pycache__/inquiry.cpython-314.pyc b/max_bot/app/handlers/__pycache__/inquiry.cpython-314.pyc new file mode 100644 index 0000000..c6453e3 Binary files /dev/null and b/max_bot/app/handlers/__pycache__/inquiry.cpython-314.pyc differ diff --git a/max_bot/app/handlers/__pycache__/inquiry.cpython-38.pyc b/max_bot/app/handlers/__pycache__/inquiry.cpython-38.pyc new file mode 100644 index 0000000..3e852b5 Binary files /dev/null and b/max_bot/app/handlers/__pycache__/inquiry.cpython-38.pyc differ diff --git a/max_bot/app/handlers/__pycache__/main_menu.cpython-314.pyc b/max_bot/app/handlers/__pycache__/main_menu.cpython-314.pyc new file mode 100644 index 0000000..a4cb8b8 Binary files /dev/null and b/max_bot/app/handlers/__pycache__/main_menu.cpython-314.pyc differ diff --git a/max_bot/app/handlers/__pycache__/main_menu.cpython-38.pyc b/max_bot/app/handlers/__pycache__/main_menu.cpython-38.pyc new file mode 100644 index 0000000..e0e0a20 Binary files /dev/null and b/max_bot/app/handlers/__pycache__/main_menu.cpython-38.pyc differ diff --git a/max_bot/app/handlers/__pycache__/out_of_hours.cpython-314.pyc b/max_bot/app/handlers/__pycache__/out_of_hours.cpython-314.pyc new file mode 100644 index 0000000..06d3682 Binary files /dev/null and b/max_bot/app/handlers/__pycache__/out_of_hours.cpython-314.pyc differ diff --git a/max_bot/app/handlers/consent.py b/max_bot/app/handlers/consent.py new file mode 100644 index 0000000..6f0bf4e --- /dev/null +++ b/max_bot/app/handlers/consent.py @@ -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) diff --git a/max_bot/app/handlers/contact.py b/max_bot/app/handlers/contact.py new file mode 100644 index 0000000..f580149 --- /dev/null +++ b/max_bot/app/handlers/contact.py @@ -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, чтобы мы могли с вами связаться.") diff --git a/max_bot/app/handlers/features/__init__.py b/max_bot/app/handlers/features/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/max_bot/app/handlers/features/__pycache__/appointment.cpython-38.pyc b/max_bot/app/handlers/features/__pycache__/appointment.cpython-38.pyc new file mode 100644 index 0000000..35cca08 Binary files /dev/null and b/max_bot/app/handlers/features/__pycache__/appointment.cpython-38.pyc differ diff --git a/max_bot/app/handlers/features/__pycache__/commercial.cpython-38.pyc b/max_bot/app/handlers/features/__pycache__/commercial.cpython-38.pyc new file mode 100644 index 0000000..ec7198b Binary files /dev/null and b/max_bot/app/handlers/features/__pycache__/commercial.cpython-38.pyc differ diff --git a/max_bot/app/handlers/features/__pycache__/my_objects.cpython-38.pyc b/max_bot/app/handlers/features/__pycache__/my_objects.cpython-38.pyc new file mode 100644 index 0000000..32a6f38 Binary files /dev/null and b/max_bot/app/handlers/features/__pycache__/my_objects.cpython-38.pyc differ diff --git a/max_bot/app/handlers/features/__pycache__/photo_report.cpython-38.pyc b/max_bot/app/handlers/features/__pycache__/photo_report.cpython-38.pyc new file mode 100644 index 0000000..6f7454a Binary files /dev/null and b/max_bot/app/handlers/features/__pycache__/photo_report.cpython-38.pyc differ diff --git a/max_bot/app/handlers/features/__pycache__/quality_rate.cpython-38.pyc b/max_bot/app/handlers/features/__pycache__/quality_rate.cpython-38.pyc new file mode 100644 index 0000000..bc94260 Binary files /dev/null and b/max_bot/app/handlers/features/__pycache__/quality_rate.cpython-38.pyc differ diff --git a/max_bot/app/handlers/features/__pycache__/risk_score.cpython-38.pyc b/max_bot/app/handlers/features/__pycache__/risk_score.cpython-38.pyc new file mode 100644 index 0000000..d1a1009 Binary files /dev/null and b/max_bot/app/handlers/features/__pycache__/risk_score.cpython-38.pyc differ diff --git a/max_bot/app/handlers/features/__pycache__/sla_status.cpython-38.pyc b/max_bot/app/handlers/features/__pycache__/sla_status.cpython-38.pyc new file mode 100644 index 0000000..6224889 Binary files /dev/null and b/max_bot/app/handlers/features/__pycache__/sla_status.cpython-38.pyc differ diff --git a/max_bot/app/handlers/features/appointment.py b/max_bot/app/handlers/features/appointment.py new file mode 100644 index 0000000..31aae79 --- /dev/null +++ b/max_bot/app/handlers/features/appointment.py @@ -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 diff --git a/max_bot/app/handlers/features/broadcasts.py b/max_bot/app/handlers/features/broadcasts.py new file mode 100644 index 0000000..4d19b53 --- /dev/null +++ b/max_bot/app/handlers/features/broadcasts.py @@ -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) diff --git a/max_bot/app/handlers/features/commercial.py b/max_bot/app/handlers/features/commercial.py new file mode 100644 index 0000000..a5fb027 --- /dev/null +++ b/max_bot/app/handlers/features/commercial.py @@ -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 diff --git a/max_bot/app/handlers/features/my_objects.py b/max_bot/app/handlers/features/my_objects.py new file mode 100644 index 0000000..0ef1dae --- /dev/null +++ b/max_bot/app/handlers/features/my_objects.py @@ -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) diff --git a/max_bot/app/handlers/features/notifications.py b/max_bot/app/handlers/features/notifications.py new file mode 100644 index 0000000..436b047 --- /dev/null +++ b/max_bot/app/handlers/features/notifications.py @@ -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) diff --git a/max_bot/app/handlers/features/photo_report.py b/max_bot/app/handlers/features/photo_report.py new file mode 100644 index 0000000..6669dae --- /dev/null +++ b/max_bot/app/handlers/features/photo_report.py @@ -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) diff --git a/max_bot/app/handlers/features/quality_rate.py b/max_bot/app/handlers/features/quality_rate.py new file mode 100644 index 0000000..35782fc --- /dev/null +++ b/max_bot/app/handlers/features/quality_rate.py @@ -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) diff --git a/max_bot/app/handlers/features/reminders.py b/max_bot/app/handlers/features/reminders.py new file mode 100644 index 0000000..afee5e7 --- /dev/null +++ b/max_bot/app/handlers/features/reminders.py @@ -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 diff --git a/max_bot/app/handlers/features/risk_score.py b/max_bot/app/handlers/features/risk_score.py new file mode 100644 index 0000000..f294715 --- /dev/null +++ b/max_bot/app/handlers/features/risk_score.py @@ -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) diff --git a/max_bot/app/handlers/features/sla_status.py b/max_bot/app/handlers/features/sla_status.py new file mode 100644 index 0000000..8d3376d --- /dev/null +++ b/max_bot/app/handlers/features/sla_status.py @@ -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) diff --git a/max_bot/app/handlers/features/sla_ticket.py b/max_bot/app/handlers/features/sla_ticket.py new file mode 100644 index 0000000..d5189f0 --- /dev/null +++ b/max_bot/app/handlers/features/sla_ticket.py @@ -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) diff --git a/max_bot/app/handlers/greeting.py b/max_bot/app/handlers/greeting.py new file mode 100644 index 0000000..5c43003 --- /dev/null +++ b/max_bot/app/handlers/greeting.py @@ -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) diff --git a/max_bot/app/handlers/handoff.py b/max_bot/app/handlers/handoff.py new file mode 100644 index 0000000..2c3dc45 --- /dev/null +++ b/max_bot/app/handlers/handoff.py @@ -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) diff --git a/max_bot/app/handlers/inquiry.py b/max_bot/app/handlers/inquiry.py new file mode 100644 index 0000000..2e6bfd7 --- /dev/null +++ b/max_bot/app/handlers/inquiry.py @@ -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) diff --git a/max_bot/app/handlers/main_menu.py b/max_bot/app/handlers/main_menu.py new file mode 100644 index 0000000..80bba2e --- /dev/null +++ b/max_bot/app/handlers/main_menu.py @@ -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) diff --git a/max_bot/app/handlers/out_of_hours.py b/max_bot/app/handlers/out_of_hours.py new file mode 100644 index 0000000..4b726a7 --- /dev/null +++ b/max_bot/app/handlers/out_of_hours.py @@ -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) diff --git a/max_bot/app/integrations/_1c_unf.py b/max_bot/app/integrations/_1c_unf.py new file mode 100644 index 0000000..02aafc6 --- /dev/null +++ b/max_bot/app/integrations/_1c_unf.py @@ -0,0 +1,100 @@ +import json +import logging +from datetime import datetime +from typing import Optional +import aiohttp +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.models import BotConversation, Bot1CSyncLog, BotSetting +from app.settings_cache import SettingsCache + +logger = logging.getLogger(__name__) + + +async def send_to_1c(db: AsyncSession, conversation: BotConversation) -> Optional[dict]: + webhook_url = SettingsCache.get("1c_webhook_url") + if not webhook_url: + return None + + user = conversation.user + category = conversation.category + + payload = { + "conversation_id": conversation.id, + "user": { + "max_user_id": user.max_user_id, + "first_name": user.first_name, + "last_name": user.last_name, + "phone": user.phone, + "email": user.email, + }, + "inquiry": { + "text": conversation.inquiry_text, + "category": category.name if category else "", + "priority": conversation.priority, + "has_attachment": conversation.has_attachment, + "attachment_type": conversation.attachment_type, + }, + "created_at": conversation.created_at.isoformat() if conversation.created_at else "", + } + + sync_log = Bot1CSyncLog( + conversation_id=conversation.id, + status="pending", + request_payload=json.dumps(payload, ensure_ascii=False), + ) + db.add(sync_log) + await db.flush() + + try: + async with aiohttp.ClientSession() as session: + async with session.post(webhook_url, json=payload, timeout=aiohttp.ClientTimeout(total=30)) as resp: + response_text = await resp.text() + sync_log.status = "sent" if resp.status == 200 else "error" + sync_log.response_payload = response_text + sync_log.sent_at = datetime.now() + await db.flush() + if resp.status == 200: + try: + return await resp.json() + except Exception: + return {"raw": response_text} + logger.warning(f"1C webhook returned {resp.status}: {response_text[:500]}") + return None + except Exception as e: + logger.error(f"1C webhook error: {e}") + sync_log.status = "error" + sync_log.response_payload = str(e) + sync_log.sent_at = datetime.now() + await db.flush() + return None + + +async def handle_1c_response(db: AsyncSession, data: dict) -> bool: + conversation_id = data.get("conversation_id") + if not conversation_id: + return False + + result = await db.execute(select(BotConversation).where(BotConversation.id == int(conversation_id))) + conv = result.scalar_one_or_none() + if not conv: + return False + + sync_log = Bot1CSyncLog( + conversation_id=conv.id, + status="confirmed", + response_payload=json.dumps(data, ensure_ascii=False), + confirmed_at=datetime.now(), + ) + db.add(sync_log) + + status = data.get("status", "") + if status: + conv.status = status + + assigned = data.get("assigned_to", "") + if assigned: + conv.assigned_to = assigned + + await db.flush() + return True diff --git a/max_bot/app/integrations/__init__.py b/max_bot/app/integrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/max_bot/app/integrations/__pycache__/_1c_unf.cpython-314.pyc b/max_bot/app/integrations/__pycache__/_1c_unf.cpython-314.pyc new file mode 100644 index 0000000..da6d69a Binary files /dev/null and b/max_bot/app/integrations/__pycache__/_1c_unf.cpython-314.pyc differ diff --git a/max_bot/app/integrations/__pycache__/_1c_unf.cpython-38.pyc b/max_bot/app/integrations/__pycache__/_1c_unf.cpython-38.pyc new file mode 100644 index 0000000..fab329d Binary files /dev/null and b/max_bot/app/integrations/__pycache__/_1c_unf.cpython-38.pyc differ diff --git a/max_bot/app/integrations/__pycache__/__init__.cpython-314.pyc b/max_bot/app/integrations/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..de10113 Binary files /dev/null and b/max_bot/app/integrations/__pycache__/__init__.cpython-314.pyc differ diff --git a/max_bot/app/integrations/__pycache__/portal_db.cpython-314.pyc b/max_bot/app/integrations/__pycache__/portal_db.cpython-314.pyc new file mode 100644 index 0000000..64e6d45 Binary files /dev/null and b/max_bot/app/integrations/__pycache__/portal_db.cpython-314.pyc differ diff --git a/max_bot/app/integrations/__pycache__/portal_db.cpython-38.pyc b/max_bot/app/integrations/__pycache__/portal_db.cpython-38.pyc new file mode 100644 index 0000000..bdf673a Binary files /dev/null and b/max_bot/app/integrations/__pycache__/portal_db.cpython-38.pyc differ diff --git a/max_bot/app/integrations/__pycache__/yandex_gpt.cpython-314.pyc b/max_bot/app/integrations/__pycache__/yandex_gpt.cpython-314.pyc new file mode 100644 index 0000000..1291fde Binary files /dev/null and b/max_bot/app/integrations/__pycache__/yandex_gpt.cpython-314.pyc differ diff --git a/max_bot/app/integrations/__pycache__/yandex_gpt.cpython-38.pyc b/max_bot/app/integrations/__pycache__/yandex_gpt.cpython-38.pyc new file mode 100644 index 0000000..9c8dc0a Binary files /dev/null and b/max_bot/app/integrations/__pycache__/yandex_gpt.cpython-38.pyc differ diff --git a/max_bot/app/integrations/portal_db.py b/max_bot/app/integrations/portal_db.py new file mode 100644 index 0000000..dcd74f4 --- /dev/null +++ b/max_bot/app/integrations/portal_db.py @@ -0,0 +1,57 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.models import Object, SLAContract, Task, Incident, Customer + + +async def get_object_by_name_or_id(db: AsyncSession, query: str) -> Object: + try: + obj_id = int(query) + return await db.get(Object, obj_id) + except (ValueError, TypeError): + result = await db.execute( + select(Object).where(Object.name.ilike(f"%{query}%")).limit(1) + ) + return result.scalar_one_or_none() + + +async def get_sla_for_object(db: AsyncSession, object_id: int) -> SLAContract: + result = await db.execute( + select(SLAContract).where( + SLAContract.object_id == object_id, + SLAContract.status == "active" + ).order_by(SLAContract.created_at.desc()).limit(1) + ) + return result.scalar_one_or_none() + + +async def get_objects_for_customer(db: AsyncSession, customer_name: str) -> list: + result = await db.execute( + select(Customer).where(Customer.name.ilike(f"%{customer_name}%")).limit(1) + ) + customer = result.scalar_one_or_none() + if not customer: + return [] + result = await db.execute( + select(Object).where(Object.customer_id == customer.id, Object.status == "active") + ) + return result.scalars().all() + + +async def get_active_tasks_for_object(db: AsyncSession, object_id: int) -> list: + result = await db.execute( + select(Task).where( + Task.object_id == object_id, + Task.status.in_(["open", "in_progress"]) + ) + ) + return result.scalars().all() + + +async def get_last_incident_for_object(db: AsyncSession, object_id: int): + result = await db.execute( + select(Incident).where( + Incident.object_id == object_id, + Incident.status.in_(["resolved", "closed"]) + ).order_by(Incident.created_at.desc()).limit(1) + ) + return result.scalar_one_or_none() diff --git a/max_bot/app/integrations/yandex_gpt.py b/max_bot/app/integrations/yandex_gpt.py new file mode 100644 index 0000000..86e682f --- /dev/null +++ b/max_bot/app/integrations/yandex_gpt.py @@ -0,0 +1,80 @@ +import aiohttp +import logging +from app.settings_cache import SettingsCache + +logger = logging.getLogger(__name__) + + +async def categorize_with_gpt(text: str) -> str: + gpt_key = SettingsCache.get("yandex_gpt_key") + folder_id = SettingsCache.get("yandex_folder_id") + if not gpt_key or not folder_id: + return "" + + prompt = ( + "Определи категорию обращения клиента из списка: " + "Видеонаблюдение, СКУД, Пожарная сигнализация, IT-инфраструктура, " + "Обслуживание, Консультация, Другое. " + "Ответь только названием категории, без пояснений. " + f"Текст обращения: {text}" + ) + + url = "https://llm.api.cloud.yandex.net/foundationModels/v1/completion" + headers = { + "Authorization": f"Api-Key {gpt_key}", + "Content-Type": "application/json", + "x-folder-id": folder_id, + } + body = { + "modelUri": f"gpt://{folder_id}/yandexgpt-lite/latest", + "completionOptions": {"stream": False, "temperature": 0.1, "maxTokens": 50}, + "messages": [{"role": "user", "text": prompt}], + } + + try: + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=headers, json=body) as resp: + if resp.status == 200: + data = await resp.json() + return data.get("result", {}).get("alternatives", [{}])[0].get("message", {}).get("text", "").strip() + logger.warning(f"GPT categorize returned {resp.status}") + except Exception as e: + logger.error(f"GPT categorize error: {e}") + return "" + + +async def analyze_emotion_with_gpt(text: str) -> str: + gpt_key = SettingsCache.get("yandex_gpt_key") + folder_id = SettingsCache.get("yandex_folder_id") + if not gpt_key or not folder_id: + return "neutral" + + prompt = ( + "Определи эмоциональную окраску текста клиента. " + "Варианты: positive, neutral, negative. " + "Ответь только одним словом. " + f"Текст: {text}" + ) + + url = "https://llm.api.cloud.yandex.net/foundationModels/v1/completion" + headers = { + "Authorization": f"Api-Key {gpt_key}", + "Content-Type": "application/json", + "x-folder-id": folder_id, + } + body = { + "modelUri": f"gpt://{folder_id}/yandexgpt-lite/latest", + "completionOptions": {"stream": False, "temperature": 0.1, "maxTokens": 10}, + "messages": [{"role": "user", "text": prompt}], + } + + try: + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=headers, json=body) as resp: + if resp.status == 200: + data = await resp.json() + return data.get("result", {}).get("alternatives", [{}])[0].get("message", {}).get("text", "").strip().lower() + logger.warning(f"GPT emotion analysis returned {resp.status}") + except Exception as e: + logger.error(f"GPT emotion analysis error: {e}") + return "neutral" diff --git a/max_bot/app/keyboards/__init__.py b/max_bot/app/keyboards/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/max_bot/app/keyboards/__pycache__/__init__.cpython-314.pyc b/max_bot/app/keyboards/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..e67fec3 Binary files /dev/null and b/max_bot/app/keyboards/__pycache__/__init__.cpython-314.pyc differ diff --git a/max_bot/app/keyboards/__pycache__/inline.cpython-314.pyc b/max_bot/app/keyboards/__pycache__/inline.cpython-314.pyc new file mode 100644 index 0000000..b4dd1a2 Binary files /dev/null and b/max_bot/app/keyboards/__pycache__/inline.cpython-314.pyc differ diff --git a/max_bot/app/keyboards/__pycache__/inline.cpython-38.pyc b/max_bot/app/keyboards/__pycache__/inline.cpython-38.pyc new file mode 100644 index 0000000..67ae683 Binary files /dev/null and b/max_bot/app/keyboards/__pycache__/inline.cpython-38.pyc differ diff --git a/max_bot/app/keyboards/inline.py b/max_bot/app/keyboards/inline.py new file mode 100644 index 0000000..c18e6ee --- /dev/null +++ b/max_bot/app/keyboards/inline.py @@ -0,0 +1,90 @@ +from app.settings_cache import SettingsCache + + +def build_main_menu_buttons() -> list: + buttons = [] + row = [ + {"type": "message", "text": "📋 Оставить заявку", "payload": "new_inquiry"}, + {"type": "message", "text": "📞 Связаться с оператором", "payload": "handoff"}, + ] + buttons.append(row) + + features = SettingsCache.get_enabled_features() + feature_buttons = [] + feature_map = { + "risk_score": ("📊 Risk Score", "feature_risk_score"), + "sla_status": ("🔍 SLA статус", "feature_sla_status"), + "photo_report": ("📸 Фото-отчёт", "feature_photo_report"), + "appointment": ("📅 Запись на визит", "feature_appointment"), + "quality_rate": ("⭐ Оценка качества", "feature_quality_rate"), + "commercial_offer": ("📄 Коммерческое предложение", "feature_commercial"), + "my_objects": ("🏢 Мои объекты", "feature_my_objects"), + "reminders": ("🔔 Напоминания", "feature_reminders"), + "notifications": ("🔔 Уведомления", "feature_notifications"), + "broadcasts": ("📢 Рассылки", "feature_broadcast"), + } + for key, (label, payload) in feature_map.items(): + if key in features: + feature_buttons.append({"type": "message", "text": label, "payload": payload}) + + while feature_buttons: + buttons.append(feature_buttons[:2]) + feature_buttons = feature_buttons[2:] + + row = [{"type": "message", "text": "❓ Задать вопрос", "payload": "kb_search"}] + buttons.append(row) + + return buttons + + +def build_consent_buttons() -> list: + return [[ + {"type": "message", "text": "✅ Даю согласие", "payload": "consent_given"}, + ], [ + {"type": "message", "text": "❌ Не даю согласие", "payload": "consent_refused"}, + ]] + + +def build_contact_buttons() -> list: + return [[ + {"type": "request_contact", "text": "📱 Поделиться контактом"}, + ]] + + +def build_category_buttons() -> list: + categories = SettingsCache.get_categories() + buttons = [] + row = [] + for cat in categories: + row.append({"type": "message", "text": f"{cat.icon_emoji} {cat.name}", "payload": f"cat_{cat.id}"}) + if len(row) >= 2: + buttons.append(row) + row = [] + if row: + buttons.append(row) + return buttons + + +def build_quality_buttons() -> list: + return [[ + {"type": "callback", "text": "⭐", "payload": "rate_1"}, + {"type": "callback", "text": "⭐⭐", "payload": "rate_2"}, + {"type": "callback", "text": "⭐⭐⭐", "payload": "rate_3"}, + ], [ + {"type": "callback", "text": "⭐⭐⭐⭐", "payload": "rate_4"}, + {"type": "callback", "text": "⭐⭐⭐⭐⭐", "payload": "rate_5"}, + ]] + + +def build_cancel_button() -> list: + return [[{"type": "message", "text": "↩️ Отмена", "payload": "cancel"}]] + + +def build_phone_email_buttons(phone_1: str, phone_2: str, email: str, subject: str) -> list: + from urllib.parse import quote + buttons = [ + [{"type": "link", "text": f"📞 {phone_1}", "url": f"tel:{phone_1.replace(' ', '').replace('-', '').replace('(', '').replace(')', '')}"}], + [{"type": "link", "text": f"📞 {phone_2}", "url": f"tel:{phone_2.replace(' ', '').replace('-', '').replace('(', '').replace(')', '')}"}], + [{"type": "link", "text": f"✉️ {email}", "url": f"mailto:{email}?subject={quote(subject)}"}], + ] + return buttons diff --git a/max_bot/app/main.py b/max_bot/app/main.py new file mode 100644 index 0000000..69e4dc3 --- /dev/null +++ b/max_bot/app/main.py @@ -0,0 +1,879 @@ +import json +import logging +from contextlib import asynccontextmanager +from datetime import datetime +from typing import Optional + +from fastapi import FastAPI, Request, Depends, Query +from fastapi.responses import JSONResponse, HTMLResponse +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_ + +from app.config import get_settings +from app.database import get_db, async_session, engine +from app.models.models import Base +from app.max_api import MaxAPIClient +from app.settings_cache import SettingsCache +from app.bot_engine import handle_update +from app.text_renderer import render_template, make_clickable_links + +settings = get_settings() +logger = logging.getLogger(__name__) + +max_api: Optional[MaxAPIClient] = None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + global max_api + async with engine.begin() as conn: + pass + + async with async_session() as db: + await SettingsCache.refresh(db) + + token = SettingsCache.get_bot_token() + if token: + max_api = MaxAPIClient(token) + try: + me = await max_api.get_me() + logger.info(f"Bot connected: {me}") + except Exception as e: + logger.error(f"Bot connection failed: {e}") + else: + logger.warning("No bot token configured") + + yield + + if max_api: + await max_api.close() + + +app = FastAPI(title="AegisOne Max Bot — София", lifespan=lifespan) + + +@app.post(settings.WEBHOOK_PATH) +async def webhook(request: Request, db: AsyncSession = Depends(get_db)): + secret_header = request.headers.get("X-Max-Bot-Api-Secret", "") + webhook_secret = settings.WEBHOOK_SECRET + if webhook_secret and secret_header != webhook_secret: + logger.warning("Invalid webhook secret") + return JSONResponse({"error": "Invalid secret"}, status_code=403) + + raw = await request.body() + try: + body = json.loads(raw) + except json.JSONDecodeError as e: + logger.error(f"Invalid JSON from MAX: {e}, raw={raw[:500]}") + return JSONResponse({"ok": False}, status_code=200) + + logger.info(f"Webhook update: {body.get('update_type', 'unknown')}") + + global max_api + if not max_api: + token = SettingsCache.get_bot_token() + if token: + max_api = MaxAPIClient(token) + + try: + await handle_update(None, body, db, max_api) + await db.commit() + except Exception as e: + logger.error(f"Webhook handler error: {e}", exc_info=True) + await db.rollback() + + return JSONResponse({"ok": True}) + + +@app.get("/api/1c/response") +@app.post("/api/1c/response") +async def api_1c_response(request: Request, db: AsyncSession = Depends(get_db)): + try: + body = await request.json() + from app.integrations import _1c_unf as ic_integration + success = await ic_integration.handle_1c_response(db, body) + await db.commit() + if success: + conv_id = body.get("conversation_id") + user_id = body.get("user_id") + if user_id and max_api: + status = body.get("status", "в работе") + engineer = body.get("engineer", "") + from app.conversation import get_template_rendered + text = await get_template_rendered(db, "status_update", + conv_id=conv_id, status=status, engineer=engineer) + await max_api.send_message(user_id, text, format="markdown") + return JSONResponse({"ok": success}) + except Exception as e: + logger.error(f"1C response error: {e}", exc_info=True) + await db.rollback() + return JSONResponse({"ok": False, "error": str(e)}, status_code=500) + + +@app.get("/health") +async def health(): + return {"status": "ok", "bot": "София"} + + +# ===================== Service Portal API ===================== + +@app.get("/api/bot/settings") +async def api_bot_settings(db: AsyncSession = Depends(get_db)): + await SettingsCache.refresh(db) + return JSONResponse(SettingsCache.get_all()) + + +@app.post("/api/bot/settings/save") +async def api_bot_settings_save(request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotSetting + body = await request.json() + for key, value in body.items(): + result = await db.execute(select(BotSetting).where(BotSetting.key == key)) + setting = result.scalar_one_or_none() + if setting: + setting.value = str(value) + else: + db.add(BotSetting(key=key, value=str(value))) + await db.commit() + SettingsCache._last_update = 0 + return JSONResponse({"ok": True}) + + +@app.get("/api/bot/templates") +async def api_bot_templates(db: AsyncSession = Depends(get_db)): + await SettingsCache.refresh(db) + return JSONResponse(SettingsCache.get_all_templates()) + + +@app.post("/api/bot/templates/save") +async def api_bot_templates_save(request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotResponseTemplate + body = await request.json() + for key, value in body.items(): + result = await db.execute(select(BotResponseTemplate).where(BotResponseTemplate.template_key == key)) + tmpl = result.scalar_one_or_none() + if tmpl: + tmpl.template_text = value + else: + db.add(BotResponseTemplate(template_key=key, template_text=value)) + await db.commit() + SettingsCache._last_update = 0 + return JSONResponse({"ok": True}) + + +@app.get("/api/bot/features") +async def api_bot_features(db: AsyncSession = Depends(get_db)): + from app.models.models import BotFeature + result = await db.execute(select(BotFeature).order_by(BotFeature.sort_order)) + features = result.scalars().all() + return JSONResponse([ + {"key": f.feature_key, "name": f.name, "description": f.description, "enabled": f.enabled} + for f in features + ]) + + +@app.post("/api/bot/features/toggle") +async def api_bot_features_toggle(request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotFeature + body = await request.json() + key = body.get("feature_key") + enabled = body.get("enabled", False) + result = await db.execute(select(BotFeature).where(BotFeature.feature_key == key)) + feature = result.scalar_one_or_none() + if feature: + feature.enabled = enabled + await db.commit() + SettingsCache._last_update = 0 + return JSONResponse({"ok": True}) + + +@app.get("/api/bot/categories") +async def api_bot_categories(db: AsyncSession = Depends(get_db)): + from app.models.models import BotCategory + result = await db.execute(select(BotCategory).order_by(BotCategory.sort_order)) + cats = result.scalars().all() + return JSONResponse([ + {"id": c.id, "name": c.name, "description": c.description, "active": c.active, + "icon_emoji": c.icon_emoji, "sort_order": c.sort_order, "keywords": c.keywords} + for c in cats + ]) + + +@app.post("/api/bot/categories/create") +async def api_bot_categories_create(request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotCategory + body = await request.json() + db.add(BotCategory( + name=body.get("name", ""), description=body.get("description", ""), + active=body.get("active", True), icon_emoji=body.get("icon_emoji", "📋"), + sort_order=body.get("sort_order", 0), keywords=body.get("keywords", []), + )) + await db.commit() + SettingsCache._last_update = 0 + return JSONResponse({"ok": True}) + + +@app.post("/api/bot/categories/edit") +async def api_bot_categories_edit(request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotCategory + body = await request.json() + result = await db.execute(select(BotCategory).where(BotCategory.id == body.get("id"))) + cat = result.scalar_one_or_none() + if cat: + for f in ("name", "description", "active", "icon_emoji", "sort_order", "keywords"): + if f in body: + setattr(cat, f, body[f]) + await db.commit() + SettingsCache._last_update = 0 + return JSONResponse({"ok": True}) + + +@app.post("/api/bot/categories/delete") +async def api_bot_categories_delete(request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotCategory + body = await request.json() + result = await db.execute(select(BotCategory).where(BotCategory.id == body.get("id"))) + cat = result.scalar_one_or_none() + if cat: + await db.delete(cat) + await db.commit() + SettingsCache._last_update = 0 + return JSONResponse({"ok": True}) + + +@app.get("/api/bot/kb") +async def api_bot_kb(db: AsyncSession = Depends(get_db)): + from app.models.models import BotKnowledgeBase + result = await db.execute(select(BotKnowledgeBase).order_by(BotKnowledgeBase.sort_order)) + entries = result.scalars().all() + return JSONResponse([ + {"id": e.id, "question": e.question, "answer": e.answer, "category": e.category, + "active": e.active, "sort_order": e.sort_order, "keywords": e.keywords} + for e in entries + ]) + + +@app.post("/api/bot/kb/create") +async def api_bot_kb_create(request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotKnowledgeBase + body = await request.json() + db.add(BotKnowledgeBase( + question=body.get("question", ""), answer=body.get("answer", ""), + category=body.get("category", ""), active=body.get("active", True), + sort_order=body.get("sort_order", 0), keywords=body.get("keywords", []), + )) + await db.commit() + return JSONResponse({"ok": True}) + + +@app.post("/api/bot/kb/edit") +async def api_bot_kb_edit(request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotKnowledgeBase + body = await request.json() + result = await db.execute(select(BotKnowledgeBase).where(BotKnowledgeBase.id == body.get("id"))) + entry = result.scalar_one_or_none() + if entry: + for f in ("question", "answer", "category", "active", "sort_order", "keywords"): + if f in body: + setattr(entry, f, body[f]) + await db.commit() + return JSONResponse({"ok": True}) + + +@app.post("/api/bot/kb/delete") +async def api_bot_kb_delete(request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotKnowledgeBase + body = await request.json() + result = await db.execute(select(BotKnowledgeBase).where(BotKnowledgeBase.id == body.get("id"))) + entry = result.scalar_one_or_none() + if entry: + await db.delete(entry) + await db.commit() + return JSONResponse({"ok": True}) + + +@app.get("/api/bot/conversations") +async def api_bot_conversations(db: AsyncSession = Depends(get_db), + status: str = Query(None), + page: int = Query(1), + limit: int = Query(20)): + from app.models.models import BotConversation, BotUser + query = select(BotConversation, BotUser.first_name).join(BotUser) + if status: + query = query.where(BotConversation.status == status) + query = query.order_by(BotConversation.created_at.desc()).offset((page - 1) * limit).limit(limit) + result = await db.execute(query) + rows = result.all() + return JSONResponse([ + {"id": c.id, "user_name": fn, "status": c.status, "inquiry_text": c.inquiry_text[:100], + "created_at": c.created_at.isoformat() if c.created_at else "", "priority": c.priority} + for c, fn in rows + ]) + + +@app.get("/api/bot/users") +async def api_bot_users(db: AsyncSession = Depends(get_db), + page: int = Query(1), limit: int = Query(20), + sort_by: str = Query("created_at"), + sort_dir: str = Query("desc"), + consent: str = Query(None)): + from app.models.models import BotUser + allowed_sort = {"id", "max_user_id", "first_name", "last_name", "phone", "email", "consent_given", "created_at"} + if sort_by not in allowed_sort: + sort_by = "created_at" + sort_col = getattr(BotUser, sort_by) + order = sort_col.desc() if sort_dir == "desc" else sort_col.asc() + + filters = [] + if consent == "true": + filters.append(BotUser.consent_given == True) + elif consent == "false": + filters.append(BotUser.consent_given == False) + + count_q = select(func.count(BotUser.id)) + if filters: + count_q = count_q.where(and_(*filters)) + total = (await db.execute(count_q)).scalar() or 0 + + query = select(BotUser).where(*filters).order_by(order).offset((page - 1) * limit).limit(limit) + result = await db.execute(query) + users = result.scalars().all() + return JSONResponse({ + "total": total, + "users": [ + {"id": u.id, "max_user_id": u.max_user_id, "first_name": u.first_name, + "last_name": u.last_name, "phone": u.phone, "email": u.email, + "consent_given": u.consent_given, "created_at": u.created_at.isoformat() if u.created_at else ""} + for u in users + ], + }) + + +@app.get("/api/bot/analytics") +async def api_bot_analytics(db: AsyncSession = Depends(get_db), + days: int = Query(30)): + from app.models.models import BotConversation, BotUser + from datetime import timedelta + since = datetime.now() - timedelta(days=days) + + total_consented = await db.execute( + select(func.count(BotUser.id)).where(BotUser.consent_given == True)) + total_convs = await db.execute( + select(func.count(BotConversation.id)).where(BotConversation.created_at >= since)) + open_convs = await db.execute(select(func.count(BotConversation.id)).where( + BotConversation.status == "open", BotConversation.created_at >= since)) + closed_convs = await db.execute(select(func.count(BotConversation.id)).where( + BotConversation.status == "closed", BotConversation.created_at >= since)) + + return JSONResponse({ + "total_users": total_consented.scalar(), + "conversations_period": total_convs.scalar(), + "open_conversations": open_convs.scalar(), + "closed_conversations": closed_convs.scalar(), + "period_days": days, + }) + + +@app.post("/api/bot/test-run") +async def api_bot_test_run(request: Request, db: AsyncSession = Depends(get_db)): + body = await request.json() + scenario = body.get("scenario", "new_dialog") + messages = body.get("messages", []) + + settings = SettingsCache.get_all() + features = {k: v.enabled for k, v in SettingsCache.get_enabled_features().items()} + categories = [{"id": c.id, "name": c.name} for c in SettingsCache.get_categories()] + + test_api = MaxAPIClient("test_token", test_mode=True) + conversation_log = [] + + try: + from app.fsm import FSM, BotState + from app.conversation import get_or_create_user, add_message + await SettingsCache.refresh(db) + + mock_user_id = body.get("mock_user_id", 999999999) + user = await get_or_create_user(db, mock_user_id) + if not user: + user = await get_or_create_user(db, mock_user_id) + FSM.set_state(mock_user_id, BotState.IDLE) + + if scenario == "new_dialog": + update = { + "update_type": "bot_started", + "user": {"user_id": mock_user_id}, + } + await handle_update(None, update, db, test_api) + conversation_log = [{"role": "bot", "text": m["text"]} for m in test_api.test_messages] + test_api.test_messages.clear() + elif scenario == "out_of_hours": + update = { + "update_type": "bot_started", + "user": {"user_id": mock_user_id}, + } + await handle_update(None, update, db, test_api) + conversation_log = [{"role": "bot", "text": m["text"]} for m in test_api.test_messages] + test_api.test_messages.clear() + elif scenario == "returning": + conv = await get_or_create_user(db, mock_user_id) + user = await get_or_create_user(db, mock_user_id) + from app.conversation import create_conversation + from app.models.models import BotConversation + existing = await db.execute( + select(BotConversation).where(BotConversation.user_id == user.id).order_by(BotConversation.created_at.desc()) + ) + if not existing.scalar_one_or_none(): + await create_conversation(db, user.id, "open", "Test inquiry") + await db.commit() + update = { + "update_type": "bot_started", + "user": {"user_id": mock_user_id}, + } + await handle_update(None, update, db, test_api) + conversation_log = [{"role": "bot", "text": m["text"]} for m in test_api.test_messages] + test_api.test_messages.clear() + + for msg_text in messages: + update = { + "update_type": "message_created", + "user": {"user_id": mock_user_id}, + "message": { + "body": {"text": msg_text}, + "attachments": [], + }, + } + await handle_update(None, update, db, test_api) + conversation_log.append({"role": "user", "text": msg_text}) + for bot_msg in test_api.test_messages: + conversation_log.append({"role": "bot", "text": bot_msg["text"]}) + test_api.test_messages.clear() + + await db.rollback() + FSM.clear_all() + + except Exception as e: + logger.error(f"Test-run error: {e}", exc_info=True) + await db.rollback() + + return JSONResponse({ + "scenario": scenario, + "conversation": conversation_log, + "settings": settings, + "enabled_features": features, + "categories": categories, + }) + + +@app.get("/api/bot/broadcast/recipients") +async def api_bot_broadcast_recipients(db: AsyncSession = Depends(get_db), + q: str = Query(None), + page: int = Query(1), limit: int = Query(200)): + from app.models.models import BotUser + filters = [BotUser.consent_given == True] + if q: + like = f"%{q}%" + filters.append( + BotUser.first_name.ilike(like) | + BotUser.last_name.ilike(like) | + BotUser.phone.ilike(like) | + BotUser.email.ilike(like) + ) + count_q = select(func.count(BotUser.id)).where(and_(*filters)) + total = (await db.execute(count_q)).scalar() or 0 + query = select(BotUser).where(and_(*filters)).order_by(BotUser.first_name).offset((page - 1) * limit).limit(limit) + result = await db.execute(query) + users = result.scalars().all() + return JSONResponse({ + "total": total, + "users": [ + {"id": u.id, "max_user_id": u.max_user_id, "first_name": u.first_name, + "last_name": u.last_name, "phone": u.phone, "email": u.email, + "consent_given": u.consent_given, "created_at": u.created_at.isoformat() if u.created_at else ""} + for u in users + ], + }) + + +@app.post("/api/bot/broadcast") +async def api_bot_broadcast(request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotBroadcast, BotUser + body = await request.json() + text = body.get("text", "") + if not text: + return JSONResponse({"error": "Text is required"}, status_code=400) + + action = body.get("action", "send") + user_ids = body.get("user_ids", None) # None or [] = all consenting users + draft_id = body.get("draft_id") + + # Build recipient list: only consenting users + filters = [BotUser.consent_given == True] + if user_ids is not None and len(user_ids) > 0: + filters.append(BotUser.max_user_id.in_(user_ids)) + + result = await db.execute(select(BotUser.max_user_id, BotUser.first_name, BotUser.last_name).where(and_(*filters))) + recipients = result.all() + recipient_ids = [r[0] for r in recipients] + recipient_names = [f"{r[1]} {r[2]}".strip() or f"id{r[0]}" for r in recipients] + + if action == "preview": + # Create or reuse draft + if draft_id: + result = await db.execute(select(BotBroadcast).where(BotBroadcast.id == draft_id)) + broadcast = result.scalar_one_or_none() + if not broadcast: + return JSONResponse({"error": "Draft not found"}, status_code=404) + broadcast.text = text + broadcast.recipient_count = len(recipient_ids) + broadcast.recipient_ids = recipient_ids + else: + broadcast = BotBroadcast(text=text, recipient_count=len(recipient_ids), + recipient_ids=recipient_ids, status="draft") + db.add(broadcast) + await db.commit() + return JSONResponse({ + "draft_id": broadcast.id, + "text": text, + "recipient_count": len(recipient_ids), + "recipients": recipient_names, + }) + + # action == "send" + if draft_id: + result = await db.execute(select(BotBroadcast).where(BotBroadcast.id == draft_id)) + broadcast = result.scalar_one_or_none() + if broadcast: + broadcast.text = text + broadcast.recipient_count = len(recipient_ids) + broadcast.recipient_ids = recipient_ids + broadcast.status = "sent" + broadcast.sent_at = datetime.now() + else: + return JSONResponse({"error": "Draft not found"}, status_code=404) + else: + broadcast = BotBroadcast(text=text, recipient_count=len(recipient_ids), + recipient_ids=recipient_ids, status="sent", sent_at=datetime.now()) + db.add(broadcast) + await db.commit() + + sent_count = 0 + if max_api: + for uid in recipient_ids: + try: + await max_api.send_message(uid, text, format="markdown") + sent_count += 1 + except Exception: + pass + + return JSONResponse({"ok": True, "sent_to": sent_count, "total_recipients": len(recipient_ids)}) + + +@app.get("/api/bot/broadcast/history") +async def api_bot_broadcast_history(db: AsyncSession = Depends(get_db), + page: int = Query(1), limit: int = Query(20)): + from app.models.models import BotBroadcast + count_q = select(func.count(BotBroadcast.id)).where(BotBroadcast.status == "sent") + total = (await db.execute(count_q)).scalar() or 0 + query = select(BotBroadcast).where(BotBroadcast.status == "sent").order_by( + BotBroadcast.sent_at.desc()).offset((page - 1) * limit).limit(limit) + result = await db.execute(query) + broadcasts = result.scalars().all() + return JSONResponse({ + "total": total, + "broadcasts": [ + {"id": b.id, "text": b.text[:200], "recipient_count": b.recipient_count, + "sent_at": b.sent_at.isoformat() if b.sent_at else ""} + for b in broadcasts + ], + }) + + +# ===================== Risk Questions API ===================== + +@app.get("/api/bot/risk-questions") +async def api_bot_risk_questions(db: AsyncSession = Depends(get_db)): + from app.models.models import BotRiskQuestion + result = await db.execute( + select(BotRiskQuestion).order_by(BotRiskQuestion.sort_order) + ) + questions = result.scalars().all() + return JSONResponse([ + { + "id": q.id, "question_key": q.question_key, + "question": q.question, "weight": q.weight, + "sort_order": q.sort_order, "is_active": q.is_active, + } + for q in questions + ]) + + +@app.post("/api/bot/risk-questions/create") +async def api_bot_risk_questions_create(request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotRiskQuestion + body = await request.json() + q = BotRiskQuestion( + question_key=body["question_key"], + question=body["question"], + weight=int(body.get("weight", 10)), + sort_order=int(body.get("sort_order", 0)), + is_active=body.get("is_active", True), + ) + db.add(q) + await db.commit() + return JSONResponse({"ok": True, "id": q.id}) + + +@app.post("/api/bot/risk-questions/edit") +async def api_bot_risk_questions_edit(request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotRiskQuestion + body = await request.json() + qid = body.get("id") + if not qid: + return JSONResponse({"ok": False, "error": "id required"}, status_code=400) + q = await db.get(BotRiskQuestion, int(qid)) + if not q: + return JSONResponse({"ok": False, "error": "Not found"}, status_code=404) + if "question_key" in body: + q.question_key = body["question_key"] + if "question" in body: + q.question = body["question"] + if "weight" in body: + q.weight = int(body["weight"]) + if "sort_order" in body: + q.sort_order = int(body["sort_order"]) + if "is_active" in body: + q.is_active = body["is_active"] + await db.commit() + return JSONResponse({"ok": True}) + + +@app.post("/api/bot/risk-questions/toggle") +async def api_bot_risk_questions_toggle(request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotRiskQuestion + body = await request.json() + q = await db.get(BotRiskQuestion, int(body["id"])) + if not q: + return JSONResponse({"ok": False, "error": "Not found"}, status_code=404) + q.is_active = not q.is_active + await db.commit() + return JSONResponse({"ok": True}) + + +@app.post("/api/bot/risk-questions/delete") +async def api_bot_risk_questions_delete(request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotRiskQuestion + body = await request.json() + q = await db.get(BotRiskQuestion, int(body["id"])) + if not q: + return JSONResponse({"ok": False, "error": "Not found"}, status_code=404) + await db.delete(q) + await db.commit() + return JSONResponse({"ok": True}) + + +# ===================== Tickets API ===================== + +@app.get("/api/bot/tickets") +async def api_bot_tickets(db: AsyncSession = Depends(get_db), + status: str = Query(None), + priority: str = Query(None)): + from app.models.models import BotTicket, BotUser + query = select(BotTicket, BotUser.first_name, BotUser.last_name).join( + BotUser, BotTicket.user_id == BotUser.id, isouter=True + ) + if status: + query = query.where(BotTicket.status == status) + if priority: + query = query.where(BotTicket.priority == priority) + query = query.order_by(BotTicket.created_at.desc()) + result = await db.execute(query) + rows = result.all() + return JSONResponse([ + { + "id": t.id, "ticket_number": t.ticket_number, + "title": t.title, "description": t.description[:200], + "priority": t.priority, "status": t.status, + "user_name": f"{fn or ''} {ln or ''}".strip() if fn else None, + "object_name": None, + "created_at": t.created_at.isoformat() if t.created_at else "", + } + for t, fn, ln in rows + ]) + + +@app.get("/api/bot/tickets/{ticket_id}") +async def api_bot_ticket_get(ticket_id: int, db: AsyncSession = Depends(get_db)): + from app.models.models import BotTicket, BotUser, BotTicketMessage + t = await db.get(BotTicket, ticket_id) + if not t: + return JSONResponse({"error": "Not found"}, status_code=404) + user = await db.get(BotUser, t.user_id) if t.user_id else None + result = await db.execute( + select(BotTicketMessage).where(BotTicketMessage.ticket_id == ticket_id) + .order_by(BotTicketMessage.created_at) + ) + messages = result.scalars().all() + return JSONResponse({ + "id": t.id, "ticket_number": t.ticket_number, + "title": t.title, "description": t.description, + "priority": t.priority, "status": t.status, + "user_name": f"{user.first_name} {user.last_name}".strip() if user else None, + "object_name": None, + "created_at": t.created_at.isoformat() if t.created_at else "", + "messages": [ + {"id": m.id, "direction": m.direction, "text": m.text, + "sender": m.sender, + "timestamp": m.created_at.isoformat() if m.created_at else ""} + for m in messages + ], + }) + + +@app.post("/api/bot/tickets/create") +async def api_bot_tickets_create(request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotTicket, BotTicketMessage, BotTicketStatus + from sqlalchemy import func + body = await request.json() + count = (await db.execute(select(func.count(BotTicket.id)))).scalar() or 0 + ticket_num = f"T-{count + 1:04d}" + t = BotTicket( + ticket_number=ticket_num, + title=body.get("title", ""), + description=body.get("description", ""), + priority=body.get("priority", "normal"), + status="open", + created_by=body.get("created_by", "portal"), + ) + db.add(t) + await db.flush() + db.add(BotTicketMessage(ticket_id=t.id, direction="in", text=body.get("description", ""), sender="client")) + db.add(BotTicketStatus(ticket_id=t.id, old_status="", new_status="open", changed_by="portal")) + await db.commit() + return JSONResponse({"ok": True, "id": t.id, "ticket_number": ticket_num}) + + +@app.post("/api/bot/tickets/{ticket_id}/message") +async def api_bot_tickets_message(ticket_id: int, request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotTicketMessage + body = await request.json() + m = BotTicketMessage( + ticket_id=ticket_id, + direction=body.get("direction", "in"), + text=body.get("text", ""), + sender=body.get("sender", ""), + ) + db.add(m) + await db.commit() + return JSONResponse({"ok": True}) + + +@app.post("/api/bot/tickets/{ticket_id}/status") +async def api_bot_tickets_status(ticket_id: int, request: Request, db: AsyncSession = Depends(get_db)): + from app.models.models import BotTicket, BotTicketStatus + body = await request.json() + t = await db.get(BotTicket, ticket_id) + if not t: + return JSONResponse({"ok": False, "error": "Not found"}, status_code=404) + old = t.status + new_status = body.get("status", old) + db.add(BotTicketStatus(ticket_id=t.id, old_status=old, new_status=new_status, changed_by="portal")) + t.status = new_status + await db.commit() + return JSONResponse({"ok": True}) + + +@app.get("/api/bot/engineers") +async def api_bot_engineers(db: AsyncSession = Depends(get_db)): + from app.models.models import User + result = await db.execute( + select(User.id, User.full_name).where( + User.is_active == True, + User.role.in_(["engineer", "owner"]) + ).order_by(User.full_name) + ) + return JSONResponse([{"id": row[0], "full_name": row[1]} for row in result.fetchall()]) + + +@app.get("/api/bot/handoffs") +async def api_bot_handoffs(db: AsyncSession = Depends(get_db)): + from app.models.models import BotConversation, BotUser + query = ( + select(BotConversation, BotUser.first_name, BotUser.max_user_id) + .join(BotUser) + .where(BotConversation.status == "handoff") + .order_by(BotConversation.created_at.desc()) + ) + result = await db.execute(query) + rows = result.all() + return JSONResponse([ + { + "id": c.id, "user_name": fn, "max_user_id": muid, + "text": c.inquiry_text[:200] if c.inquiry_text else "", + "status": c.status, "priority": c.priority, + "created_at": c.created_at.isoformat() if c.created_at else "", + } + for c, fn, muid in rows + ]) + + +@app.post("/api/bot/handoffs/{handoff_id}/take") +async def api_bot_handoff_take(handoff_id: int, db: AsyncSession = Depends(get_db)): + from app.models.models import BotConversation + conv = await db.get(BotConversation, handoff_id) + if not conv or conv.status != "handoff": + return JSONResponse({"ok": False, "error": "Not found or not handoff"}, status_code=404) + conv.status = "open" + conv.priority = "normal" + await db.commit() + return JSONResponse({"ok": True}) + + +@app.post("/api/bot/handoffs/{handoff_id}/close") +async def api_bot_handoff_close(handoff_id: int, db: AsyncSession = Depends(get_db)): + from app.models.models import BotConversation + from datetime import datetime + conv = await db.get(BotConversation, handoff_id) + if not conv or conv.status != "handoff": + return JSONResponse({"ok": False, "error": "Not found or not handoff"}, status_code=404) + conv.status = "closed" + conv.closed_at = datetime.now() + await db.commit() + return JSONResponse({"ok": True}) + + +@app.get("/api/bot/notifications") +async def api_bot_notifications(db: AsyncSession = Depends(get_db), + page: int = Query(1), limit: int = Query(50)): + from app.models.models import BotNotification + count_q = select(func.count(BotNotification.id)) + total = (await db.execute(count_q)).scalar() or 0 + query = ( + select(BotNotification) + .order_by(BotNotification.created_at.desc()) + .offset((page - 1) * limit).limit(limit) + ) + result = await db.execute(query) + notifications = result.scalars().all() + return JSONResponse({ + "total": total, + "notifications": [ + { + "id": n.id, "type": n.type, "recipient_type": n.recipient_type, + "title": n.title, "body": n.body[:200], + "is_read": n.is_read, + "created_at": n.created_at.isoformat() if n.created_at else "", + } + for n in notifications + ], + }) + + +@app.post("/api/bot/notifications/{notif_id}/read") +async def api_bot_notification_read(notif_id: int, db: AsyncSession = Depends(get_db)): + from app.models.models import BotNotification + n = await db.get(BotNotification, notif_id) + if not n: + return JSONResponse({"ok": False, "error": "Not found"}, status_code=404) + n.is_read = True + await db.commit() + return JSONResponse({"ok": True}) diff --git a/max_bot/app/max_api.py b/max_bot/app/max_api.py new file mode 100644 index 0000000..b5b0486 --- /dev/null +++ b/max_bot/app/max_api.py @@ -0,0 +1,159 @@ +import os +import re +import hmac +import hashlib +import logging +from datetime import datetime +from typing import Optional + +import aiohttp + +logger = logging.getLogger(__name__) + +MAX_API_URL = "https://platform-api.max.ru" + + +class MaxAPIClient: + def __init__(self, token: str, test_mode: bool = False): + self.token = token + self.test_mode = test_mode + self._session: Optional[aiohttp.ClientSession] = None + self.test_messages: list = [] + + async def _get_session(self) -> aiohttp.ClientSession: + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession() + return self._session + + async def close(self): + if self._session and not self._session.closed: + await self._session.close() + + def _headers(self) -> dict: + return {"Authorization": self.token, "Content-Type": "application/json"} + + async def _request(self, method: str, path: str, **kwargs) -> dict: + if self.test_mode: + logger.info(f"[TEST_MODE] {method} {path} kwargs={kwargs}") + return {"ok": True, "test_mode": True} + session = await self._get_session() + url = f"{MAX_API_URL}{path}" + async with session.request(method, url, headers=self._headers(), **kwargs) as resp: + if resp.status >= 400: + body = await resp.text() + logger.error(f"Max API error {resp.status}: {body}") + resp.raise_for_status() + return await resp.json() + + async def get_me(self) -> dict: + return await self._request("GET", "/me") + + async def send_message(self, user_id: int = None, chat_id: int = None, text: str = "", + attachments: list = None, format: str = None, + disable_link_preview: bool = False, notify: bool = True) -> dict: + body = {"text": text} + if attachments: + body["attachments"] = attachments + if format: + body["format"] = format + if not disable_link_preview: + body["disable_link_preview"] = False + if not notify: + body["notify"] = False + + params = {} + if user_id: + params["user_id"] = user_id + if chat_id: + params["chat_id"] = chat_id + + if self.test_mode: + msg = {"role": "bot", "text": text, "format": format} + if attachments: + msg["attachments"] = attachments + self.test_messages.append(msg) + return await self._request("POST", "/messages", json=body, params=params) + + async def send_message_with_keyboard(self, user_id: int, text: str, buttons: list, + format: str = None) -> dict: + attachments = [{ + "type": "inline_keyboard", + "payload": {"buttons": buttons} + }] + return await self.send_message(user_id=user_id, text=text, attachments=attachments, format=format) + + async def edit_message(self, message_id: int, text: str, attachments: list = None) -> dict: + body = {"text": text} + if attachments: + body["attachments"] = attachments + return await self._request("PUT", f"/messages/{message_id}", json=body) + + async def get_message(self, message_id: int) -> dict: + return await self._request("GET", f"/messages/{message_id}") + + async def get_messages(self, user_id: int = None, chat_id: int = None, limit: int = 10) -> dict: + params = {"limit": limit} + if user_id: + params["user_id"] = user_id + if chat_id: + params["chat_id"] = chat_id + return await self._request("GET", "/messages", params=params) + + async def answer_callback(self, callback_id: str, text: str = "", show_alert: bool = False) -> dict: + body = {"callback_id": callback_id} + if text: + body["text"] = text + if show_alert: + body["show_alert"] = True + return await self._request("POST", "/answers", json=body) + + async def subscribe_webhook(self, url: str, update_types: list = None, secret: str = None) -> dict: + body = {"url": url} + if update_types: + body["update_types"] = update_types + if secret: + body["secret"] = secret + return await self._request("POST", "/subscriptions", json=body) + + async def get_subscriptions(self) -> dict: + return await self._request("GET", "/subscriptions") + + async def unsubscribe_webhook(self) -> dict: + return await self._request("DELETE", "/subscriptions") + + async def upload_file(self, file_path: str) -> dict: + session = await self._get_session() + url = f"{MAX_API_URL}/uploads" + with open(file_path, "rb") as f: + data = aiohttp.FormData() + data.add_field("file", f, filename=os.path.basename(file_path)) + async with session.post(url, headers={"Authorization": self.token}, data=data) as resp: + resp.raise_for_status() + return await resp.json() + + def verify_webhook_secret(self, header_value: str, expected_secret: str) -> bool: + if not expected_secret: + return True + return hmac.compare_digest(header_value, expected_secret) + + async def send_file(self, user_id: int, file_path: str, caption: str = "") -> dict: + upload = await self.upload_file(file_path) + file_id = upload.get("file_id", "") + body = {"text": caption, "attachments": [{"type": "file", "payload": {"file_id": file_id}}]} + return await self._request("POST", "/messages", json=body, params={"user_id": user_id}) + + async def get_file_url(self, file_id: str) -> str: + resp = await self._request("GET", f"/uploads/{file_id}") + return resp.get("url", "") + + async def send_contact_request(self, user_id: int, text: str = "Пожалуйста, отправьте ваш контакт") -> dict: + attachments = [{ + "type": "contact_request", + "payload": {"text": text} + }] + return await self.send_message(user_id=user_id, text=text, attachments=attachments) + + def verify_contact_hash(self, vcf_info: str, access_token: str, expected_hash: str) -> bool: + vcf_normalized = vcf_info.replace("\\r\\n", "\r\n") + computed = hmac.new(access_token.encode(), vcf_normalized.encode(), hashlib.sha256).hexdigest() + return hmac.compare_digest(computed, expected_hash) diff --git a/max_bot/app/max_notifications.py b/max_bot/app/max_notifications.py new file mode 100644 index 0000000..894d1c2 --- /dev/null +++ b/max_bot/app/max_notifications.py @@ -0,0 +1,148 @@ +from datetime import datetime +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.models import ( + BotNotification, BotNotificationSubscription, BotUser, +) +from app.settings_cache import SettingsCache + +try: + from app.models.models import User +except ImportError: + User = None + + +async def _get_max_user_id_by_role(db: AsyncSession, role: str) -> int | None: + if User is None: + return None + result = await db.execute( + select(User.max_user_id).where( + User.role == role, User.is_active == True, + User.max_user_id.isnot(None) + ).limit(1) + ) + return result.scalar_one_or_none() + + +async def _get_user_max_ids_by_role(db: AsyncSession, role: str) -> list[int]: + if User is None: + return [] + result = await db.execute( + select(User.max_user_id).where( + User.role == role, User.is_active == True, + User.max_user_id.isnot(None) + ) + ) + return [row[0] for row in result.fetchall() if row[0]] + + +async def _log_notification(db: AsyncSession, ntype: str, recipient_type: str, + recipient_max_user_id: int | None, title: str, body: str, + related_type: str = "", related_id: int | None = None): + n = BotNotification( + type=ntype, recipient_type=recipient_type, + recipient_max_user_id=recipient_max_user_id, + title=title, body=body, + related_type=related_type, related_id=related_id + ) + db.add(n) + await db.flush() + return n + + +async def notify_operator(db: AsyncSession, max_api, text: str, title: str = "", + related_type: str = "", related_id: int | None = None): + max_ids = await _get_user_max_ids_by_role(db, "owner") + sent_count = 0 + for mid in max_ids: + try: + await max_api.send_message(user_id=mid, text=text, format="markdown") + sent_count += 1 + except Exception: + pass + await _log_notification( + db, "operator_alert", "operator", max_ids[0] if max_ids else None, + title or "Уведомление оператору", text, + related_type, related_id + ) + return sent_count + + +async def notify_level2(db: AsyncSession, max_api, text: str, title: str = "", + related_type: str = "", related_id: int | None = None): + max_ids = await _get_user_max_ids_by_role(db, "engineer") + sent_count = 0 + for mid in max_ids: + try: + await max_api.send_message(user_id=mid, text=text, format="markdown") + sent_count += 1 + except Exception: + pass + await _log_notification( + db, "level2_alert", "level2", max_ids[0] if max_ids else None, + title or "Уведомление инженеру", text, + related_type, related_id + ) + return sent_count + + +async def notify_client_status_change(db: AsyncSession, max_api, + bot_user: BotUser, conv_id: int, + new_status: str): + text = f"Статус вашего обращения №{conv_id} изменён на «{new_status}»." + try: + await max_api.send_message(user_id=bot_user.max_user_id, text=text, format="markdown") + except Exception: + pass + await _log_notification( + db, "status_change", "client", bot_user.max_user_id, + "Статус обращения", text, "conversation", conv_id + ) + + +async def notify_ticket_created(db: AsyncSession, ticket, max_api): + ticket_text = ( + f"🆕 **Новый тикет {ticket.ticket_number}**\n" + f"Приоритет: {ticket.priority}\n" + f"Описание: {ticket.description[:200]}\n" + f"Обращение №{ticket.id}" + ) + await notify_level2( + db, max_api, ticket_text, + title=f"Тикет {ticket.ticket_number}", + related_type="ticket", related_id=ticket.id + ) + + +async def notify_handoff_escalation(db: AsyncSession, max_api, + bot_user: BotUser, conv_id: int, + text: str = ""): + msg = ( + f"🔴 **Эскалация оператору**\n" + f"Пользователь: {bot_user.first_name or '—'} (ID {bot_user.max_user_id})\n" + f"Обращение №{conv_id}\n" + f"Текст: {text[:200] if text else '—'}" + ) + await notify_operator( + db, max_api, msg, + title="Эскалация", + related_type="conversation", related_id=conv_id + ) + + +async def get_subscribed_users(db: AsyncSession, notify_type: str = "broadcast") -> list[BotUser]: + col_map = { + "broadcast": BotNotificationSubscription.notify_on_broadcast, + "status_change": BotNotificationSubscription.notify_on_status_change, + "reply": BotNotificationSubscription.notify_on_reply, + } + col = col_map.get(notify_type) + if col is None: + return [] + result = await db.execute( + select(BotUser).join( + BotNotificationSubscription, + BotNotificationSubscription.user_id == BotUser.id + ).where(col == True) + ) + return list(result.scalars().all()) diff --git a/max_bot/app/models/__init__.py b/max_bot/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/max_bot/app/models/__pycache__/__init__.cpython-314.pyc b/max_bot/app/models/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..e632cb3 Binary files /dev/null and b/max_bot/app/models/__pycache__/__init__.cpython-314.pyc differ diff --git a/max_bot/app/models/__pycache__/models.cpython-314.pyc b/max_bot/app/models/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000..3622427 Binary files /dev/null and b/max_bot/app/models/__pycache__/models.cpython-314.pyc differ diff --git a/max_bot/app/models/__pycache__/models.cpython-38.pyc b/max_bot/app/models/__pycache__/models.cpython-38.pyc new file mode 100644 index 0000000..176ba38 Binary files /dev/null and b/max_bot/app/models/__pycache__/models.cpython-38.pyc differ diff --git a/max_bot/app/models/models.py b/max_bot/app/models/models.py new file mode 100644 index 0000000..af39e90 --- /dev/null +++ b/max_bot/app/models/models.py @@ -0,0 +1,350 @@ +from datetime import datetime +from sqlalchemy import ( + Column, Integer, String, Boolean, Text, DateTime, BigInteger, SmallInteger, JSON, ForeignKey, CheckConstraint, text as sa_text +) +from sqlalchemy.orm import relationship, declarative_base + +Base = declarative_base() + + +class BotUser(Base): + __tablename__ = "bot_users" + + id = Column(Integer, primary_key=True, autoincrement=True) + max_user_id = Column(BigInteger, unique=True, nullable=False) + first_name = Column(String(255), default="") + last_name = Column(String(255), default="") + username = Column(String(255), default="") + phone = Column(String(50), default="") + email = Column(String(255), default="") + consent_given = Column(Boolean, nullable=False, default=False) + consent_timestamp = Column(DateTime, nullable=True) + consent_method = Column(String(50), default="") + created_at = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP")) + last_active_at = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP")) + + conversations = relationship("BotConversation", back_populates="user") + consent_logs = relationship("BotConsentLog", back_populates="user") + notification_subscriptions = relationship("BotNotificationSubscription", back_populates="user", uselist=False) + + +class BotConversation(Base): + __tablename__ = "bot_conversations" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("bot_users.id", ondelete="CASCADE"), nullable=False) + status = Column(String(20), nullable=False, default="open") + inquiry_type = Column(Integer, ForeignKey("bot_categories.id", ondelete="SET NULL"), nullable=True) + inquiry_text = Column(Text, default="") + priority = Column(String(20), default="normal") + created_at = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP")) + closed_at = Column(DateTime, nullable=True) + assigned_to = Column(String(255), default="") + has_attachment = Column(Boolean, default=False) + attachment_type = Column(String(50), default="") + attachment_path = Column(String(500), default="") + + __table_args__ = ( + CheckConstraint("status IN ('open','closed','handoff','1c_sent','awaiting_rating')", name="ck_bot_conv_status"), + CheckConstraint("priority IN ('low','normal','high','urgent')", name="ck_bot_conv_priority"), + ) + + user = relationship("BotUser", back_populates="conversations") + category = relationship("BotCategory", back_populates="conversations") + messages = relationship("BotMessage", back_populates="conversation", order_by="BotMessage.timestamp") + sync_log = relationship("Bot1CSyncLog", back_populates="conversation") + + +class BotMessage(Base): + __tablename__ = "bot_messages" + + id = Column(Integer, primary_key=True, autoincrement=True) + conversation_id = Column(Integer, ForeignKey("bot_conversations.id", ondelete="CASCADE"), nullable=False) + direction = Column(String(3), nullable=False) + text = Column(Text, default="") + has_attachment = Column(Boolean, default=False) + attachment_type = Column(String(50), default="") + attachment_path = Column(String(500), default="") + max_message_id = Column(BigInteger, nullable=True) + timestamp = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP")) + + __table_args__ = ( + CheckConstraint("direction IN ('in','out')", name="ck_bot_msg_direction"), + ) + + conversation = relationship("BotConversation", back_populates="messages") + + +class BotConsentLog(Base): + __tablename__ = "bot_consent_logs" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("bot_users.id", ondelete="CASCADE"), nullable=False) + action = Column(String(20), nullable=False) + timestamp = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP")) + method = Column(String(50), default="") + ip_address = Column(String(45), default="") + + __table_args__ = ( + CheckConstraint("action IN ('given','revoked')", name="ck_bot_consent_action"), + ) + + user = relationship("BotUser", back_populates="consent_logs") + + +class Bot1CSyncLog(Base): + __tablename__ = "bot_1c_sync_log" + + id = Column(Integer, primary_key=True, autoincrement=True) + conversation_id = Column(Integer, ForeignKey("bot_conversations.id", ondelete="CASCADE"), nullable=False) + status = Column(String(20), nullable=False, default="pending") + request_payload = Column(Text, default="") + response_payload = Column(Text, default="") + sent_at = Column(DateTime, nullable=True) + confirmed_at = Column(DateTime, nullable=True) + + __table_args__ = ( + CheckConstraint("status IN ('pending','sent','confirmed','error')", name="ck_bot_1c_status"), + ) + + conversation = relationship("BotConversation", back_populates="sync_log") + + +class BotKnowledgeBase(Base): + __tablename__ = "bot_knowledge_base" + + id = Column(Integer, primary_key=True, autoincrement=True) + question = Column(String(500), nullable=False) + answer = Column(Text, nullable=False) + category = Column(String(100), default="") + keywords = Column(JSON, default=list) + active = Column(Boolean, nullable=False, default=True) + sort_order = Column(Integer, default=0) + + +class BotCategory(Base): + __tablename__ = "bot_categories" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100), nullable=False) + description = Column(String(500), default="") + active = Column(Boolean, nullable=False, default=True) + sort_order = Column(Integer, default=0) + icon_emoji = Column(String(10), default="📋") + keywords = Column(JSON, default=list) + + conversations = relationship("BotConversation", back_populates="category") + + +class BotSetting(Base): + __tablename__ = "bot_settings" + + id = Column(Integer, primary_key=True, autoincrement=True) + key = Column(String(100), unique=True, nullable=False) + value = Column(Text, default="") + description = Column(String(500), default="") + + +class BotFeature(Base): + __tablename__ = "bot_features" + + id = Column(Integer, primary_key=True, autoincrement=True) + feature_key = Column(String(100), unique=True, nullable=False) + name = Column(String(100), nullable=False) + description = Column(String(500), default="") + enabled = Column(Boolean, nullable=False, default=False) + sort_order = Column(Integer, default=0) + + +class BotResponseTemplate(Base): + __tablename__ = "bot_response_templates" + + id = Column(Integer, primary_key=True, autoincrement=True) + template_key = Column(String(100), unique=True, nullable=False) + template_text = Column(Text, nullable=False) + description = Column(String(500), default="") + variables = Column(JSON, default=list) + + +class FileSyncQueue(Base): + __tablename__ = "file_sync_queue" + + id = Column(Integer, primary_key=True, autoincrement=True) + local_path = Column(String(500), nullable=False) + remote_path = Column(String(500), default="") + entity_type = Column(String(50), nullable=False) + entity_id = Column(Integer, nullable=False) + status = Column(String(20), nullable=False, default="pending") + error = Column(Text, default="") + created_at = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP")) + + __table_args__ = ( + CheckConstraint("status IN ('pending','uploading','synced','error')", name="ck_file_sync_status"), + ) + + +class BotBroadcast(Base): + __tablename__ = "bot_broadcasts" + + id = Column(Integer, primary_key=True, autoincrement=True) + text = Column(Text, nullable=False) + sent_at = Column(DateTime, nullable=True) + sent_by = Column(Integer, nullable=True) + recipient_count = Column(Integer, default=0) + recipient_ids = Column(JSON, default=list) + status = Column(String(20), default="draft") + + __table_args__ = ( + CheckConstraint("status IN ('draft','sent','failed')", name="ck_bot_broadcast_status"), + ) + + +class BotNotification(Base): + __tablename__ = "bot_notifications" + + id = Column(Integer, primary_key=True, autoincrement=True) + type = Column(String(50), nullable=False) + recipient_type = Column(String(20), nullable=False) + recipient_max_user_id = Column(BigInteger, nullable=True) + title = Column(String(255), default="") + body = Column(Text, default="") + related_type = Column(String(50), default="") + related_id = Column(Integer, nullable=True) + is_read = Column(Boolean, nullable=False, default=False) + created_at = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP")) + + __table_args__ = ( + CheckConstraint("recipient_type IN ('operator','level2','client')", name="ck_bot_notif_recipient"), + ) + + +class BotNotificationSubscription(Base): + __tablename__ = "bot_notification_subscriptions" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("bot_users.id", ondelete="CASCADE"), nullable=False) + notify_on_status_change = Column(Boolean, nullable=False, default=False) + notify_on_reply = Column(Boolean, nullable=False, default=False) + notify_on_broadcast = Column(Boolean, nullable=False, default=False) + + user = relationship("BotUser", back_populates="notification_subscriptions") + + +class Customer(Base): + __tablename__ = "customers" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(255), nullable=False) + contact_phone = Column(String(50), default="") + + +class Object(Base): + __tablename__ = "objects" + + id = Column(Integer, primary_key=True, autoincrement=True) + customer_id = Column(Integer, ForeignKey("customers.id", ondelete="SET NULL"), nullable=True) + name = Column(String(255), nullable=False) + status = Column(String(20), nullable=False, default="active") + risk_score = Column(Integer, default=0) + + +class SLAContract(Base): + __tablename__ = "sla_contracts" + + id = Column(Integer, primary_key=True, autoincrement=True) + object_id = Column(Integer, ForeignKey("objects.id", ondelete="CASCADE"), nullable=False) + status = Column(String(20), nullable=False, default="negotiation") + created_at = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP")) + service_level = Column(String(20), default="start") + sla_price_monthly = Column(Integer, default=0) + + +class Task(Base): + __tablename__ = "tasks" + + id = Column(Integer, primary_key=True, autoincrement=True) + object_id = Column(Integer, ForeignKey("objects.id", ondelete="CASCADE"), nullable=False) + status = Column(String(20), nullable=False, default="open") + + +class Incident(Base): + __tablename__ = "incidents" + + id = Column(Integer, primary_key=True, autoincrement=True) + object_id = Column(Integer, ForeignKey("objects.id", ondelete="CASCADE"), nullable=False) + status = Column(String(20), nullable=False, default="open") + created_at = Column(DateTime, nullable=False, server_default="NOW()") + title = Column(String(500), default="") + description = Column(Text, default="") + severity = Column(String(4), nullable=False, default="P3") + reported_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, autoincrement=True) + login = Column(String(50), default="") + full_name = Column(String(255), default="") + role = Column(String(20), nullable=False, default="technician") + is_active = Column(Boolean, nullable=False, default=True) + max_user_id = Column(BigInteger, nullable=True) + + +class BotRiskQuestion(Base): + __tablename__ = "bot_risk_questions" + + id = Column(Integer, primary_key=True, autoincrement=True) + question_key = Column(String(100), unique=True, nullable=False) + question = Column(String(500), nullable=False) + weight = Column(Integer, nullable=False, default=10) + sort_order = Column(Integer, default=0) + is_active = Column(Boolean, nullable=False, default=True) + + +class BotTicket(Base): + __tablename__ = "bot_tickets" + + id = Column(Integer, primary_key=True, autoincrement=True) + ticket_number = Column(String(20), unique=True, nullable=False) + user_id = Column(Integer, ForeignKey("bot_users.id", ondelete="SET NULL"), nullable=True) + object_id = Column(Integer, nullable=True) + customer_id = Column(Integer, nullable=True) + title = Column(String(255), nullable=False) + description = Column(Text, default="") + priority = Column(String(20), default="normal") + status = Column(String(20), nullable=False, default="open") + created_by = Column(String(20), default="bot") + sla_contract_id = Column(Integer, nullable=True) + has_attachment = Column(Boolean, default=False) + attachment_type = Column(String(50), default="") + attachment_path = Column(String(500), default="") + created_at = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP")) + updated_at = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP")) + + __table_args__ = ( + CheckConstraint("priority IN ('low','normal','high','urgent')", name="ck_bot_ticket_priority"), + CheckConstraint("status IN ('open','in_progress','resolved','closed')", name="ck_bot_ticket_status"), + ) + + +class BotTicketMessage(Base): + __tablename__ = "bot_ticket_messages" + + id = Column(Integer, primary_key=True, autoincrement=True) + ticket_id = Column(Integer, ForeignKey("bot_tickets.id", ondelete="CASCADE"), nullable=False) + direction = Column(String(10), nullable=False) + text = Column(Text, default="") + sender = Column(String(50), default="") + created_at = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP")) + + +class BotTicketStatus(Base): + __tablename__ = "bot_ticket_statuses" + + id = Column(Integer, primary_key=True, autoincrement=True) + ticket_id = Column(Integer, ForeignKey("bot_tickets.id", ondelete="CASCADE"), nullable=False) + old_status = Column(String(20), default="") + new_status = Column(String(20), nullable=False) + changed_by = Column(String(50), default="") + created_at = Column(DateTime, nullable=False, server_default=sa_text("CURRENT_TIMESTAMP")) diff --git a/max_bot/app/settings_cache.py b/max_bot/app/settings_cache.py new file mode 100644 index 0000000..f4587cb --- /dev/null +++ b/max_bot/app/settings_cache.py @@ -0,0 +1,129 @@ +import time +from typing import Optional +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.models import BotSetting, BotFeature, BotResponseTemplate, BotCategory + + +class SettingsCache: + _cache: dict = {} + _features: dict = {} + _templates: dict = {} + _categories: list = [] + _last_update: float = 0 + TTL = 300 # 5 minutes + + @classmethod + async def refresh(cls, db: AsyncSession): + now = time.time() + if now - cls._last_update < cls.TTL and cls._cache: + return + + result = await db.execute(select(BotSetting)) + cls._cache = {row.key: row.value for row in result.scalars().all()} + + result = await db.execute(select(BotFeature).where(BotFeature.enabled == True)) + cls._features = {row.feature_key: row for row in result.scalars().all()} + + result = await db.execute(select(BotFeature)) + all_features = {row.feature_key: row for row in result.scalars().all()} + + result = await db.execute(select(BotResponseTemplate)) + cls._templates = {row.template_key: row.template_text for row in result.scalars().all()} + + result = await db.execute( + select(BotCategory).where(BotCategory.active == True).order_by(BotCategory.sort_order) + ) + cls._categories = result.scalars().all() + + cls._last_update = now + + @classmethod + def get(cls, key: str, default: str = "") -> str: + return cls._cache.get(key, default) + + @classmethod + def get_int(cls, key: str, default: int = 0) -> int: + val = cls._cache.get(key, str(default)) + try: + return int(val) + except (ValueError, TypeError): + return default + + @classmethod + def get_list(cls, key: str, default: list = None) -> list: + val = cls._cache.get(key, "") + if not val: + return default or [] + return [x.strip() for x in val.split(",")] + + @classmethod + def get_all(cls) -> dict: + return dict(cls._cache) + + @classmethod + def is_feature_enabled(cls, feature_key: str) -> bool: + return feature_key in cls._features + + @classmethod + def get_enabled_features(cls) -> dict: + return dict(cls._features) + + @classmethod + def get_template(cls, key: str, default: str = "") -> str: + return cls._templates.get(key, default) + + @classmethod + def get_all_templates(cls) -> dict: + return dict(cls._templates) + + @classmethod + def get_categories(cls) -> list: + return list(cls._categories) + + @classmethod + def get_bot_token(cls) -> str: + return cls._cache.get("bot_token", "") + + @classmethod + def get_assistant_name(cls) -> str: + return cls._cache.get("assistant_name", "София") + + @classmethod + def is_work_hours(cls) -> bool: + import datetime + import pytz + tz_name = cls._cache.get("timezone", "Europe/Moscow") + try: + tz = pytz.timezone(tz_name) + except Exception: + tz = pytz.timezone("Europe/Moscow") + now = datetime.datetime.now(tz) + work_days = cls.get_list("work_days", ["1", "2", "3", "4", "5"]) + weekday = str(now.isoweekday()) + if weekday not in work_days: + return False + start = cls.get_int("work_hours_start", 9) + end = cls.get_int("work_hours_end", 18) + return start <= now.hour < end + + @classmethod + def get_work_hours_str(cls) -> str: + start = cls.get_int("work_hours_start", 9) + end = cls.get_int("work_hours_end", 18) + return f"{start}:00-{end}:00" + + @classmethod + def get_phones(cls) -> list: + phones = [] + p1 = cls._cache.get("phone_1", "") + p2 = cls._cache.get("phone_2", "") + if p1: + phones.append(p1) + if p2: + phones.append(p2) + return phones + + @classmethod + def get_email(cls) -> str: + return cls._cache.get("support_email", "") diff --git a/max_bot/app/text_renderer.py b/max_bot/app/text_renderer.py new file mode 100644 index 0000000..de2afdc --- /dev/null +++ b/max_bot/app/text_renderer.py @@ -0,0 +1,34 @@ +import re +from typing import Optional + + +def render_template(template: str, **kwargs) -> str: + result = template + for key, value in kwargs.items(): + result = result.replace("{" + key + "}", str(value)) + remaining = re.findall(r'\{(\w+)\}', result) + for var in remaining: + result = result.replace("{" + var + "}", "") + return result + + +def parse_link_text(text: str) -> str: + text = re.sub(r'\[([^\]]+)\]\(tel:[^\)]+\)', r'\1', text) + text = re.sub(r'\[([^\]]+)\]\(mailto:[^\)]+\)', r'\1', text) + text = re.sub(r'\[([^\]]+)\]\(https?://[^\)]+\)', r'\1', text) + return text + + +def make_clickable_links(text: str, phone_1: str = "", phone_2: str = "", + email: str = "", email_subject: str = "") -> str: + phone_1_raw = re.sub(r'[^\d+]', '', phone_1) + phone_2_raw = re.sub(r'[^\d+]', '', phone_2) + email_encoded = email.replace(' ', '%20') + subject_encoded = email_subject.replace(' ', '%20') + + text = text.replace("{phone_1_raw}", phone_1_raw) + text = text.replace("{phone_2_raw}", phone_2_raw) + text = text.replace("{email_encoded}", email_encoded) + text = text.replace("{email_subject_encoded}", subject_encoded) + + return text diff --git a/max_bot/app/utils/__init__.py b/max_bot/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/max_bot/app/utils/__pycache__/__init__.cpython-314.pyc b/max_bot/app/utils/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..943351a Binary files /dev/null and b/max_bot/app/utils/__pycache__/__init__.cpython-314.pyc differ diff --git a/max_bot/app/utils/__pycache__/emotion_detector.cpython-314.pyc b/max_bot/app/utils/__pycache__/emotion_detector.cpython-314.pyc new file mode 100644 index 0000000..d1f4b95 Binary files /dev/null and b/max_bot/app/utils/__pycache__/emotion_detector.cpython-314.pyc differ diff --git a/max_bot/app/utils/emotion_detector.py b/max_bot/app/utils/emotion_detector.py new file mode 100644 index 0000000..b85d0b8 --- /dev/null +++ b/max_bot/app/utils/emotion_detector.py @@ -0,0 +1,28 @@ +from app.settings_cache import SettingsCache +from app.integrations.yandex_gpt import analyze_emotion_with_gpt + +_NEGATIVE_WORDS = [ + "ужасно", "плохо", "долго", "кошмар", "отвратительно", "невыносимо", + "бесполезно", "халтура", "безобразие", "позор", "разочарован", "злюсь", + "раздражён", "недоволен", "жалоба", "претензия", "скандал", "суд", + "верните деньги", "мошенники", "обман", +] + + +async def detect_emotion(text: str) -> str: + gpt_key = SettingsCache.get("yandex_gpt_key") + if gpt_key: + try: + result = await analyze_emotion_with_gpt(text) + if result in ("positive", "neutral", "negative"): + return result + except Exception: + pass + return _keyword_fallback(text) + + +def _keyword_fallback(text: str) -> str: + text_lower = text.lower() + if any(word in text_lower for word in _NEGATIVE_WORDS): + return "negative" + return "neutral" diff --git a/max_bot/docker-compose.yml b/max_bot/docker-compose.yml new file mode 100644 index 0000000..f392e33 --- /dev/null +++ b/max_bot/docker-compose.yml @@ -0,0 +1,15 @@ +services: + max-bot: + build: + context: . + network: host + container_name: aegisone-max-bot + network_mode: host + environment: + DATABASE_URL: postgresql+asyncpg://aegisone:aegisone_pass@localhost:5432/aegisone + APP_ENV: production + LOG_LEVEL: info + WEBHOOK_SECRET: ${WEBHOOK_SECRET:-max-bot-secret-key} + volumes: + - ./uploads:/app/uploads + restart: unless-stopped diff --git a/max_bot/max_bot_deploy.sh b/max_bot/max_bot_deploy.sh new file mode 100644 index 0000000..82e8fa4 --- /dev/null +++ b/max_bot/max_bot_deploy.sh @@ -0,0 +1,106 @@ +#!/bin/bash +# ========================================== +# MAX Bot — Deploy Script +# ========================================== +# Usage: bash max_bot_deploy.sh +# Run on VPS as angel user (or from local with SSH) +set -e + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' +log() { echo -e "${CYAN}[$(date +%H:%M:%S)]${NC} $1"; } +ok() { echo -e " ${GREEN}✅ $1${NC}"; } +info() { echo -e " ${YELLOW}ℹ️ $1${NC}"; } +fail() { echo -e " ${RED}❌ $1${NC}"; exit 1; } + +cd "$(dirname "$0")" + +echo -e "${CYAN}" +echo "==========================================" +echo " MAX Bot — Deploy" +echo "==========================================" +echo -e "${NC}" + +BOT="/opt/projects/aegisone-py/max_bot" +PY="/opt/projects/aegisone-py" +NGX="/opt/projects/nginx-proxy" + +# ========================================== +# 1. Deploy max_bot files +# ========================================== +log "Deploying max_bot files..." +mkdir -p "$BOT" +rsync -a --delete ./ "$BOT/" 2>/dev/null || cp -r ./* "$BOT/" +ok "Files deployed to $BOT" + +# ========================================== +# 2. Deploy nginx config +# ========================================== +log "Deploying nginx config for max.aegisone.ru..." +cp max_bot/nginx/max-aegisone.conf "$NGX/conf.d/max-aegisone.conf" +docker compose -f "$NGX/docker-compose.yml" exec nginx nginx -s reload 2>/dev/null || true +ok "Nginx reloaded" + +# ========================================== +# 3. Run SQL migrations +# ========================================== +PG_CONTAINER=$(docker ps --filter "name=aegisone-postgres" --format "{{.ID}}" | head -1) +if [ -n "$PG_CONTAINER" ]; then + log "Running SQL migration v1.5.0..." + cat "$PY/sql/v1.5.0_migration.sql" 2>/dev/null | docker exec -i "$PG_CONTAINER" psql -U aegisone -d aegisone 2>&1 | grep -v "^INSERT\|^CREATE\|^ALTER\|^CREATE INDEX" || true + ok "SQL migration v1.5.0 applied" + + log "Running SQL migration v1.5.2..." + cat "$BOT/sql/migrations/v1.5.2_role_menu_permissions.sql" 2>/dev/null | docker exec -i "$PG_CONTAINER" psql -U aegisone -d aegisone 2>&1 | grep -v "^INSERT\|^CREATE\|^ALTER\|^CREATE INDEX" || true + ok "SQL migration v1.5.2 applied" + + log "Running SQL migration v1.5.5..." + cat "$BOT/sql/migrations/v1.5.5_tables_and_seed.sql" 2>/dev/null | docker exec -i "$PG_CONTAINER" psql -U aegisone -d aegisone 2>&1 | grep -v "^INSERT\|^CREATE\|^ALTER\|^CREATE INDEX" || true + ok "SQL migration v1.5.5 applied" +else + info "PostgreSQL container not found — run migration manually" +fi + +# ========================================== +# 4. Run SQL migration v1.6.0 via docker exec pipe +# ========================================== +PG_CONTAINER=$(docker ps --filter "name=aegisone-postgres" --format "{{.ID}}" | head -1) +if [ -n "$PG_CONTAINER" ]; then + log "Running SQL migration v1.6.0..." + cat "$BOT/sql/migrations/v1.6.0_fixes.sql" | docker exec -i "$PG_CONTAINER" psql -U aegisone -d aegisone 2>&1 | grep -v "^INSERT\|^UPDATE\|^DELETE\|^ALTER TABLE\|^CREATE INDEX\|^DROP VIEW" || true + ok "SQL migration v1.6.0 applied" +else + info "PostgreSQL container not found — run migration manually" +fi + +# ========================================== +# 5. Build & start max_bot +# ========================================== +log "Building and starting max_bot..." +cd "$BOT" && docker compose up -d --build +ok "max_bot started on port 8002" + +# ========================================== +# 6. Health check +# ========================================== +log "Checking health..." +sleep 3 +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8002/health 2>/dev/null || echo "000") +if [ "$HTTP_CODE" = "200" ]; then + ok "max_bot is healthy (HTTP $HTTP_CODE)" +else + info "Health check returned HTTP $HTTP_CODE — check logs: docker logs aegisone-max-bot" +fi + +# ========================================== +# Done +# ========================================== +echo "" +echo -e "${GREEN}==========================================" +echo " MAX Bot deploy complete!" +echo "==========================================${NC}" +echo "" +echo " Bot webhook: https://max.aegisone.ru/webhook" +echo " Bot API: https://max.aegisone.ru/api/" +echo " Health check: http://localhost:8002/health" +echo " Logs: docker logs -f aegisone-max-bot" +echo "" diff --git a/max_bot/nginx/max-aegisone.conf b/max_bot/nginx/max-aegisone.conf new file mode 100644 index 0000000..b9b019c --- /dev/null +++ b/max_bot/nginx/max-aegisone.conf @@ -0,0 +1,52 @@ +server { + listen 443 ssl; + http2 on; + server_name max.aegisone.ru; + + ssl_certificate /etc/nginx/certs/live/max.aegisone.ru/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/live/max.aegisone.ru/privkey.pem; + + client_max_body_size 50M; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location /webhook { + proxy_pass http://127.0.0.1:8002/webhook; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api/ { + proxy_pass http://127.0.0.1:8002/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /health { + proxy_pass http://127.0.0.1:8002/health; + proxy_set_header Host $host; + } + + location / { + return 404; + } +} + +server { + listen 80; + server_name max.aegisone.ru; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} diff --git a/max_bot/requirements.txt b/max_bot/requirements.txt new file mode 100644 index 0000000..55c6b31 --- /dev/null +++ b/max_bot/requirements.txt @@ -0,0 +1,12 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +sqlalchemy[asyncio]==2.0.36 +asyncpg==0.30.0 +aiohttp==3.11.11 +pydantic-settings==2.7.0 +python-multipart==0.0.18 +pytz==2024.2 +pytest==8.3.4 +pytest-asyncio==0.25.3 +httpx==0.28.1 +aiosqlite==0.20.0 diff --git a/max_bot/sql/bot_schema.sql b/max_bot/sql/bot_schema.sql new file mode 100644 index 0000000..7faa988 --- /dev/null +++ b/max_bot/sql/bot_schema.sql @@ -0,0 +1,205 @@ +-- Bot tables for AegisOne Engineering Max Bot "София" +-- Run against the shared aegisone database + +CREATE TABLE IF NOT EXISTS bot_users ( + id SERIAL PRIMARY KEY, + max_user_id BIGINT UNIQUE NOT NULL, + first_name VARCHAR(255) DEFAULT '', + last_name VARCHAR(255) DEFAULT '', + username VARCHAR(255) DEFAULT '', + phone VARCHAR(50) DEFAULT '', + email VARCHAR(255) DEFAULT '', + consent_given BOOLEAN NOT NULL DEFAULT FALSE, + consent_timestamp TIMESTAMP NULL, + consent_method VARCHAR(50) DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + last_active_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS bot_categories ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description VARCHAR(500) DEFAULT '', + active BOOLEAN NOT NULL DEFAULT TRUE, + sort_order INTEGER DEFAULT 0, + icon_emoji VARCHAR(10) DEFAULT '📋', + keywords JSONB DEFAULT '[]'::jsonb +); + +CREATE TABLE IF NOT EXISTS bot_conversations ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES bot_users(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL DEFAULT 'open' CHECK (status IN ('open','closed','handoff','1c_sent','awaiting_rating')), + inquiry_type INTEGER REFERENCES bot_categories(id) ON DELETE SET NULL, + inquiry_text TEXT DEFAULT '', + priority VARCHAR(20) DEFAULT 'normal' CHECK (priority IN ('low','normal','high','urgent')), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + closed_at TIMESTAMP NULL, + assigned_to VARCHAR(255) DEFAULT '', + has_attachment BOOLEAN DEFAULT FALSE, + attachment_type VARCHAR(50) DEFAULT '', + attachment_path VARCHAR(500) DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS bot_messages ( + id SERIAL PRIMARY KEY, + conversation_id INTEGER REFERENCES bot_conversations(id) ON DELETE CASCADE, + direction VARCHAR(3) NOT NULL CHECK (direction IN ('in','out')), + text TEXT DEFAULT '', + has_attachment BOOLEAN DEFAULT FALSE, + attachment_type VARCHAR(50) DEFAULT '', + attachment_path VARCHAR(500) DEFAULT '', + max_message_id BIGINT NULL, + timestamp TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS bot_consent_logs ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES bot_users(id) ON DELETE CASCADE, + action VARCHAR(20) NOT NULL CHECK (action IN ('given','revoked')), + timestamp TIMESTAMP NOT NULL DEFAULT NOW(), + method VARCHAR(50) DEFAULT '', + ip_address VARCHAR(45) DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS bot_1c_sync_log ( + id SERIAL PRIMARY KEY, + conversation_id INTEGER REFERENCES bot_conversations(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','sent','confirmed','error')), + request_payload TEXT DEFAULT '', + response_payload TEXT DEFAULT '', + sent_at TIMESTAMP NULL, + confirmed_at TIMESTAMP NULL +); + +CREATE TABLE IF NOT EXISTS bot_knowledge_base ( + id SERIAL PRIMARY KEY, + question VARCHAR(500) NOT NULL, + answer TEXT NOT NULL, + category VARCHAR(100) DEFAULT '', + keywords JSONB DEFAULT '[]'::jsonb, + active BOOLEAN NOT NULL DEFAULT TRUE, + sort_order INTEGER DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS bot_settings ( + id SERIAL PRIMARY KEY, + key VARCHAR(100) UNIQUE NOT NULL, + value TEXT DEFAULT '', + description VARCHAR(500) DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS bot_features ( + id SERIAL PRIMARY KEY, + feature_key VARCHAR(100) UNIQUE NOT NULL, + name VARCHAR(100) NOT NULL, + description VARCHAR(500) DEFAULT '', + enabled BOOLEAN NOT NULL DEFAULT FALSE, + sort_order INTEGER DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS bot_response_templates ( + id SERIAL PRIMARY KEY, + template_key VARCHAR(100) UNIQUE NOT NULL, + template_text TEXT NOT NULL, + description VARCHAR(500) DEFAULT '', + variables JSONB DEFAULT '[]'::jsonb +); + +CREATE TABLE IF NOT EXISTS bot_broadcasts ( + id SERIAL PRIMARY KEY, + text TEXT NOT NULL, + sent_at TIMESTAMP NULL, + sent_by INTEGER NULL, + recipient_count INTEGER DEFAULT 0, + status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft','sent','failed')) +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_bot_conv_user ON bot_conversations(user_id); +CREATE INDEX IF NOT EXISTS idx_bot_conv_status ON bot_conversations(status); +CREATE INDEX IF NOT EXISTS idx_bot_msg_conv ON bot_messages(conversation_id); +CREATE INDEX IF NOT EXISTS idx_bot_consent_user ON bot_consent_logs(user_id); +CREATE INDEX IF NOT EXISTS idx_bot_1c_conv ON bot_1c_sync_log(conversation_id); +CREATE INDEX IF NOT EXISTS idx_bot_1c_status ON bot_1c_sync_log(status); +CREATE INDEX IF NOT EXISTS idx_bot_kb_active ON bot_knowledge_base(active); +CREATE INDEX IF NOT EXISTS idx_bot_cat_active ON bot_categories(active); + +-- Initial settings +INSERT INTO bot_settings (key, value, description) VALUES +('bot_token', 'id2311381465_bot', 'Токен бота Max'), +('assistant_name', 'София', 'Имя виртуального помощника'), +('phone_1', '8 (861) 203-33-30', 'Телефон 1'), +('phone_2', '8 (995) 203-33-30', 'Телефон 2'), +('support_email', 'zakaz@aegisone.ru', 'Email поддержки'), +('email_subject', 'Обращение через Виртуального помощника AegisOne Engineering', 'Тема письма при отказе от согласия'), +('work_hours_start', '9', 'Начало рабочего времени (час)'), +('work_hours_end', '18', 'Конец рабочего времени (час)'), +('work_days', '1,2,3,4,5', 'Рабочие дни (1=Пн, 7=Вс)'), +('timezone', 'Europe/Moscow', 'Часовой пояс'), +('1c_webhook_url', '', 'URL webhook 1С:УНФ'), +('consent_policy_url', 'https://aegisone.ru/politica.php', 'Ссылка на политику ПД'), +('yandex_gpt_key', '', 'Ключ YandexGPT (пусто = ключевые слова)'), +('yandex_folder_id', '', 'Folder ID YandexGPT') +ON CONFLICT (key) DO NOTHING; + +-- Initial features +INSERT INTO bot_features (feature_key, name, description, enabled, sort_order) VALUES +('risk_score', 'Risk Score калькулятор', 'Расчёт оценки рисков объекта', false, 1), +('sla_status', 'Проверка SLA статуса', 'Проверка статуса SLA контракта по номеру объекта', false, 2), +('photo_report', 'Фото-отчёт', 'Отправка фото проблемы с созданием инцидента', false, 3), +('appointment', 'Запись на визит', 'Запись на визит инженера', false, 4), +('quality_rate', 'Оценка качества', 'Оценка качества после закрытия обращения', false, 5), +('commercial', 'Коммерческое предложение', 'Генерация КП по запросу', false, 6), +('reminders', 'Напоминания о ТО', 'Автоматические напоминания о плановом ТО', false, 7), +('my_objects', 'Мои объекты', 'Просмотр информации по объектам клиента', false, 8), +('notifications', 'Уведомления о статусе', 'Автоматические уведомления при изменении статуса', false, 9), +('broadcasts', 'Рассылки', 'Массовая рассылка сообщений клиентам', false, 10), +('sla_ticket', 'SLA заявки', 'Подача заявки о неисправности клиентом с активным SLA', false, 11) +ON CONFLICT (feature_key) DO NOTHING; + +-- Initial categories +INSERT INTO bot_categories (name, description, icon_emoji, keywords, sort_order) VALUES +('Видеонаблюдение', 'Камеры, видеорегистраторы, хранилища', '📹', '["камер","видео","наблюден","регистратор","хранен","запись"]', 1), +('СКУД', 'Контроль доступа, турникеты, замки', '🔐', '["скуд","доступ","турникет","замок","пропуск","карт"]', 2), +('Пожарная сигнализация', 'Датчики, оповещение, пожаротушение', '🔥', '["пожар","сигнализаци","датчик","оповещен","пожаротушен","дым"]', 3), +('IT-инфраструктура', 'Серверы, сеть, компьютеры', '💻', '["сервер","сет","компьютер","ит","инфраструктур","switch","роутер"]', 4), +('Обслуживание', 'Плановое ТО, ремонт, замена', '🔧', '["обслуживан","ремонт","замен","то","планов","срок"]', 5), +('Консультация', 'Вопросы, информация, расчёт стоимости', '💬', '["консультаци","вопрос","информац","стоимост","расчёт","цен"]', 6), +('Другое', 'Прочие обращения', '📋', '[]', 7) +ON CONFLICT DO NOTHING; + +-- Initial response templates +INSERT INTO bot_response_templates (template_key, template_text, description, variables) VALUES +('greeting', 'Здравствуйте! Я {name}, виртуальный помощник AegisOne Engineering. Чем могу помочь?', 'Приветствие нового пользователя', '["name"]'), +('greeting_returning', 'Здравствуйте, {first_name}! У вас открыто обращение #{conv_id}. Чем ещё могу помочь?', 'Приветствие возвращающегося пользователя', '["first_name","conv_id"]'), +('consent_request', 'Для обработки обращения нам нужны контактные данные. Даёте согласие на обработку персональных данных?\n\n[Подробнее о политике]({policy_url})', 'Запрос согласия ФЗ-152', '["policy_url"]'), +('consent_refused', 'Понимаем. Вы можете связаться с нами:\n\n📞 {phone_1}\n📞 {phone_2}\n✉️ {support_email}', 'Ответ при отказе от согласия', '["phone_1","phone_2","support_email"]'), +('name_request', 'Как к вам обращаться?', 'Запрос имени', '[]'), +('contact_request', 'Как с вами связаться?', 'Запрос контактных данных', '[]'), +('inquiry_request', 'Опишите ваш вопрос. Вы можете прикрепить фото или документ (PDF, DOCX, XLSX, TXT).', 'Запрос обращения', '[]'), +('out_of_hours', 'Спасибо за обращение! Сейчас мы не работаем.\n\nНаше рабочее время: Пн-Пт {work_hours_start}:00-{work_hours_end}:00 (МСК).\n\nНо вы можете оставить сообщение — мы ответим как можно быстрее.', 'Сообщение вне рабочего времени', '["work_hours_start","work_hours_end"]'), +('confirmation', 'Спасибо, {first_name}! Обращение #{conv_id} принято.\n\nОтветим как можно быстрее.', 'Подтверждение (рабочее время)', '["first_name","conv_id"]'), +('confirmation_ooh', 'Спасибо, {first_name}! Обращение #{conv_id} принято.\n\nСейчас нерабочее время, но мы ответим как можно быстрее.', 'Подтверждение (вне рабочего времени)', '["first_name","conv_id"]'), +('handoff', 'Передаю обращение оператору. Ожидайте.', 'Передача оператору', '[]'), +('quality_request', 'Обращение #{conv_id} выполнено. Оцените качество нашей работы:\n\n⭐ 1 — Плохо\n⭐⭐ 2\n⭐⭐⭐ 3\n⭐⭐⭐⭐ 4\n⭐⭐⭐⭐⭐ 5 — Отлично', 'Запрос оценки качества', '["conv_id"]'), +('sla_found', 'Объект: {object_name}\nSLA: {service_level}\nСтатус: {status}\nЦена/мес: {price} ₽', 'Результат проверки SLA', '["object_name","service_level","status","price"]'), +('sla_not_found', 'Объект не найден или SLA контракт не оформлен.', 'SLA не найден', '[]'), +('risk_result', 'Risk Score вашего объекта: {score}/100\nУровень: {label}\n\nРекомендуем: {recommendation}', 'Результат Risk Score', '["score","label","recommendation"]'), +('appointment_confirm', 'Запись на визит подтверждена.\n\nДата: {date}\nИнженер: {engineer}', 'Подтверждение записи на визит', '["date","engineer"]'), +('commercial_offer', 'Коммерческое предложение по вашему запросу:\n\n{offer_text}', 'Коммерческое предложение', '["offer_text"]'), +('reminder', 'Напоминаем: плановое ТО объекта {object_name} запланировано на {date}.', 'Напоминание о ТО', '["object_name","date"]'), +('status_update', 'Ваше обращение #{conv_id} — {status}.\nИнженер: {engineer}.', 'Уведомление о статусе', '["conv_id","status","engineer"]'), +('main_menu', 'Выберите действие:', 'Текст главного меню', '[]'), +('emotion_escalate', 'Я понимаю ваше беспокойство. Передаю обращение старшему специалисту. Ожидайте, пожалуйста.', 'Реакция на негативные эмоции', '[]'), +('auto_categorize_confirm', 'Я определил вашу тему как: {category}. Это верно?', 'Подтверждение авто-категоризации', '["category"]') +ON CONFLICT (template_key) DO NOTHING; + +-- Sample knowledge base entries +INSERT INTO bot_knowledge_base (question, answer, category, keywords, active, sort_order) VALUES +('Какие услуги вы оказываете?', 'Мы оказываем услуги по проектированию, монтажу и обслуживанию систем видеонаблюдения, СКУД, пожарной сигнализации и IT-инфраструктуры.', 'Общее', '["услуг","оказыва","монтаж","проект","обслуживан"]', true, 1), +('Как оставить заявку?', 'Нажмите кнопку «Оставить заявку» в главном меню и опишите ваш вопрос.', 'Общее', '["заявк","оставит","как"]', true, 2), +('Какие гарантии?', 'Мы предоставляем гарантию на все выполненные работы и установленное оборудование. Подробности уточняйте у менеджера.', 'Общее', '["гарант","условия"]', true, 3), +('Режим работы?', 'Наш офис работает Пн-Пт с 9:00 до 18:00 (МСК).', 'Общее', '["режим","работ","время","график"]', true, 4), +('Как связаться?', 'Вы можете написать нам здесь, позвонить по телефону 8 (861) 203-33-30 или написать на zakaz@aegisone.ru', 'Общее', '["связат","контакт","телефон","почт"]', true, 5) +ON CONFLICT DO NOTHING; diff --git a/max_bot/sql/migrations/v1.5.1_broadcast_recipient_ids.sql b/max_bot/sql/migrations/v1.5.1_broadcast_recipient_ids.sql new file mode 100644 index 0000000..b28ee53 --- /dev/null +++ b/max_bot/sql/migrations/v1.5.1_broadcast_recipient_ids.sql @@ -0,0 +1,2 @@ +-- Migration v1.5.1: Add recipient_ids column to bot_broadcasts +ALTER TABLE bot_broadcasts ADD COLUMN IF NOT EXISTS recipient_ids JSONB DEFAULT '[]'::jsonb; diff --git a/max_bot/sql/migrations/v1.5.2_role_menu_permissions.sql b/max_bot/sql/migrations/v1.5.2_role_menu_permissions.sql new file mode 100644 index 0000000..afb210a --- /dev/null +++ b/max_bot/sql/migrations/v1.5.2_role_menu_permissions.sql @@ -0,0 +1,26 @@ +-- Migration v1.5.2: Role-based menu permissions +CREATE TABLE IF NOT EXISTS role_menu_permissions ( + id SERIAL PRIMARY KEY, + role VARCHAR(20) NOT NULL, + menu_key VARCHAR(50) NOT NULL, + visible BOOLEAN NOT NULL DEFAULT TRUE, + UNIQUE(role, menu_key) +); + +-- Default: enable all menus for both roles +INSERT INTO role_menu_permissions (role, menu_key, visible) VALUES + ('engineer', 'tasks', true), + ('engineer', 'reports', true), + ('engineer', 'incidents', true), + ('engineer', 'questionnaire', true), + ('engineer', 'passports', true), + ('engineer', 'customers', true), + ('engineer', 'objects', true), + ('engineer', 'sla', true), + ('engineer', 'tech_access', true), + ('engineer', 'docs', true), + ('technician', 'tasks', true), + ('technician', 'reports', true), + ('technician', 'incidents', true), + ('technician', 'checklist', true) +ON CONFLICT (role, menu_key) DO NOTHING; diff --git a/max_bot/sql/migrations/v1.5.5_tables_and_seed.sql b/max_bot/sql/migrations/v1.5.5_tables_and_seed.sql new file mode 100644 index 0000000..6ef2abe --- /dev/null +++ b/max_bot/sql/migrations/v1.5.5_tables_and_seed.sql @@ -0,0 +1,121 @@ +-- Migration v1.5.5: New tables + seed data +-- Run: psql -U aegisone -d aegisone -f v1.5.5_tables_and_seed.sql + +BEGIN; + +-- ===================== New tables ===================== + +CREATE TABLE IF NOT EXISTS bot_notifications ( + id SERIAL PRIMARY KEY, + type VARCHAR(50) NOT NULL DEFAULT 'info', + recipient_type VARCHAR(20) NOT NULL DEFAULT 'operator', + recipient_max_user_id BIGINT NULL, + title VARCHAR(255) DEFAULT '', + body TEXT DEFAULT '', + related_type VARCHAR(50) DEFAULT '', + related_id INTEGER NULL, + is_read BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + CHECK (recipient_type IN ('operator','level2','client')) +); + +CREATE TABLE IF NOT EXISTS bot_notification_subscriptions ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES bot_users(id) ON DELETE CASCADE, + notify_on_status_change BOOLEAN NOT NULL DEFAULT FALSE, + notify_on_reply BOOLEAN NOT NULL DEFAULT FALSE, + notify_on_broadcast BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE(user_id) +); + +CREATE TABLE IF NOT EXISTS bot_risk_questions ( + id SERIAL PRIMARY KEY, + question_key VARCHAR(100) UNIQUE NOT NULL, + question VARCHAR(500) NOT NULL, + weight INTEGER NOT NULL DEFAULT 10, + sort_order INTEGER DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE +); + +CREATE TABLE IF NOT EXISTS bot_tickets ( + id SERIAL PRIMARY KEY, + ticket_number VARCHAR(20) UNIQUE NOT NULL, + user_id INTEGER REFERENCES bot_users(id) ON DELETE SET NULL, + object_id INTEGER NULL, + customer_id INTEGER NULL, + title VARCHAR(255) NOT NULL, + description TEXT DEFAULT '', + priority VARCHAR(20) DEFAULT 'normal', + status VARCHAR(20) NOT NULL DEFAULT 'open', + created_by VARCHAR(20) DEFAULT 'bot', + sla_contract_id INTEGER NULL, + has_attachment BOOLEAN DEFAULT FALSE, + attachment_type VARCHAR(50) DEFAULT '', + attachment_path VARCHAR(500) DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + CHECK (priority IN ('low','normal','high','urgent')), + CHECK (status IN ('open','in_progress','resolved','closed')) +); + +CREATE TABLE IF NOT EXISTS bot_ticket_messages ( + id SERIAL PRIMARY KEY, + ticket_id INTEGER REFERENCES bot_tickets(id) ON DELETE CASCADE NOT NULL, + direction VARCHAR(10) NOT NULL, + text TEXT DEFAULT '', + sender VARCHAR(50) DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS bot_ticket_statuses ( + id SERIAL PRIMARY KEY, + ticket_id INTEGER REFERENCES bot_tickets(id) ON DELETE CASCADE NOT NULL, + old_status VARCHAR(20) DEFAULT '', + new_status VARCHAR(20) NOT NULL, + changed_by VARCHAR(50) DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- ===================== Add recipient_ids to broadcasts if missing ===================== +ALTER TABLE bot_broadcasts ADD COLUMN IF NOT EXISTS recipient_ids JSONB DEFAULT '[]'::jsonb; + +-- ===================== Ensure new columns exist on existing tables ===================== +ALTER TABLE bot_notifications ADD COLUMN IF NOT EXISTS recipient_max_user_id BIGINT NULL; +ALTER TABLE bot_notifications ADD COLUMN IF NOT EXISTS related_type VARCHAR(50) DEFAULT ''; +ALTER TABLE bot_notifications ADD COLUMN IF NOT EXISTS related_id INTEGER NULL; + +ALTER TABLE bot_notification_subscriptions ADD COLUMN IF NOT EXISTS notify_on_status_change BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE bot_notification_subscriptions ADD COLUMN IF NOT EXISTS notify_on_reply BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE bot_notification_subscriptions ADD COLUMN IF NOT EXISTS notify_on_broadcast BOOLEAN NOT NULL DEFAULT FALSE; + +-- Drop old subscribe_types column if it exists (replaced by boolean columns) +ALTER TABLE bot_notification_subscriptions DROP COLUMN IF EXISTS subscribe_types; + +-- ===================== Seed: enable all features ===================== +UPDATE bot_features SET enabled = true WHERE enabled = false; + +-- ===================== Seed: risk_questions ===================== +INSERT INTO bot_risk_questions (question_key, question, weight, sort_order, is_active) VALUES + ('no_archive', '📋 Отсутствует архив документации?', 25, 1, true), + ('no_power_backup', '🔋 Нет резервного питания?', 20, 2, true), + ('no_regulations', '📜 Нет регламентов обслуживания?', 15, 3, true), + ('system_failures', '⚠️ Были отказы системы за последний год?', 20, 4, true), + ('no_documentation', '🗂️ Нет схем и паспортов объекта?', 10, 5, true), + ('outdated_equipment', '🕰️ Оборудование старше 5 лет?', 5, 6, true), + ('no_monitoring', '📡 Нет удалённого мониторинга?', 5, 7, true) +ON CONFLICT (question_key) DO NOTHING; + +-- ===================== Seed: default role permissions for new menu items ===================== +INSERT INTO role_menu_permissions (role, menu_key, visible) +SELECT * FROM (VALUES + ('engineer', 'bot_tickets', true), + ('engineer', 'bot_notifications', true), + ('engineer', 'bot_risk_questions', true) +) AS v(role, menu_key, visible) +WHERE NOT EXISTS ( + SELECT 1 FROM role_menu_permissions r + WHERE r.role = v.role AND r.menu_key = v.menu_key +); + +COMMIT; diff --git a/max_bot/sql/migrations/v1.6.0_fixes.sql b/max_bot/sql/migrations/v1.6.0_fixes.sql new file mode 100644 index 0000000..745bd51 --- /dev/null +++ b/max_bot/sql/migrations/v1.6.0_fixes.sql @@ -0,0 +1,38 @@ +-- Migration v1.6.0: Fixes for broadcast recipient_ids + feature keys + categories + engineer permissions + +-- 1. Add recipient_ids column to bot_broadcasts if missing +ALTER TABLE bot_broadcasts ADD COLUMN IF NOT EXISTS recipient_ids JSONB DEFAULT '[]'::jsonb; + +-- 2. Fix feature_key auto_notifications → notifications +UPDATE bot_features SET feature_key = 'notifications' WHERE feature_key = 'auto_notifications'; + +-- 3. Fix feature_key commercial_offer → commercial (match code) +UPDATE bot_features SET feature_key = 'commercial' WHERE feature_key = 'commercial_offer'; + +-- 4. Replace categories with new set +DELETE FROM bot_categories; +INSERT INTO bot_categories (name, description, icon_emoji, keywords, sort_order) VALUES +('Аудит безопасности', 'Независимая оценка систем видеонаблюдения, СКУД, ОПС', '🔍', '["аудит","безопасность","оценка","видеонаблюдение","скуд","опс"]', 1), +('Сервисное обслуживание / SLA', 'Абонентское обслуживание по SLA-контракту', '🛡️', '["сервис","sla","абонентский","обслуживание","контракт"]', 2), +('Реагирование на инциденты', 'Срочное реагирование при нештатных ситуациях', '🚨', '["инцидент","реагирование","срочный","нештатный","авария"]', 3), +('Технический надзор', 'Контроль качества при монтаже и пусконаладке', '📐', '["надзор","технический","контроль","качество","монтаж","пусконаладка"]', 4), +('Проектная документация', 'Разработка и согласование проектной документации', '📄', '["проект","документация","разработка","согласование"]', 5), +('Риск-инжиниринг', 'Оценка рисков, BCP, DRP, стратегия безопасности', '⚡', '["риск","инжиниринг","bcp","drp","стратегия","безопасность"]', 6), +('Общий вопрос', 'Консультация или вопрос вне указанных категорий', '💬', '["консультация","вопрос","общий","другое"]', 7) +ON CONFLICT DO NOTHING; + +-- 5. Add bot_* menu permissions for engineer role +INSERT INTO role_menu_permissions (role, menu_key, visible) VALUES + ('engineer', 'bot_analytics', true), + ('engineer', 'bot_users', true), + ('engineer', 'bot_conversations', true), + ('engineer', 'bot_tickets', true), + ('engineer', 'bot_handoffs', true), + ('engineer', 'bot_notifications', true), + ('engineer', 'bot_risk_questions', true), + ('engineer', 'bot_broadcasts', true), + ('engineer', 'bot_storage', true) +ON CONFLICT (role, menu_key) DO NOTHING; + +-- 6. Drop stale view if exists +DROP VIEW IF EXISTS bot_dashboard; diff --git a/max_bot/tests/__init__.py b/max_bot/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/max_bot/tests/__pycache__/__init__.cpython-314.pyc b/max_bot/tests/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..83e7681 Binary files /dev/null and b/max_bot/tests/__pycache__/__init__.cpython-314.pyc differ diff --git a/max_bot/tests/__pycache__/conftest.cpython-314-pytest-9.0.3.pyc b/max_bot/tests/__pycache__/conftest.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000..f9865d7 Binary files /dev/null and b/max_bot/tests/__pycache__/conftest.cpython-314-pytest-9.0.3.pyc differ diff --git a/max_bot/tests/__pycache__/test_fsm.cpython-314-pytest-9.0.3.pyc b/max_bot/tests/__pycache__/test_fsm.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000..820ed8d Binary files /dev/null and b/max_bot/tests/__pycache__/test_fsm.cpython-314-pytest-9.0.3.pyc differ diff --git a/max_bot/tests/__pycache__/test_handlers.cpython-314-pytest-9.0.3.pyc b/max_bot/tests/__pycache__/test_handlers.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000..50fbc8f Binary files /dev/null and b/max_bot/tests/__pycache__/test_handlers.cpython-314-pytest-9.0.3.pyc differ diff --git a/max_bot/tests/__pycache__/test_max_api.cpython-314-pytest-9.0.3.pyc b/max_bot/tests/__pycache__/test_max_api.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000..1ccd866 Binary files /dev/null and b/max_bot/tests/__pycache__/test_max_api.cpython-314-pytest-9.0.3.pyc differ diff --git a/max_bot/tests/__pycache__/test_webhook.cpython-314-pytest-9.0.3.pyc b/max_bot/tests/__pycache__/test_webhook.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000..76e3165 Binary files /dev/null and b/max_bot/tests/__pycache__/test_webhook.cpython-314-pytest-9.0.3.pyc differ diff --git a/max_bot/tests/conftest.py b/max_bot/tests/conftest.py new file mode 100644 index 0000000..280f53d --- /dev/null +++ b/max_bot/tests/conftest.py @@ -0,0 +1,78 @@ +import os +os.environ["WEBHOOK_SECRET"] = "test-secret" +os.environ["DATABASE_URL"] = "sqlite+aiosqlite://" +os.environ["APP_ENV"] = "test" + +import asyncio +from typing import AsyncGenerator +from unittest.mock import AsyncMock, MagicMock + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.config import Settings, get_settings +from app.database import get_db +from app.models.models import Base + +get_settings.cache_clear() +from app.main import app # noqa: E402 + + +@pytest.fixture(scope="session") +def event_loop(): + loop = asyncio.new_event_loop() + yield loop + loop.close() + + +@pytest_asyncio.fixture(scope="session") +async def test_engine(): + engine = create_async_engine("sqlite+aiosqlite://", echo=False) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield engine + await engine.dispose() + + +@pytest_asyncio.fixture +async def test_session(test_engine) -> AsyncGenerator[AsyncSession, None]: + TestSession = async_sessionmaker(test_engine, expire_on_commit=False) + async with TestSession() as session: + yield session + + +@pytest_asyncio.fixture +async def client(test_session: AsyncSession) -> AsyncGenerator: + async def override_get_db() -> AsyncGenerator[AsyncSession, None]: + yield test_session + + app.dependency_overrides[get_db] = override_get_db + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + app.dependency_overrides.clear() + + +@pytest.fixture +def mock_max_api(): + mock = AsyncMock() + mock.send_message = AsyncMock(return_value={"ok": True}) + mock.send_message_with_keyboard = AsyncMock(return_value={"ok": True}) + mock.send_file = AsyncMock(return_value={"ok": True}) + mock.get_file_url = AsyncMock(return_value="https://example.com/file") + mock.send_contact_request = AsyncMock(return_value={"ok": True}) + mock.answer_callback = AsyncMock(return_value={"ok": True}) + mock.get_me = AsyncMock(return_value={"ok": True, "bot": "test"}) + mock.subscribe_webhook = AsyncMock(return_value={"ok": True}) + mock.verify_webhook_secret = MagicMock(return_value=True) + mock.verify_contact_hash = MagicMock(return_value=True) + return mock + + +@pytest.fixture +def bot(): + return None diff --git a/max_bot/tests/test_fsm.py b/max_bot/tests/test_fsm.py new file mode 100644 index 0000000..f9a6656 --- /dev/null +++ b/max_bot/tests/test_fsm.py @@ -0,0 +1,168 @@ +from app.fsm import BotState, FSM, STATE_TRANSITIONS + + +class TestFSM: + def test_initial_state(self): + FSM.clear_all() + assert FSM.get_state(1) == BotState.IDLE + + def test_set_and_get_state(self): + FSM.clear_all() + FSM.set_state(1, BotState.NAME_REQUEST) + assert FSM.get_state(1) == BotState.NAME_REQUEST + + def test_reset(self): + FSM.clear_all() + FSM.set_state(1, BotState.NAME_REQUEST) + FSM.reset(1) + assert FSM.get_state(1) == BotState.IDLE + + def test_valid_transition(self): + FSM.clear_all() + result = FSM.transition(1, "start") + assert result == BotState.NAME_REQUEST + + def test_invalid_transition_returns_same(self): + FSM.clear_all() + # From IDLE, "unknown" should keep us at IDLE + result = FSM.transition(1, "unknown_event") + assert result == BotState.IDLE + + def test_can_transition_true(self): + FSM.clear_all() + assert FSM.can_transition(1, "start") is True + + def test_can_transition_false(self): + FSM.clear_all() + assert FSM.can_transition(1, "unknown_event") is False + + def test_full_greeting_flow(self): + FSM.clear_all() + assert FSM.transition(1, "start") == BotState.NAME_REQUEST + assert FSM.transition(1, "name_provided") == BotState.CONSENT_REQUEST + assert FSM.transition(1, "consent_given") == BotState.CONTACT_REQUEST + assert FSM.transition(1, "contact_provided") == BotState.INQUIRY_REQUEST + assert FSM.transition(1, "inquiry_provided") == BotState.CATEGORY_SELECT + assert FSM.transition(1, "category_selected") == BotState.CONFIRMATION + assert FSM.transition(1, "done") == BotState.IDLE + + def test_consent_refused_flow(self): + FSM.clear_all() + FSM.set_state(1, BotState.CONSENT_REQUEST) + assert FSM.transition(1, "consent_refused") == BotState.IDLE + + def test_handoff_flow(self): + FSM.clear_all() + FSM.set_state(1, BotState.HANDOFF_REQUEST) + assert FSM.transition(1, "confirmed") == BotState.HANDOFF_CONFIRMATION + assert FSM.transition(1, "done") == BotState.IDLE + + def test_handoff_cancel_to_menu(self): + FSM.clear_all() + FSM.set_state(1, BotState.HANDOFF_REQUEST) + assert FSM.transition(1, "cancel") == BotState.MAIN_MENU + + def test_main_menu_new_inquiry(self): + FSM.clear_all() + FSM.set_state(1, BotState.MAIN_MENU) + assert FSM.transition(1, "new_inquiry") == BotState.NAME_REQUEST + + def test_main_menu_features(self): + FSM.clear_all() + FSM.set_state(1, BotState.MAIN_MENU) + assert FSM.transition(1, "feature_risk_score") == BotState.FEATURE_RISK_SCORE + FSM.set_state(1, BotState.MAIN_MENU) + assert FSM.transition(1, "feature_sla_status") == BotState.FEATURE_SLA_STATUS + FSM.set_state(1, BotState.MAIN_MENU) + assert FSM.transition(1, "feature_photo_report") == BotState.FEATURE_PHOTO_REPORT + FSM.set_state(1, BotState.MAIN_MENU) + assert FSM.transition(1, "feature_appointment") == BotState.FEATURE_APPOINTMENT + FSM.set_state(1, BotState.MAIN_MENU) + assert FSM.transition(1, "feature_quality_rate") == BotState.FEATURE_QUALITY_RATE + FSM.set_state(1, BotState.MAIN_MENU) + assert FSM.transition(1, "feature_commercial") == BotState.FEATURE_COMMERCIAL + FSM.set_state(1, BotState.MAIN_MENU) + assert FSM.transition(1, "feature_my_objects") == BotState.FEATURE_MY_OBJECTS + FSM.set_state(1, BotState.MAIN_MENU) + assert FSM.transition(1, "sla_ticket_create") == BotState.SLA_TICKET_CREATE + + def test_feature_done_returns_to_menu(self): + FSM.clear_all() + FSM.set_state(1, BotState.FEATURE_RISK_SCORE) + assert FSM.transition(1, "done") == BotState.MAIN_MENU + FSM.set_state(1, BotState.FEATURE_RISK_SCORE) + assert FSM.transition(1, "cancel") == BotState.MAIN_MENU + + def test_out_of_hours_flow(self): + FSM.clear_all() + FSM.set_state(1, BotState.OUT_OF_HOURS) + assert FSM.transition(1, "leave_message") == BotState.INQUIRY_REQUEST + FSM.set_state(1, BotState.OUT_OF_HOURS) + assert FSM.transition(1, "callback") == BotState.NAME_REQUEST + FSM.set_state(1, BotState.OUT_OF_HOURS) + assert FSM.transition(1, "cancel") == BotState.IDLE + FSM.set_state(1, BotState.OUT_OF_HOURS) + assert FSM.transition(1, "main_menu") == BotState.MAIN_MENU + + def test_category_select_flow(self): + FSM.clear_all() + FSM.set_state(1, BotState.CATEGORY_SELECT) + assert FSM.transition(1, "category_selected") == BotState.CONFIRMATION + FSM.set_state(1, BotState.CATEGORY_SELECT) + assert FSM.transition(1, "cancel") == BotState.IDLE + + def test_kb_search_found(self): + FSM.clear_all() + FSM.set_state(1, BotState.KB_SEARCH) + assert FSM.transition(1, "found") == BotState.MAIN_MENU + FSM.set_state(1, BotState.KB_SEARCH) + assert FSM.transition(1, "not_found") == BotState.INQUIRY_REQUEST + + def test_waiting_1c_response(self): + FSM.clear_all() + FSM.set_state(1, BotState.WAITING_1C_RESPONSE) + assert FSM.transition(1, "received") == BotState.MAIN_MENU + FSM.set_state(1, BotState.WAITING_1C_RESPONSE) + assert FSM.transition(1, "timeout") == BotState.IDLE + + def test_rating_flow(self): + FSM.clear_all() + FSM.set_state(1, BotState.AWAITING_RATING) + assert FSM.transition(1, "rating_provided") == BotState.IDLE + + def test_new_user_start_via_callback(self): + FSM.clear_all() + # Simulate callback: from MAIN_MENU, cancel goes to IDLE + FSM.set_state(1, BotState.MAIN_MENU) + assert FSM.transition(1, "cancel") == BotState.IDLE + + def test_idle_handoff_event(self): + FSM.clear_all() + assert FSM.transition(1, "handoff") == BotState.HANDOFF_REQUEST + + def test_edit_from_confirmation(self): + FSM.clear_all() + FSM.set_state(1, BotState.CONFIRMATION) + assert FSM.transition(1, "edit_name") == BotState.NAME_REQUEST + FSM.set_state(1, BotState.CONFIRMATION) + assert FSM.transition(1, "edit_contact") == BotState.CONTACT_REQUEST + FSM.set_state(1, BotState.CONFIRMATION) + assert FSM.transition(1, "edit_inquiry") == BotState.INQUIRY_REQUEST + + def test_clear_all(self): + FSM.clear_all() + FSM.set_state(1, BotState.NAME_REQUEST) + FSM.set_state(2, BotState.CONSENT_REQUEST) + FSM.clear_all() + assert FSM.get_state(1) == BotState.IDLE + assert FSM.get_state(2) == BotState.IDLE + + def test_all_states_have_transitions(self): + for state in BotState: + assert state in STATE_TRANSITIONS, f"{state} missing from STATE_TRANSITIONS" + + def test_all_transitions_go_to_valid_states(self): + for state, events in STATE_TRANSITIONS.items(): + for event, target in events.items(): + assert target in BotState.__members__.values(), \ + f"{state}.{event} -> {target} is not a valid BotState" diff --git a/max_bot/tests/test_handlers.py b/max_bot/tests/test_handlers.py new file mode 100644 index 0000000..5a59f16 --- /dev/null +++ b/max_bot/tests/test_handlers.py @@ -0,0 +1,179 @@ +import pytest +from unittest.mock import AsyncMock + +from app.fsm import BotState, FSM +from app.handlers.greeting import handle_start +from app.handlers.consent import handle_consent_request, handle_consent_given, handle_consent_refused +from app.handlers.main_menu import handle_main_menu +from app.settings_cache import SettingsCache + + +@pytest.mark.asyncio +async def test_handle_start_new_user(test_session, mock_max_api, bot): + FSM.clear_all() + user_data = {"user_id": 12345, "first_name": "Иван", "last_name": "Петров"} + + await SettingsCache.refresh(test_session) + await handle_start(bot, user_data, test_session, mock_max_api) + + mock_max_api.send_message_with_keyboard.assert_called_once() + call_args = mock_max_api.send_message_with_keyboard.call_args + assert call_args[0][0] == 12345 + assert FSM.get_state(12345) == BotState.IDLE + + +@pytest.mark.asyncio +async def test_handle_start_returning_user(test_session, mock_max_api, bot): + from app.conversation import get_or_create_user, create_conversation + FSM.clear_all() + user_data = {"user_id": 67890, "first_name": "Пётр"} + + user = await get_or_create_user(test_session, 67890, "Пётр") + await test_session.flush() + await create_conversation(test_session, user.id) + await test_session.flush() + + await SettingsCache.refresh(test_session) + await handle_start(bot, user_data, test_session, mock_max_api) + + mock_max_api.send_message_with_keyboard.assert_called_once() + assert FSM.get_state(67890) == BotState.IDLE + + +@pytest.mark.asyncio +async def test_handle_consent_request(test_session, mock_max_api, bot): + FSM.clear_all() + user_data = {"user_id": 111, "first_name": "Анна"} + + await SettingsCache.refresh(test_session) + await handle_consent_request(bot, user_data, test_session, mock_max_api) + + mock_max_api.send_message_with_keyboard.assert_called_once() + assert FSM.get_state(111) == BotState.CONSENT_REQUEST + + +@pytest.mark.asyncio +async def test_handle_consent_given(test_session, mock_max_api, bot): + from app.conversation import get_or_create_user + FSM.clear_all() + user_data = {"user_id": 222} + + user = await get_or_create_user(test_session, 222) + await test_session.flush() + + await handle_consent_given(bot, user_data, test_session, mock_max_api) + + assert user.consent_given is True + mock_max_api.send_message_with_keyboard.assert_called_once() + assert FSM.get_state(222) == BotState.CONTACT_REQUEST + + +@pytest.mark.asyncio +async def test_handle_consent_refused(test_session, mock_max_api, bot): + from app.conversation import get_or_create_user + FSM.clear_all() + user_data = {"user_id": 333} + + user = await get_or_create_user(test_session, 333) + await test_session.flush() + + await SettingsCache.refresh(test_session) + await handle_consent_refused(bot, user_data, test_session, mock_max_api) + + mock_max_api.send_message_with_keyboard.assert_called_once() + assert FSM.get_state(333) == BotState.IDLE + + +@pytest.mark.asyncio +async def test_handle_main_menu_new_inquiry_without_consent(test_session, mock_max_api, bot): + FSM.clear_all() + user_data = {"user_id": 444, "payload": "new_inquiry"} + + await SettingsCache.refresh(test_session) + await handle_main_menu(bot, user_data, test_session, mock_max_api) + + # Should go to consent request + mock_max_api.send_message_with_keyboard.assert_called_once() + + +@pytest.mark.asyncio +async def test_handle_main_menu_cancel(test_session, mock_max_api, bot): + FSM.clear_all() + user_data = {"user_id": 555, "payload": "cancel"} + + await SettingsCache.refresh(test_session) + await handle_main_menu(bot, user_data, test_session, mock_max_api) + + mock_max_api.send_message_with_keyboard.assert_called_once() + assert FSM.get_state(555) == BotState.IDLE + + +@pytest.mark.asyncio +async def test_handle_main_menu_unknown_payload(test_session, mock_max_api, bot): + FSM.clear_all() + user_data = {"user_id": 666, "payload": "unknown_command"} + + await SettingsCache.refresh(test_session) + await handle_main_menu(bot, user_data, test_session, mock_max_api) + + mock_max_api.send_message_with_keyboard.assert_called_once() + assert FSM.get_state(666) == BotState.IDLE + + +@pytest.mark.asyncio +async def test_handle_main_menu_handoff(test_session, mock_max_api, bot): + from app.conversation import get_or_create_user + from app.handlers.handoff import handle_handoff_request + FSM.clear_all() + user_data = {"user_id": 668, "payload": "handoff"} + + await get_or_create_user(test_session, 668, "Тест") + await test_session.flush() + + await SettingsCache.refresh(test_session) + # Call directly (goes to out_of_hours if after hours, or handoff template) + await handle_handoff_request(bot, user_data, test_session, mock_max_api) + + # Either send_message (handoff) or send_message_with_keyboard (out_of_hours) + assert mock_max_api.send_message.called or mock_max_api.send_message_with_keyboard.called + + +@pytest.mark.asyncio +async def test_handle_out_of_hours(test_session, mock_max_api, bot): + from app.handlers.out_of_hours import handle_out_of_hours + FSM.clear_all() + user_data = {"user_id": 777} + + await SettingsCache.refresh(test_session) + await handle_out_of_hours(bot, user_data, test_session, mock_max_api) + + mock_max_api.send_message_with_keyboard.assert_called_once() + assert FSM.get_state(777) == BotState.OUT_OF_HOURS + + +@pytest.mark.asyncio +async def test_handle_out_of_hours_leave_message(test_session, mock_max_api, bot): + from app.handlers.out_of_hours import handle_out_of_hours_callback + FSM.clear_all() + user_data = {"user_id": 888, "text": "тестовое обращение"} + FSM.set_state(888, BotState.OUT_OF_HOURS) + + await SettingsCache.refresh(test_session) + await handle_out_of_hours_callback(bot, user_data, test_session, mock_max_api, "leave_message") + + # handle_inquiry completes the flow → ends at CONFIRMATION + assert FSM.get_state(888) == BotState.CONFIRMATION + + +@pytest.mark.asyncio +async def test_handle_out_of_hours_callback_request(test_session, mock_max_api, bot): + from app.handlers.out_of_hours import handle_out_of_hours_callback + FSM.clear_all() + user_data = {"user_id": 999} + FSM.set_state(999, BotState.OUT_OF_HOURS) + + await SettingsCache.refresh(test_session) + await handle_out_of_hours_callback(bot, user_data, test_session, mock_max_api, "callback") + + mock_max_api.send_message.assert_called_once() + assert FSM.get_state(999) == BotState.NAME_REQUEST diff --git a/max_bot/tests/test_max_api.py b/max_bot/tests/test_max_api.py new file mode 100644 index 0000000..42b1e34 --- /dev/null +++ b/max_bot/tests/test_max_api.py @@ -0,0 +1,134 @@ +from unittest.mock import AsyncMock, patch, MagicMock + +import pytest + +from app.max_api import MaxAPIClient + + +@pytest.fixture +def api(): + return MaxAPIClient(token="test-token") + + +@pytest.mark.asyncio +async def test_send_message(api): + api._request = AsyncMock(return_value={"ok": True}) + result = await api.send_message(user_id=123, text="Hello") + assert result == {"ok": True} + api._request.assert_called_once() + + +@pytest.mark.asyncio +async def test_send_message_with_chat_id(api): + api._request = AsyncMock(return_value={"ok": True}) + result = await api.send_message(chat_id=456, text="Hello") + assert result == {"ok": True} + + +@pytest.mark.asyncio +async def test_send_message_with_keyboard(api): + api.send_message = AsyncMock(return_value={"ok": True}) + buttons = [[{"type": "message", "text": "Test", "payload": "test"}]] + result = await api.send_message_with_keyboard(user_id=123, text="Menu", buttons=buttons) + assert result == {"ok": True} + api.send_message.assert_called_once() + _, kwargs = api.send_message.call_args + assert kwargs["user_id"] == 123 + assert kwargs["text"] == "Menu" + assert len(kwargs["attachments"]) == 1 + + +@pytest.mark.asyncio +async def test_get_me(api): + api._request = AsyncMock(return_value={"ok": True, "bot": "test"}) + result = await api.get_me() + assert result["bot"] == "test" + + +@pytest.mark.asyncio +async def test_subscribe_webhook(api): + api._request = AsyncMock(return_value={"ok": True}) + result = await api.subscribe_webhook(url="https://example.com/webhook", secret="secret123") + assert result == {"ok": True} + + +@pytest.mark.asyncio +async def test_answer_callback(api): + api._request = AsyncMock(return_value={"ok": True}) + result = await api.answer_callback(callback_id="cb_123", text="Thanks") + assert result == {"ok": True} + + +@pytest.mark.asyncio +async def test_send_file(api): + api.upload_file = AsyncMock(return_value={"file_id": "file_123"}) + api._request = AsyncMock(return_value={"ok": True}) + result = await api.send_file(user_id=123, file_path="/tmp/test.pdf", caption="Here") + assert result == {"ok": True} + + +@pytest.mark.asyncio +async def test_get_file_url(api): + api._request = AsyncMock(return_value={"url": "https://example.com/file.pdf"}) + result = await api.get_file_url("file_123") + assert result == "https://example.com/file.pdf" + + +@pytest.mark.asyncio +async def test_send_contact_request(api): + api.send_message = AsyncMock(return_value={"ok": True}) + result = await api.send_contact_request(user_id=123) + assert result == {"ok": True} + + +@pytest.mark.asyncio +async def test_edit_message(api): + api._request = AsyncMock(return_value={"ok": True}) + result = await api.edit_message(message_id=1, text="Updated") + assert result == {"ok": True} + + +@pytest.mark.asyncio +async def test_get_subscriptions(api): + api._request = AsyncMock(return_value={"ok": True}) + result = await api.get_subscriptions() + assert result == {"ok": True} + + +@pytest.mark.asyncio +async def test_unsubscribe_webhook(api): + api._request = AsyncMock(return_value={"ok": True}) + result = await api.unsubscribe_webhook() + assert result == {"ok": True} + + +def test_verify_webhook_secret_valid(api): + assert api.verify_webhook_secret("secret123", "secret123") is True + + +def test_verify_webhook_secret_invalid(api): + assert api.verify_webhook_secret("wrong", "secret123") is False + + +def test_verify_webhook_secret_empty(api): + assert api.verify_webhook_secret("anything", "") is True + + +def test_verify_contact_hash(api): + result = api.verify_contact_hash("test vcf", "access_token", "some_hash") + assert result is False or result is True + + +def test_headers(api): + headers = api._headers() + assert headers["Authorization"] == "test-token" + assert headers["Content-Type"] == "application/json" + + +@pytest.mark.asyncio +async def test_close(api): + session = AsyncMock() + session.closed = False + api._session = session + await api.close() + session.close.assert_awaited_once() diff --git a/max_bot/tests/test_webhook.py b/max_bot/tests/test_webhook.py new file mode 100644 index 0000000..522a1d7 --- /dev/null +++ b/max_bot/tests/test_webhook.py @@ -0,0 +1,183 @@ +import json + +import pytest + + +class TestWebhook: + @pytest.mark.asyncio + async def test_health_endpoint(self, client): + response = await client.get("/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert data["bot"] == "София" + + @pytest.mark.asyncio + async def test_webhook_missing_secret(self, client): + payload = {"update_type": "bot_started", "user": {"user_id": 123}} + response = await client.post( + "/webhook", + content=json.dumps(payload), + headers={"Content-Type": "application/json"}, + ) + assert response.status_code == 403 + + @pytest.mark.asyncio + async def test_webhook_with_secret(self, client): + payload = {"update_type": "bot_started", "user": {"user_id": 12345}} + response = await client.post( + "/webhook", + content=json.dumps(payload), + headers={ + "Content-Type": "application/json", + "X-Max-Bot-Api-Secret": "test-secret", + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["ok"] is True + + @pytest.mark.asyncio + async def test_webhook_message_created(self, client): + payload = { + "update_type": "message_created", + "user": {"user_id": 99999, "first_name": "Тест"}, + "message": {"body": {"text": "Привет"}}, + } + response = await client.post( + "/webhook", + content=json.dumps(payload), + headers={ + "Content-Type": "application/json", + "X-Max-Bot-Api-Secret": "test-secret", + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["ok"] is True + + @pytest.mark.asyncio + async def test_webhook_invalid_json(self, client): + response = await client.post( + "/webhook", + content="not json", + headers={ + "Content-Type": "application/json", + "X-Max-Bot-Api-Secret": "test-secret", + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["ok"] is False + + @pytest.mark.asyncio + async def test_webhook_message_callback(self, client): + payload = { + "update_type": "message_callback", + "user": {"user_id": 88888}, + "callback": {"payload": "cancel", "id": "cb_001"}, + } + response = await client.post( + "/webhook", + content=json.dumps(payload), + headers={ + "Content-Type": "application/json", + "X-Max-Bot-Api-Secret": "test-secret", + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["ok"] is True + + +class TestAPI: + @pytest.mark.asyncio + async def test_api_settings(self, client): + response = await client.get("/api/bot/settings") + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_api_features(self, client): + response = await client.get("/api/bot/features") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + + @pytest.mark.asyncio + async def test_api_categories(self, client): + response = await client.get("/api/bot/categories") + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_api_templates(self, client): + response = await client.get("/api/bot/templates") + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_api_users(self, client): + response = await client.get("/api/bot/users") + assert response.status_code == 200 + data = response.json() + assert "total" in data + assert "users" in data + + @pytest.mark.asyncio + async def test_api_conversations(self, client): + response = await client.get("/api/bot/conversations") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + + @pytest.mark.asyncio + async def test_api_analytics(self, client): + response = await client.get("/api/bot/analytics") + assert response.status_code == 200 + data = response.json() + assert "total_users" in data + + @pytest.mark.asyncio + async def test_api_test_run(self, client): + response = await client.post( + "/api/bot/test-run", + content=json.dumps({"scenario": "new_dialog", "messages": []}), + headers={"Content-Type": "application/json"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["scenario"] == "new_dialog" + + @pytest.mark.asyncio + async def test_api_test_run_out_of_hours(self, client): + response = await client.post( + "/api/bot/test-run", + content=json.dumps({"scenario": "out_of_hours", "messages": []}), + headers={"Content-Type": "application/json"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["scenario"] == "out_of_hours" + # Should have at least a bot response and mention out_of_hours template + assert len(data.get("conversation", [])) >= 1 + + @pytest.mark.asyncio + async def test_api_broadcast_recipients(self, client): + response = await client.get("/api/bot/broadcast/recipients") + assert response.status_code == 200 + data = response.json() + assert "total" in data + assert "users" in data + + @pytest.mark.asyncio + async def test_api_broadcast_history(self, client): + response = await client.get("/api/bot/broadcast/history") + assert response.status_code == 200 + data = response.json() + assert "total" in data + assert "broadcasts" in data + + @pytest.mark.asyncio + async def test_api_kb(self, client): + response = await client.get("/api/bot/kb") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) diff --git a/max_bot/version.txt b/max_bot/version.txt new file mode 100644 index 0000000..9075be4 --- /dev/null +++ b/max_bot/version.txt @@ -0,0 +1 @@ +1.5.5 diff --git a/py_service/app/routers/service_pages.py b/py_service/app/routers/service_pages.py index 726222f..10ac3a4 100644 --- a/py_service/app/routers/service_pages.py +++ b/py_service/app/routers/service_pages.py @@ -49,7 +49,8 @@ def ctx(request, user, **kw): "role_permissions": getattr(request.state, 'role_permissions', {}), "role_override_active": original_user is not None, "original_role": original_user["role"] if original_user else None, - "available_roles": available_roles.get(user["role"], [user["role"]]) if user else []} + "available_roles": available_roles.get(user["role"], [user["role"]]) if user else [], + "page_name": kw.get("active_page", "")} c.update(kw) return c @@ -76,11 +77,13 @@ async def users_create(request: Request, db: AsyncSession = Depends(get_db), use if not login or not password: return RedirectResponse(url="/service/users", status_code=302) from app.auth import hash_password + max_id_val = form.get("max_user_id") new_user = User( login=login, password_hash=hash_password(password), full_name=form.get("full_name"), role=form.get("role", "technician"), phone=form.get("phone", ""), email=form.get("email", ""), + max_user_id=int(max_id_val) if max_id_val and max_id_val.strip() else None, is_active=form.get("is_active", "1") == "1", ) db.add(new_user); await db.flush() @@ -102,6 +105,8 @@ async def users_edit(request: Request, db: AsyncSession = Depends(get_db), user: u.role = form.get("role", u.role) u.phone = form.get("phone", u.phone) u.email = form.get("email", u.email) + max_id_val = form.get("max_user_id") + u.max_user_id = int(max_id_val) if max_id_val and max_id_val.strip() else None if "is_active" in form: u.is_active = form["is_active"] == "1" from app.auth import hash_password @@ -1243,104 +1248,85 @@ import aiohttp MAX_BOT_URL = "http://127.0.0.1:8002" +async def _proxy(method, path, json_body=None): + try: + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session: + async with session.request(method, f"{MAX_BOT_URL}{path}", json=json_body) as resp: + return JSONResponse(await resp.json()) + except aiohttp.ClientError as e: + return JSONResponse({"error": f"Bot service unavailable: {e}", "ok": False}, status_code=503) + except Exception as e: + return JSONResponse({"error": str(e), "ok": False}, status_code=500) + + @router.get("/service/api/bot/settings") async def api_bot_settings(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): - async with aiohttp.ClientSession() as session: - async with session.get(f"{MAX_BOT_URL}/api/bot/settings") as resp: - return JSONResponse(await resp.json()) + return await _proxy("GET", "/api/bot/settings") @router.post("/service/api/bot/settings/save") async def api_bot_settings_save(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): body = await request.json() - async with aiohttp.ClientSession() as session: - async with session.post(f"{MAX_BOT_URL}/api/bot/settings/save", json=body) as resp: - return JSONResponse(await resp.json()) + return await _proxy("POST", "/api/bot/settings/save", body) @router.get("/service/api/bot/templates") async def api_bot_templates(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): - async with aiohttp.ClientSession() as session: - async with session.get(f"{MAX_BOT_URL}/api/bot/templates") as resp: - return JSONResponse(await resp.json()) + return await _proxy("GET", "/api/bot/templates") @router.post("/service/api/bot/templates/save") async def api_bot_templates_save(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): body = await request.json() - async with aiohttp.ClientSession() as session: - async with session.post(f"{MAX_BOT_URL}/api/bot/templates/save", json=body) as resp: - return JSONResponse(await resp.json()) + return await _proxy("POST", "/api/bot/templates/save", body) @router.get("/service/api/bot/features") async def api_bot_features(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): - async with aiohttp.ClientSession() as session: - async with session.get(f"{MAX_BOT_URL}/api/bot/features") as resp: - return JSONResponse(await resp.json()) + return await _proxy("GET", "/api/bot/features") @router.post("/service/api/bot/features/toggle") async def api_bot_features_toggle(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): body = await request.json() - async with aiohttp.ClientSession() as session: - async with session.post(f"{MAX_BOT_URL}/api/bot/features/toggle", json=body) as resp: - return JSONResponse(await resp.json()) + return await _proxy("POST", "/api/bot/features/toggle", body) @router.get("/service/api/bot/categories") async def api_bot_categories(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): - async with aiohttp.ClientSession() as session: - async with session.get(f"{MAX_BOT_URL}/api/bot/categories") as resp: - return JSONResponse(await resp.json()) + return await _proxy("GET", "/api/bot/categories") @router.post("/service/api/bot/categories/create") async def api_bot_categories_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): body = await request.json() - async with aiohttp.ClientSession() as session: - async with session.post(f"{MAX_BOT_URL}/api/bot/categories/create", json=body) as resp: - return JSONResponse(await resp.json()) + return await _proxy("POST", "/api/bot/categories/create", body) @router.post("/service/api/bot/categories/edit") async def api_bot_categories_edit(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): body = await request.json() - async with aiohttp.ClientSession() as session: - async with session.post(f"{MAX_BOT_URL}/api/bot/categories/edit", json=body) as resp: - return JSONResponse(await resp.json()) + return await _proxy("POST", "/api/bot/categories/edit", body) @router.post("/service/api/bot/categories/delete") async def api_bot_categories_delete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): body = await request.json() - async with aiohttp.ClientSession() as session: - async with session.post(f"{MAX_BOT_URL}/api/bot/categories/delete", json=body) as resp: - return JSONResponse(await resp.json()) + return await _proxy("POST", "/api/bot/categories/delete", body) @router.get("/service/api/bot/kb") async def api_bot_kb(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): - async with aiohttp.ClientSession() as session: - async with session.get(f"{MAX_BOT_URL}/api/bot/kb") as resp: - return JSONResponse(await resp.json()) + return await _proxy("GET", "/api/bot/kb") @router.post("/service/api/bot/kb/create") async def api_bot_kb_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): body = await request.json() - async with aiohttp.ClientSession() as session: - async with session.post(f"{MAX_BOT_URL}/api/bot/kb/create", json=body) as resp: - return JSONResponse(await resp.json()) + return await _proxy("POST", "/api/bot/kb/create", body) @router.post("/service/api/bot/kb/edit") async def api_bot_kb_edit(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): body = await request.json() - async with aiohttp.ClientSession() as session: - async with session.post(f"{MAX_BOT_URL}/api/bot/kb/edit", json=body) as resp: - return JSONResponse(await resp.json()) + return await _proxy("POST", "/api/bot/kb/edit", body) @router.post("/service/api/bot/kb/delete") async def api_bot_kb_delete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): body = await request.json() - async with aiohttp.ClientSession() as session: - async with session.post(f"{MAX_BOT_URL}/api/bot/kb/delete", json=body) as resp: - return JSONResponse(await resp.json()) + return await _proxy("POST", "/api/bot/kb/delete", body) @router.get("/service/api/bot/conversations") async def api_bot_conversations(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): - async with aiohttp.ClientSession() as session: - async with session.get(f"{MAX_BOT_URL}/api/bot/conversations") as resp: - return JSONResponse(await resp.json()) + return await _proxy("GET", "/api/bot/conversations") @router.get("/service/api/bot/users") async def api_bot_users(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner")), @@ -1350,22 +1336,16 @@ async def api_bot_users(db: AsyncSession = Depends(get_db), user: dict = Depends params = f"?page={page}&limit={limit}&sort_by={sort_by}&sort_dir={sort_dir}" if consent: params += f"&consent={consent}" - async with aiohttp.ClientSession() as session: - async with session.get(f"{MAX_BOT_URL}/api/bot/users{params}") as resp: - return JSONResponse(await resp.json()) + return await _proxy("GET", f"/api/bot/users{params}") @router.get("/service/api/bot/analytics") async def api_bot_analytics(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): - async with aiohttp.ClientSession() as session: - async with session.get(f"{MAX_BOT_URL}/api/bot/analytics") as resp: - return JSONResponse(await resp.json()) + return await _proxy("GET", "/api/bot/analytics") @router.post("/service/api/bot/test-run") async def api_bot_test_run(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): body = await request.json() - async with aiohttp.ClientSession() as session: - async with session.post(f"{MAX_BOT_URL}/api/bot/test-run", json=body) as resp: - return JSONResponse(await resp.json()) + return await _proxy("POST", "/api/bot/test-run", body) @router.get("/service/api/bot/broadcast/recipients") async def api_bot_broadcast_recipients(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner")), @@ -1373,20 +1353,221 @@ async def api_bot_broadcast_recipients(db: AsyncSession = Depends(get_db), user: params = f"?page={page}&limit={limit}" if q: params += f"&q={q}" - async with aiohttp.ClientSession() as session: - async with session.get(f"{MAX_BOT_URL}/api/bot/broadcast/recipients{params}") as resp: - return JSONResponse(await resp.json()) + return await _proxy("GET", f"/api/bot/broadcast/recipients{params}") @router.get("/service/api/bot/broadcast/history") async def api_bot_broadcast_history(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner")), page: int = Query(1), limit: int = Query(20)): - async with aiohttp.ClientSession() as session: - async with session.get(f"{MAX_BOT_URL}/api/bot/broadcast/history?page={page}&limit={limit}") as resp: - return JSONResponse(await resp.json()) + return await _proxy("GET", f"/api/bot/broadcast/history?page={page}&limit={limit}") @router.post("/service/api/bot/broadcast") async def api_bot_broadcast(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): body = await request.json() - async with aiohttp.ClientSession() as session: - async with session.post(f"{MAX_BOT_URL}/api/bot/broadcast", json=body) as resp: - return JSONResponse(await resp.json()) \ No newline at end of file + return await _proxy("POST", "/api/bot/broadcast", body) + + +@router.get("/service/bot-settings/handoffs") +async def bot_handoffs_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + return templates.TemplateResponse("pages/bot_handoffs.html", ctx(request, user=user, active_page="bot-handoffs", title="Handoff-диалоги")) + + +@router.get("/service/bot-settings/handoffs/poll") +async def bot_handoffs_poll(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + return await _proxy("GET", "/api/bot/handoffs") + + +@router.post("/service/bot-settings/handoffs/{handoff_id}/take") +async def bot_handoff_take(handoff_id: int, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + return await _proxy("POST", f"/api/bot/handoffs/{handoff_id}/take") + + +@router.get("/service/bot-settings/notifications") +async def bot_notifications_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + return templates.TemplateResponse("pages/bot_notifications.html", ctx(request, user=user, active_page="bot-notifications", title="Уведомления бота")) + + +@router.get("/service/bot-settings/notifications/data") +async def bot_notifications_data(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + return await _proxy("GET", "/api/bot/notifications") + + +@router.post("/service/bot-settings/notifications/{notif_id}/read") +async def bot_notification_read(notif_id: int, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + return await _proxy("POST", f"/api/bot/notifications/{notif_id}/read") + + +# ===================== BOT RISK QUESTIONS ===================== + + +@router.get("/service/bot-settings/risk-questions") +async def bot_risk_questions_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + return templates.TemplateResponse("pages/bot_risk_questions.html", ctx(request, user=user, active_page="bot-risk-questions", title="Вопросы риск-инжиниринга")) + + +@router.get("/service/bot-settings/storage") +async def bot_storage_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + return templates.TemplateResponse("pages/bot_storage.html", ctx(request, user=user, active_page="bot-storage", title="Яндекс.Диск")) + + +@router.get("/service/bot-settings/broadcasts") +async def bot_broadcasts_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + return templates.TemplateResponse("pages/bot_broadcasts.html", ctx(request, user=user, active_page="bot-broadcasts", title="Рассылки")) + + +@router.get("/service/api/bot/risk-questions") +async def api_bot_risk_questions(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + return await _proxy("GET", "/api/bot/risk-questions") + + +@router.post("/service/api/bot/risk-questions/create") +async def api_bot_risk_questions_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + body = await request.json() + return await _proxy("POST", "/api/bot/risk-questions/create", body) + + +@router.post("/service/api/bot/risk-questions/edit") +async def api_bot_risk_questions_edit(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + body = await request.json() + return await _proxy("POST", "/api/bot/risk-questions/edit", body) + + +@router.post("/service/api/bot/risk-questions/toggle") +async def api_bot_risk_questions_toggle(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + body = await request.json() + return await _proxy("POST", "/api/bot/risk-questions/toggle", body) + + +@router.post("/service/api/bot/risk-questions/delete") +async def api_bot_risk_questions_delete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + body = await request.json() + return await _proxy("POST", "/api/bot/risk-questions/delete", body) + + +# ===================== BOT TICKETS ===================== + + +@router.get("/service/bot-settings/tickets") +async def bot_tickets_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + return templates.TemplateResponse("pages/bot_tickets.html", ctx(request, user=user, active_page="bot-tickets", title="Заявки")) + + +@router.get("/service/api/bot/tickets") +async def api_bot_tickets(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner")), + status: str = Query(None), priority: str = Query(None)): + params = "" + if status: params += f"status={status}&" + if priority: params += f"priority={priority}" + return await _proxy("GET", f"/api/bot/tickets?{params}") + + +@router.get("/service/api/bot/tickets/{ticket_id}") +async def api_bot_ticket_get(ticket_id: int, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + return await _proxy("GET", f"/api/bot/tickets/{ticket_id}") + + +@router.post("/service/api/bot/tickets/create") +async def api_bot_tickets_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + body = await request.json() + return await _proxy("POST", "/api/bot/tickets/create", body) + + +@router.post("/service/api/bot/tickets/{ticket_id}/message") +async def api_bot_tickets_message(ticket_id: int, request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + body = await request.json() + return await _proxy("POST", f"/api/bot/tickets/{ticket_id}/message", body) + + +@router.post("/service/api/bot/tickets/{ticket_id}/status") +async def api_bot_tickets_status(ticket_id: int, request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + body = await request.json() + return await _proxy("POST", f"/api/bot/tickets/{ticket_id}/status", body) + + +@router.get("/service/api/bot/engineers") +async def api_bot_engineers(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))): + return await _proxy("GET", "/api/bot/engineers") + + +# ===================== BOT STORAGE (Yandex Disk) ===================== + +async def _get_yandex_client(db: AsyncSession): + from app.yandex_disk import YandexDiskClient + result = await db.execute(text("SELECT value FROM bot_settings WHERE key = 'yandex_disk_token'")) + row = result.scalar_one_or_none() + if not row: + return None + return YandexDiskClient(row) + + +@router.get("/service/api/bot/storage/list") +async def api_bot_storage_list(path: str = Query(""), db: AsyncSession = Depends(get_db), + user: dict = Depends(require_role("owner"))): + client = await _get_yandex_client(db) + if not client: + return JSONResponse({"error": "Yandex Disk not configured"}, status_code=400) + try: + items = await client.list_folder(path) + return JSONResponse(items) + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=500) + finally: + await client.close() + + +@router.get("/service/api/bot/storage/search") +async def api_bot_storage_search(query: str = Query(""), db: AsyncSession = Depends(get_db), + user: dict = Depends(require_role("owner"))): + client = await _get_yandex_client(db) + if not client: + return JSONResponse({"error": "Yandex Disk not configured"}, status_code=400) + try: + items = await client.search(query) + return JSONResponse(items) + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=500) + finally: + await client.close() + + +@router.get("/service/api/bot/storage/public-link") +async def api_bot_storage_public_link(path: str = Query(""), expiry: int = Query(30), + db: AsyncSession = Depends(get_db), + user: dict = Depends(require_role("owner"))): + client = await _get_yandex_client(db) + if not client: + return JSONResponse({"error": "Yandex Disk not configured"}, status_code=400) + try: + url = await client.get_public_url(path, expiry) + return JSONResponse({"url": url}) + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=500) + finally: + await client.close() + + +@router.get("/service/api/bot/storage/download") +async def api_bot_storage_download(path: str = Query(""), db: AsyncSession = Depends(get_db), + user: dict = Depends(require_role("owner"))): + client = await _get_yandex_client(db) + if not client: + return JSONResponse({"error": "Yandex Disk not configured"}, status_code=400) + try: + info = await client.get_file_info(path) + dl_url = info.get("public_url", "") + if not dl_url: + dl_info = await client._request("GET", "/resources/download", params={"path": path}) + dl_url = dl_info.get("href", "") + return JSONResponse({"url": dl_url}) + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=500) + finally: + await client.close() + + +# ===================== HANDOFF CLOSE ===================== + + +@router.post("/service/api/bot/handoffs/{handoff_id}/close") +async def api_bot_handoff_close(handoff_id: int, db: AsyncSession = Depends(get_db), + user: dict = Depends(require_role("owner"))): + return await _proxy("POST", f"/api/bot/handoffs/{handoff_id}/close") \ No newline at end of file diff --git a/py_service/app/templates/_sidebar.html b/py_service/app/templates/_sidebar.html new file mode 100644 index 0000000..a0b5393 --- /dev/null +++ b/py_service/app/templates/_sidebar.html @@ -0,0 +1,129 @@ + + + + \ No newline at end of file diff --git a/py_service/app/templates/pages/bot_broadcasts.html b/py_service/app/templates/pages/bot_broadcasts.html new file mode 100644 index 0000000..9dab2e3 --- /dev/null +++ b/py_service/app/templates/pages/bot_broadcasts.html @@ -0,0 +1,241 @@ +{% extends "page.html" %} +{% block page_content %} + + +
+

Рассылки

+
+
+
+ + +
+
+ +
Текст сообщения появится здесь...
+
+
+
+
+ + +
+
+
+ Выбрать всех согласных + +
+
+
+
+ + +
+
+
+
+ +
+

История рассылок

+
+ + +
IDТекстПолучателейДата
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/py_service/deploy.sh b/py_service/deploy.sh index 36a7c72..b9d9295 100644 --- a/py_service/deploy.sh +++ b/py_service/deploy.sh @@ -46,7 +46,9 @@ echo "Version: $VERSION" # Create archive cd .. ARCHIVE="other/aegisone-py-deploy.tar.gz" -tar czf "$ARCHIVE" py_service/ --exclude='py_service/__pycache__' --exclude='py_service/.venv' --exclude='py_service/.git' --exclude='py_service/app/__pycache__' --exclude='py_service/app/**/__pycache__' +tar czf "$ARCHIVE" \ + py_service/ --exclude='py_service/__pycache__' --exclude='py_service/.venv' --exclude='py_service/.git' --exclude='py_service/app/__pycache__' --exclude='py_service/app/**/__pycache__' \ + max_bot/ --exclude='max_bot/__pycache__' --exclude='max_bot/.venv' echo "Archive created: $ARCHIVE" # Upload @@ -66,6 +68,15 @@ ssh angel@81.177.141.34 << 'SSHEOF' docker compose build --no-cache --network host docker compose up -d echo "Service restarted" + + # Deploy max_bot + echo "" + echo "--- Deploying max_bot ---" + if [ -d "/opt/projects/max_bot" ]; then + bash max_bot/max_bot_deploy.sh + else + echo "⚠️ max_bot directory not found — skipping" + fi SSHEOF # Commit changelog diff --git a/py_service/tests/permission_config.py b/py_service/tests/permission_config.py index 167c557..78fefbc 100644 --- a/py_service/tests/permission_config.py +++ b/py_service/tests/permission_config.py @@ -143,6 +143,12 @@ ROUTE_PERMISSIONS: dict[tuple[str, str], tuple[str, ...]] = { ("GET", "/service/bot-settings/users"): ("owner",), ("GET", "/service/bot-settings/test-runner"): ("owner",), ("GET", "/service/bot-settings/analytics"): ("owner",), + ("GET", "/service/bot-settings/risk-questions"): ("owner",), + ("GET", "/service/bot-settings/handoffs"): ("owner",), + ("GET", "/service/bot-settings/notifications"): ("owner",), + ("GET", "/service/bot-settings/broadcasts"): ("owner",), + ("GET", "/service/bot-settings/storage"): ("owner",), + ("GET", "/service/bot-settings/tickets"): ("owner",), # === PORTAL / ROLE SETTINGS === ("GET", "/service/portal-settings"): ("owner",), @@ -179,6 +185,34 @@ ROUTE_PERMISSIONS: dict[tuple[str, str], tuple[str, ...]] = { ("GET", "/service/api/bot/broadcast/history"): ("owner",), ("POST", "/service/api/bot/broadcast"): ("owner",), + # === BOT RISK QUESTIONS === + ("GET", "/service/api/bot/risk-questions"): ("owner",), + ("POST", "/service/api/bot/risk-questions/create"): ("owner",), + ("POST", "/service/api/bot/risk-questions/edit"): ("owner",), + ("POST", "/service/api/bot/risk-questions/toggle"): ("owner",), + ("POST", "/service/api/bot/risk-questions/delete"): ("owner",), + + # === BOT STORAGE === + ("GET", "/service/api/bot/storage/list"): ("owner",), + ("GET", "/service/api/bot/storage/search"): ("owner",), + ("GET", "/service/api/bot/storage/public-link"): ("owner",), + ("GET", "/service/api/bot/storage/download"): ("owner",), + + # === BOT TICKETS === + ("GET", "/service/api/bot/tickets"): ("owner",), + ("GET", "/service/api/bot/tickets/{ticket_id}"): ("owner",), + ("POST", "/service/api/bot/tickets/create"): ("owner",), + ("POST", "/service/api/bot/tickets/{ticket_id}/message"): ("owner",), + ("POST", "/service/api/bot/tickets/{ticket_id}/status"): ("owner",), + ("GET", "/service/api/bot/engineers"): ("owner",), + + # === BOT HANDOFFS / NOTIFICATIONS (non-standard paths) === + ("GET", "/service/bot-settings/handoffs/poll"): ("owner",), + ("POST", "/service/bot-settings/handoffs/{handoff_id}/take"): ("owner",), + ("POST", "/service/api/bot/handoffs/{handoff_id}/close"): ("owner",), + ("GET", "/service/bot-settings/notifications/data"): ("owner",), + ("POST", "/service/bot-settings/notifications/{notif_id}/read"): ("owner",), + # === OTHER API (main.py) === ("GET", "/service/api/shs"): ("owner", "engineer", "technician"), ("GET", "/service/api/tasks"): ("owner", "engineer", "technician"), @@ -207,6 +241,12 @@ DYNAMIC_ROUTES: set[tuple[str, str]] = { ("GET", "/service/documents/{slug}/edit"), ("POST", "/service/documents/{slug}/edit"), ("GET", "/service/documents/{slug}/download"), + ("POST", "/service/bot-settings/handoffs/{handoff_id}/take"), + ("POST", "/service/bot-settings/notifications/{notif_id}/read"), + ("POST", "/service/api/bot/handoffs/{handoff_id}/close"), + ("GET", "/service/api/bot/tickets/{ticket_id}"), + ("POST", "/service/api/bot/tickets/{ticket_id}/message"), + ("POST", "/service/api/bot/tickets/{ticket_id}/status"), } # Routes excluded from discover test (internal framework routes)