diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..25887e1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +.pytest_cache/ +.env +logs/ +*.tar.gz diff --git a/.opencode/plans/max_bot_fixes_v2.md b/.opencode/plans/max_bot_fixes_v2.md new file mode 100644 index 0000000..16140f8 --- /dev/null +++ b/.opencode/plans/max_bot_fixes_v2.md @@ -0,0 +1,128 @@ +# MAX Bot Fixes — Implementation Plan v2 + +## Phase 1: py_service (no rebuild, restart only) + +### 1a. Fix ctx() mojibake +**File:** `py_service/app/routers/service_pages.py:48` +```python +# Add to ctx() dict: +"page_name": kw.get("active_page", "") +``` + +### 1b. Add broadcast page route +**File:** `py_service/app/routers/service_pages.py` — after line 1396: +```python +@router.get("/service/bot-settings/broadcasts") +async def bot_broadcasts_page(request, db, user): + return templates.TemplateResponse("pages/bot_broadcasts.html", ctx(request, user=user, active_page="bot-broadcasts", title="Рассылки")) +``` +Plus proxy endpoints for broadcast data. + +### 1c. Add sidebar link +**File:** `py_service/app/templates/_sidebar.html` — after bot-storage line: +```html +{% if role_permissions.get('bot_broadcasts', True) != False %}Рассылки{% endif %} +``` + +### 1d. Create bot_broadcasts.html template +**File:** `py_service/app/templates/pages/bot_broadcasts.html` +Basically the broadcast section extracted from bot_analytics.html into its own page. + +### 1e. Add try/except to all proxy endpoints +**File:** `py_service/app/routers/service_pages.py` +Create helper: +```python +async def _proxy(method, path, json_body=None): + try: + async with aiohttp.ClientSession() as session: + async with session.request(method, f"{MAX_BOT_URL}{path}", json=json_body) as resp: + return JSONResponse(await resp.json()) + except Exception as e: + return JSONResponse({"error": f"Bot service unavailable: {e}", "ok": False}, status_code=503) +``` + +### 1f. Update permission_config.py +Add ALL missing routes (bot broadcasts, notifications, handoffs, risk-questions, storage, tickets) + +## Phase 2: max_bot (rebuild required) + +### 2a. Fix feature_key auto_notifications → notifications +**File:** `max_bot/sql/bot_schema.sql:156` — change `'auto_notifications'` to `'notifications'` + +### 2b. Fix commercial_offer → commercial (or vice versa) +**File:** `max_bot/sql/bot_schema.sql:153` — change `'commercial_offer'` to `'commercial'` +**File:** `max_bot/app/keyboards/inline.py:20` — ensure payload matches + +### 2c. Fix test-run: add role:'bot' to test messages +**File:** `max_bot/app/max_api.py:71` — add `msg["role"] = "bot"` +**File:** `max_bot/app/main.py:407` — add `role: 'bot'` when copying greeting + +### 2d. Fix flush→commit everywhere +**Files:** `max_bot/app/main.py` +- Lines 596, 620, 632, 644 → `db.commit()` +- Lines 722, 725 → `db.commit()` +- Lines 740, 755 → `db.commit()` +- Lines 801, 814 → `db.commit()` +- Line 852 → `db.commit()` + +**File:** `max_bot/app/database.py:15-17` — add session.commit(): +```python +async def get_db(): + async with async_session() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise +``` + +### 2e. SQL migration: add recipient_ids to bot_broadcasts +**File:** `max_bot/sql/migrations/v1.6.0_fixes.sql` +```sql +ALTER TABLE bot_broadcasts ADD COLUMN IF NOT EXISTS recipient_ids JSONB DEFAULT '[]'::jsonb; +``` + +### 2f. FSM persistence (optional) +Create `bot_fsm_states` table + async save/load in FSM class. + +### 2g. RMP permissions for engineer (optional) +Add INSERT for engineer with bot_* menu keys. + +## Phase 3: Replace categories + +### 3a. SQL migration: +```sql +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); +``` + +## Phase 4: Deploy scripts + +### 4a. Fix max_bot_deploy.sh rsync +**File:** `max_bot/max_bot_deploy.sh:32` — change `max_bot/` to `.` + +### 4b. Add max_bot hook to main deploy.sh +**File:** `py_service/deploy.sh` — after docker compose up -d: +```bash +echo "Deploying max_bot..." +bash max_bot/max_bot_deploy.sh +``` + +## Phase 5: Changelog +Create `CHANGELOG.md` with all fixes. + +## Deploy Order +1. SQL migrations on DB +2. scp + docker exec tee for py_service files +3. docker compose restart aegisone-app +4. scp + docker compose build for max_bot +5. docker compose up -d aegisone-max-bot +6. Verify health checks diff --git a/aegisone-php/Dockerfile b/aegisone-php/Dockerfile new file mode 100644 index 0000000..39796a5 --- /dev/null +++ b/aegisone-php/Dockerfile @@ -0,0 +1,23 @@ +FROM php:8.1-fpm + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq-dev \ + libzip-dev \ + unzip \ + git \ + && docker-php-ext-install pdo pdo_pgsql pgsql zip \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + +WORKDIR /var/www/html + +COPY src/ /var/www/html/ + +RUN mkdir -p /var/www/html/service/permissions && chmod 777 /var/www/html/service/permissions + +RUN if [ -f composer.json ]; then composer install --no-dev --no-interaction 2>/dev/null || true; fi + +EXPOSE 9000 + +CMD ["php-fpm"] diff --git a/aegisone-php/docker-compose.yml b/aegisone-php/docker-compose.yml new file mode 100644 index 0000000..c9402f0 --- /dev/null +++ b/aegisone-php/docker-compose.yml @@ -0,0 +1,28 @@ +services: + nginx: + image: nginx:alpine + container_name: aegisone-php-nginx + network_mode: host + volumes: + - ./src:/var/www/html:ro + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - php + restart: unless-stopped + + php: + build: + context: . + network: host + container_name: aegisone-php-fpm + network_mode: host + volumes: + - ./src:/var/www/html + environment: + DB_TYPE: pgsql + DB_HOST: localhost + DB_PORT: "5432" + DB_NAME: aegisone + DB_USER: aegisone + DB_PASS: aegisone_pass + restart: unless-stopped diff --git a/aegisone-php/nginx.conf b/aegisone-php/nginx.conf new file mode 100644 index 0000000..3b4e103 --- /dev/null +++ b/aegisone-php/nginx.conf @@ -0,0 +1,29 @@ +server { + listen 8080; + server_name localhost; + root /var/www/html; + index index.php index.html; + + charset utf-8; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location ~ \.php$ { + fastcgi_pass 127.0.0.1:9000; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + include fastcgi_params; + fastcgi_param PHP_VALUE "upload_max_filesize = 50M \n post_max_size = 50M"; + } + + location ~ /\.ht { + deny all; + } + + location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|webp|woff|woff2|ttf|eot)$ { + expires 30d; + add_header Cache-Control "public, immutable"; + } +} diff --git a/config.php b/config.php index 487761e..e0db2f7 100644 --- a/config.php +++ b/config.php @@ -60,7 +60,7 @@ define('YANDEX_MAP_ADDRESS', '350072, Россия, Краснодарский // MAX_LINK — (опционально) резервный мессенджер define('WHATSAPP_LINK', 'https://wa.me/79531137616'); define('TELEGRAM_LINK', 'https://t.me/+79531137616'); -define('MAX_LINK', ''); +define('MAX_LINK', 'https://max.ru/id2311381465_bot'); // ============================ // 6. НАСТРОЙКИ ПОЧТЫ diff --git a/gitea/docker-compose.yml b/gitea/docker-compose.yml new file mode 100644 index 0000000..99f3710 --- /dev/null +++ b/gitea/docker-compose.yml @@ -0,0 +1,49 @@ +services: + server: + image: gitea/gitea:latest + container_name: gitea + network_mode: host + environment: + USER_UID: 1000 + USER_GID: 1000 + GITEA__database__DB_TYPE: postgres + GITEA__database__HOST: localhost:5433 + GITEA__database__NAME: gitea + GITEA__database__USER: gitea + GITEA__database__PASSWD: gitea_password + GITEA__server__DOMAIN: git.aegisone.ru + GITEA__server__HTTP_PORT: 3000 + GITEA__server__ROOT_URL: https://git.aegisone.ru/ + GITEA__server__SSH_PORT: 2222 + GITEA__server__DISABLE_SSH: "true" + GITEA__server__START_SSH_SERVER: "false" + volumes: + - gitea_data:/data + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + depends_on: + db: + condition: service_healthy + restart: unless-stopped + + db: + image: postgres:15-alpine + container_name: gitea-db + network_mode: host + environment: + POSTGRES_USER: gitea + POSTGRES_PASSWORD: gitea_password + POSTGRES_DB: gitea + PGPORT: 5433 + volumes: + - gitea_db_data:/var/lib/postgresql/data + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U gitea -h localhost"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + gitea_data: + gitea_db_data: diff --git a/index.php b/index.php index 48cdb0b..55879aa 100644 --- a/index.php +++ b/index.php @@ -154,7 +154,7 @@ require __DIR__ . '/inc/header.php'; $dsn_cases = 'mysql:host=' . DB_HOST . ';port=' . DB_PORT . ';dbname=' . DB_NAME . ';charset=' . DB_CHARSET; } $pdo_cases = new PDO($dsn_cases, DB_USER, DB_PASS, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]); - $stmt_cases = $pdo_cases->query("SELECT title, text, effect FROM cases WHERE is_active=1 ORDER BY sort_order ASC, id ASC"); + $stmt_cases = $pdo_cases->query("SELECT title, text, effect FROM cases WHERE is_active ORDER BY sort_order ASC, id ASC"); $cases = $stmt_cases->fetchAll(); } catch (\Throwable $e) { $cases = []; diff --git a/inspect.json b/inspect.json new file mode 100644 index 0000000..03b5627 Binary files /dev/null and b/inspect.json differ diff --git a/max_bot/.env.example b/max_bot/.env.example deleted file mode 100644 index 51f4cad..0000000 --- a/max_bot/.env.example +++ /dev/null @@ -1,5 +0,0 @@ -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 index 5c5cfa8..c24cb3a 100644 --- a/max_bot/CHANGELOG.md +++ b/max_bot/CHANGELOG.md @@ -1,24 +1,23 @@ -# Changelog +# Changelog — AegisOne MAX Bot -## 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` +## 1.7.0 (29.05.2026) +### Новые функции +- **Рефакторинг архитектуры:** плоская структура модулей (models.py, yandex_gpt.py, keyboards.py, email_sender.py, config_reader.py) вместо вложенных пакетов +- **VCF + User Model (Q2):** + - BotUser расширен: patronymic, email, organization, address, vcf_raw, contact_hash, phone_verified, email_verified + - VCF-парсер: FN, N, TEL, EMAIL, ORG, ADR — все поля сохраняются + - Upsert пользователя при повторном контакте (greeting.py) + - total_conversations инкремент, last_interaction обновление + - handle_contact_share сохраняет все VCF-поля (handoff.py) + - total_tickets инкремент в _finalize_ticket (inquiry.py) +- **NLP history context:** _get_user_history_context() — последние 10 сообщений + открытые заявки → YandexGPT +- **Broadcast с превью и таймером (Q1):** модалка подтверждения с таймером 10 секунд +- **CSS-переменные (Q3):** var(--white)→var(--bg-card), var(--text)→var(--text-primary) +- **bot_settings (Q4):** showNotification(), max_bot_id редактируемый +- **Webhook secret (Q5):** проброс X-Max-Bot-Api-Secret через прокси +- Маскировка sensitive API-ключей в /api/bot/settings +- dialog_cleared handler — сброс состояния диалога +- Миграции через _add_column_if_not_exists() — новые колонки BotUser +- Rate limit в broadcast: asyncio.sleep(0.5) после каждых 10 +- Уточнение сообщения (contact.py) с учётом conv.intent +- Тесты: test_yandex_gpt.py, test_email_sender.py diff --git a/max_bot/Dockerfile b/max_bot/Dockerfile index c8e21e8..5fc6ef8 100644 --- a/max_bot/Dockerfile +++ b/max_bot/Dockerfile @@ -2,15 +2,13 @@ FROM python:3.12-slim WORKDIR /app +# sendmail not available on trixie/slim; email sender falls back to debug log + COPY requirements.txt . -RUN pip install --no-cache-dir --only-binary :all: -r requirements.txt +RUN pip install --no-cache-dir -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"] +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8002"] diff --git a/max_bot/app/__pycache__/__init__.cpython-314.pyc b/max_bot/app/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 4feef11..0000000 Binary files a/max_bot/app/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/max_bot/app/__pycache__/bot_engine.cpython-314.pyc b/max_bot/app/__pycache__/bot_engine.cpython-314.pyc deleted file mode 100644 index 35b6fc1..0000000 Binary files a/max_bot/app/__pycache__/bot_engine.cpython-314.pyc and /dev/null differ diff --git a/max_bot/app/__pycache__/bot_engine.cpython-38.pyc b/max_bot/app/__pycache__/bot_engine.cpython-38.pyc deleted file mode 100644 index d0c30c4..0000000 Binary files a/max_bot/app/__pycache__/bot_engine.cpython-38.pyc and /dev/null differ diff --git a/max_bot/app/__pycache__/config.cpython-314.pyc b/max_bot/app/__pycache__/config.cpython-314.pyc deleted file mode 100644 index 911d7b3..0000000 Binary files a/max_bot/app/__pycache__/config.cpython-314.pyc and /dev/null differ diff --git a/max_bot/app/__pycache__/config.cpython-38.pyc b/max_bot/app/__pycache__/config.cpython-38.pyc index f64ce37..c3ba520 100644 Binary files a/max_bot/app/__pycache__/config.cpython-38.pyc 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 deleted file mode 100644 index ec9ee8b..0000000 Binary files a/max_bot/app/__pycache__/conversation.cpython-314.pyc and /dev/null differ diff --git a/max_bot/app/__pycache__/conversation.cpython-38.pyc b/max_bot/app/__pycache__/conversation.cpython-38.pyc deleted file mode 100644 index d897b70..0000000 Binary files a/max_bot/app/__pycache__/conversation.cpython-38.pyc and /dev/null differ diff --git a/max_bot/app/__pycache__/database.cpython-314.pyc b/max_bot/app/__pycache__/database.cpython-314.pyc deleted file mode 100644 index f7a2e86..0000000 Binary files a/max_bot/app/__pycache__/database.cpython-314.pyc and /dev/null differ diff --git a/max_bot/app/__pycache__/database.cpython-38.pyc b/max_bot/app/__pycache__/database.cpython-38.pyc index 24b0f37..32617a4 100644 Binary files a/max_bot/app/__pycache__/database.cpython-38.pyc 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 deleted file mode 100644 index eb1697b..0000000 Binary files a/max_bot/app/__pycache__/file_storage.cpython-38.pyc and /dev/null differ diff --git a/max_bot/app/__pycache__/fsm.cpython-314.pyc b/max_bot/app/__pycache__/fsm.cpython-314.pyc deleted file mode 100644 index 96817af..0000000 Binary files a/max_bot/app/__pycache__/fsm.cpython-314.pyc and /dev/null differ diff --git a/max_bot/app/__pycache__/fsm.cpython-38.pyc b/max_bot/app/__pycache__/fsm.cpython-38.pyc deleted file mode 100644 index 2d55afe..0000000 Binary files a/max_bot/app/__pycache__/fsm.cpython-38.pyc and /dev/null differ diff --git a/max_bot/app/__pycache__/main.cpython-314.pyc b/max_bot/app/__pycache__/main.cpython-314.pyc deleted file mode 100644 index 8d25139..0000000 Binary files a/max_bot/app/__pycache__/main.cpython-314.pyc and /dev/null differ diff --git a/max_bot/app/__pycache__/main.cpython-38.pyc b/max_bot/app/__pycache__/main.cpython-38.pyc deleted file mode 100644 index 2012949..0000000 Binary files a/max_bot/app/__pycache__/main.cpython-38.pyc and /dev/null differ diff --git a/max_bot/app/__pycache__/max_api.cpython-314.pyc b/max_bot/app/__pycache__/max_api.cpython-314.pyc deleted file mode 100644 index 13ccfa1..0000000 Binary files a/max_bot/app/__pycache__/max_api.cpython-314.pyc and /dev/null differ diff --git a/max_bot/app/__pycache__/max_api.cpython-38.pyc b/max_bot/app/__pycache__/max_api.cpython-38.pyc index 3f7e43f..66e0a0d 100644 Binary files a/max_bot/app/__pycache__/max_api.cpython-38.pyc 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 deleted file mode 100644 index f884a77..0000000 Binary files a/max_bot/app/__pycache__/settings_cache.cpython-314.pyc and /dev/null differ diff --git a/max_bot/app/__pycache__/settings_cache.cpython-38.pyc b/max_bot/app/__pycache__/settings_cache.cpython-38.pyc index 2ed8b77..ee7b893 100644 Binary files a/max_bot/app/__pycache__/settings_cache.cpython-38.pyc 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 deleted file mode 100644 index 0c798ed..0000000 Binary files a/max_bot/app/__pycache__/text_renderer.cpython-314.pyc and /dev/null differ diff --git a/max_bot/app/__pycache__/text_renderer.cpython-38.pyc b/max_bot/app/__pycache__/text_renderer.cpython-38.pyc deleted file mode 100644 index bff0513..0000000 Binary files a/max_bot/app/__pycache__/text_renderer.cpython-38.pyc and /dev/null differ diff --git a/max_bot/app/bot_engine.py b/max_bot/app/bot_engine.py deleted file mode 100644 index 3b629d6..0000000 --- a/max_bot/app/bot_engine.py +++ /dev/null @@ -1,268 +0,0 @@ -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 index e4a4130..063b28f 100644 --- a/max_bot/app/config.py +++ b/max_bot/app/config.py @@ -1,24 +1,22 @@ -import os -from pathlib import Path +from typing import List, Optional 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" + database_url: str = "postgresql+asyncpg://aegisone:aegisone_pass@localhost:5432/aegisone" + max_token: str = "f9LHodD0cOKkk03DYXj906MdIeRs3HdDo0BC5H2fLMVCMz3PA_Vx4aJqt2l2qPLM5zd5EQ1Zh6APxzpw2UYE" + max_api_base: str = "https://platform-api.max.ru" + webhook_url: str = "https://max.aegisone.ru/webhook" + webhook_secret: str = "aegisone_bot_secret_2026" + config_php_path: str = "/var/www/html/config.php" + php_sendmail_path: str = "/usr/sbin/sendmail" + default_assistant_name: str = "София" + default_assistant_role: str = "Представитель компании AegisOne Engineering, помогающий клиентам с запросами по безопасности" + default_support_email: str = "zakaz@aegisone.ru" + default_email_subject: str = "Обращение через Виртуального помощника AegisOne Engineering" + update_types: List[str] = ["bot_started", "message_created", "message_callback"] - class Config: - env_file = ".env" - env_file_encoding = "utf-8" + model_config = {"env_prefix": "BOT_", "env_file": ".env"} -@lru_cache() -def get_settings() -> Settings: - return Settings() +settings = Settings() diff --git a/max_bot/app/config_reader.py b/max_bot/app/config_reader.py new file mode 100644 index 0000000..28f71e0 --- /dev/null +++ b/max_bot/app/config_reader.py @@ -0,0 +1,61 @@ +from __future__ import annotations +import re +import os +from typing import Optional, List, Tuple +from app.config import settings + + +class ConfigReader: + def __init__(self): + self._cache = {} + self._loaded = False + + def load(self, path: Optional[str] = None) -> dict: + filepath = path or settings.config_php_path + if not os.path.isfile(filepath): + return self._fallback() + + with open(filepath, "r", encoding="utf-8") as f: + content = f.read() + + constants = {} + pattern = re.compile(r"define\(\s*'(\w+)'\s*,\s*'([^']*)'\s*\)") + for match in pattern.finditer(content): + constants[match.group(1)] = match.group(2) + + self._cache = constants + self._loaded = True + return constants + + def _fallback(self) -> dict: + self._cache = { + "PHONE_MAIN": "+7 (861) 203-33-30", + "PHONE_MAIN_LINK": "+78612033330", + "PHONE_SECOND": "+7 (995) 203-33-30", + "PHONE_SECOND_LINK": "+79952033330", + "SITE_MAIL": "mail@aegisone.ru", + "SITE_MAIL_FROM": "no-reply@aegisone.ru", + "SITE_MAIL_TO": "mail@aegisone.ru", + } + self._loaded = True + return self._cache + + def get(self, key: str, default: str = "") -> str: + if not self._loaded: + self.load() + return self._cache.get(key, default) + + def get_phones(self) -> List[Tuple[str, str]]: + return [ + (self.get("PHONE_MAIN"), self.get("PHONE_MAIN_LINK")), + (self.get("PHONE_SECOND"), self.get("PHONE_SECOND_LINK")), + ] + + def get_site_mail(self) -> str: + return self.get("SITE_MAIL", "mail@aegisone.ru") + + def get_mail_from(self) -> str: + return self.get("SITE_MAIL_FROM", "no-reply@aegisone.ru") + + +config_reader = ConfigReader() diff --git a/max_bot/app/conversation.py b/max_bot/app/conversation.py deleted file mode 100644 index 739cf7b..0000000 --- a/max_bot/app/conversation.py +++ /dev/null @@ -1,142 +0,0 @@ -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 index 7fe217b..b157a4f 100644 --- a/max_bot/app/database.py +++ b/max_bot/app/database.py @@ -1,22 +1,13 @@ -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) +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from app.config import settings +engine = create_async_engine(settings.database_url, pool_size=10, max_overflow=20) async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) -async def get_db(): +async def get_db() -> AsyncSession: async with async_session() as session: try: yield session - await session.commit() - except Exception: - await session.rollback() - raise + finally: + await session.close() diff --git a/max_bot/app/email_sender.py b/max_bot/app/email_sender.py new file mode 100644 index 0000000..6fede5f --- /dev/null +++ b/max_bot/app/email_sender.py @@ -0,0 +1,80 @@ +import subprocess +import os +from typing import Optional +from app.config import settings +from app.config_reader import config_reader + + +class EmailSender: + def __init__(self): + self.sendmail_path = settings.php_sendmail_path + + def send( + self, + to: str, + subject: str, + body: str, + from_addr: Optional[str] = None, + ) -> bool: + if from_addr is None: + from_addr = config_reader.get_mail_from() + + message = self._build_message(to, subject, body, from_addr) + + try: + if os.name == "nt": + return self._send_debug(to, subject, body, from_addr) + proc = subprocess.run( + [self.sendmail_path, "-t", "-i"], + input=message, + capture_output=True, + text=True, + timeout=30, + ) + return proc.returncode == 0 + except Exception: + return self._send_debug(to, subject, body, from_addr) + + def _send_debug(self, to: str, subject: str, body: str, from_addr: str) -> bool: + log_dir = os.path.join(os.path.dirname(__file__), "..", "logs") + os.makedirs(log_dir, exist_ok=True) + log_path = os.path.join(log_dir, "emails.log") + with open(log_path, "a", encoding="utf-8") as f: + f.write(f"=== EMAIL ===\nFrom: {from_addr}\nTo: {to}\nSubject: {subject}\n\n{body}\n\n") + return True + + def _build_message(self, to: str, subject: str, body: str, from_addr: str) -> str: + lines = [ + f"From: {from_addr}", + f"To: {to}", + f"Subject: {subject}", + "MIME-Version: 1.0", + "Content-Type: text/plain; charset=UTF-8", + "Content-Transfer-Encoding: 8bit", + "", + body, + ] + return "\r\n".join(lines) + + def send_ticket_escalation( + self, + to: str, + subject: str, + user_info: str, + contact: str, + inquiry: str, + history: str, + ) -> bool: + body = ( + f"Поступило новое обращение через Виртуального помощника AegisOne Engineering\n\n" + f"--- Данные клиента ---\n{user_info}\n" + f"--- Контактные данные ---\n{contact}\n" + f"--- Суть обращения ---\n{inquiry}\n" + f"--- История переписки ---\n{history}\n" + f"---\n" + f"Дата и время: {__import__('datetime').datetime.now().strftime('%d.%m.%Y %H:%M')}" + ) + return self.send(to, subject, body) + + +email_sender = EmailSender() diff --git a/max_bot/app/file_storage.py b/max_bot/app/file_storage.py deleted file mode 100644 index 729649c..0000000 --- a/max_bot/app/file_storage.py +++ /dev/null @@ -1,77 +0,0 @@ -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 deleted file mode 100644 index 51d8b7b..0000000 --- a/max_bot/app/fsm.py +++ /dev/null @@ -1,209 +0,0 @@ -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/__pycache__/__init__.cpython-314.pyc b/max_bot/app/handlers/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index ee094bc..0000000 Binary files a/max_bot/app/handlers/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/max_bot/app/handlers/__pycache__/consent.cpython-314.pyc b/max_bot/app/handlers/__pycache__/consent.cpython-314.pyc deleted file mode 100644 index 8be9a87..0000000 Binary files a/max_bot/app/handlers/__pycache__/consent.cpython-314.pyc and /dev/null differ diff --git a/max_bot/app/handlers/__pycache__/consent.cpython-38.pyc b/max_bot/app/handlers/__pycache__/consent.cpython-38.pyc index 072bca1..1aaf000 100644 Binary files a/max_bot/app/handlers/__pycache__/consent.cpython-38.pyc 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 index 2ab86d8..878b4b5 100644 Binary files a/max_bot/app/handlers/__pycache__/contact.cpython-38.pyc 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 deleted file mode 100644 index fb85bb1..0000000 Binary files a/max_bot/app/handlers/__pycache__/greeting.cpython-314.pyc and /dev/null differ diff --git a/max_bot/app/handlers/__pycache__/greeting.cpython-38.pyc b/max_bot/app/handlers/__pycache__/greeting.cpython-38.pyc index 212e202..a774f09 100644 Binary files a/max_bot/app/handlers/__pycache__/greeting.cpython-38.pyc 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 deleted file mode 100644 index 8a4a530..0000000 Binary files a/max_bot/app/handlers/__pycache__/handoff.cpython-314.pyc and /dev/null differ diff --git a/max_bot/app/handlers/__pycache__/handoff.cpython-38.pyc b/max_bot/app/handlers/__pycache__/handoff.cpython-38.pyc index 62c3362..53be288 100644 Binary files a/max_bot/app/handlers/__pycache__/handoff.cpython-38.pyc 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 deleted file mode 100644 index c6453e3..0000000 Binary files a/max_bot/app/handlers/__pycache__/inquiry.cpython-314.pyc and /dev/null differ diff --git a/max_bot/app/handlers/__pycache__/inquiry.cpython-38.pyc b/max_bot/app/handlers/__pycache__/inquiry.cpython-38.pyc index 3e852b5..48f17dc 100644 Binary files a/max_bot/app/handlers/__pycache__/inquiry.cpython-38.pyc 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 deleted file mode 100644 index a4cb8b8..0000000 Binary files a/max_bot/app/handlers/__pycache__/main_menu.cpython-314.pyc and /dev/null 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 deleted file mode 100644 index e0e0a20..0000000 Binary files a/max_bot/app/handlers/__pycache__/main_menu.cpython-38.pyc and /dev/null 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 deleted file mode 100644 index 06d3682..0000000 Binary files a/max_bot/app/handlers/__pycache__/out_of_hours.cpython-314.pyc and /dev/null differ diff --git a/max_bot/app/handlers/consent.py b/max_bot/app/handlers/consent.py index 6f0bf4e..f694edf 100644 --- a/max_bot/app/handlers/consent.py +++ b/max_bot/app/handlers/consent.py @@ -1,60 +1,107 @@ -from app.settings_cache import SettingsCache -from app.conversation import get_or_create_user, log_consent, get_template_rendered -from app.keyboards.inline import build_consent_buttons, build_contact_buttons -from app.fsm import BotState, FSM +import datetime +import logging +from sqlalchemy import select +from app.database import async_session +from app.models import BotUser, BotConversation, BotMessage +from app.max_api import max_api +from app.config_reader import config_reader +from app.keyboards import contact_keyboard, consent_keyboard, return_to_consent_keyboard + +logger = logging.getLogger(__name__) -async def handle_consent_request(bot, user_data: dict, db, max_api): - max_user_id = user_data["user_id"] - user = await get_or_create_user(db, max_user_id, - user_data.get("first_name", ""), - user_data.get("last_name", ""), - user_data.get("username", "")) - await db.flush() - - await SettingsCache.refresh(db) - - policy_url = SettingsCache.get("consent_policy_url", "https://aegisone.ru/politica.php") - text = await get_template_rendered(db, "consent_request", policy_url=policy_url) - buttons = build_consent_buttons() - await max_api.send_message_with_keyboard(max_user_id, text, buttons, format="markdown") - FSM.set_state(max_user_id, BotState.CONSENT_REQUEST) +async def handle_consent_callback(user_id: int, conv_id: int, callback_id: str) -> None: + if callback_id == "consent_yes": + await handle_consent_yes(user_id, conv_id) + elif callback_id == "consent_no": + await handle_consent_no(user_id, conv_id) -async def handle_consent_given(bot, user_data: dict, db, max_api): - max_user_id = user_data["user_id"] - user = await get_or_create_user(db, max_user_id) +async def handle_consent_response(user_id: int, conv_id: int, text: str) -> None: + text_lower = text.strip().lower() + consent_words = ["да", "даю", "согласен", "согласна", "yes", "ok", "хорошо", "даю согласие"] + refuse_words = ["нет", "не даю", "отказ", "no", "не согласен", "не согласна"] - user.consent_given = True - from datetime import datetime - user.consent_timestamp = datetime.now() - user.consent_method = "button" - await log_consent(db, user.id, "given", "button", user_data.get("ip", "")) - await db.flush() - - text = await get_template_rendered(db, "contact_request") - buttons = build_contact_buttons() - await max_api.send_message_with_keyboard(max_user_id, text, buttons) - FSM.set_state(max_user_id, BotState.CONTACT_REQUEST) + if any(w in text_lower for w in consent_words): + await handle_consent_yes(user_id, conv_id) + elif any(w in text_lower for w in refuse_words): + await handle_consent_no(user_id, conv_id) + else: + await max_api.send_message( + user_id, + "Пожалуйста, дайте чёткий ответ — даёте ли вы согласие на обработку " + "персональных данных? Это необходимо для обработки вашего запроса.", + attachments=consent_keyboard(), + ) -async def handle_consent_refused(bot, user_data: dict, db, max_api): - max_user_id = user_data["user_id"] - user = await get_or_create_user(db, max_user_id) +async def handle_consent_yes(user_id: int, conv_id: int) -> None: + async with async_session() as db: + result = await db.execute(select(BotUser).where(BotUser.id == user_id)) + user = result.scalar_one_or_none() + if user: + user.consent_given = True + user.consent_date = datetime.datetime.utcnow() - await log_consent(db, user.id, "revoked", "button", user_data.get("ip", "")) - await db.flush() + result = await db.execute( + select(BotConversation).where(BotConversation.id == conv_id) + ) + conv = result.scalar_one_or_none() + if conv: + conv.state = "awaiting_contact" - await SettingsCache.refresh(db) - phone_1 = SettingsCache.get("phone_1") - phone_2 = SettingsCache.get("phone_2") - email = SettingsCache.get("support_email") + await db.commit() - text = await get_template_rendered(db, "consent_refused", - phone_1=phone_1, phone_2=phone_2, - support_email=email) - from app.keyboards.inline import build_phone_email_buttons - subject = SettingsCache.get("email_subject") - buttons = build_phone_email_buttons(phone_1, phone_2, email, subject) - await max_api.send_message_with_keyboard(max_user_id, text, buttons, format="markdown") - FSM.reset(max_user_id) + await max_api.send_message( + user_id, + "Спасибо! Поделитесь, пожалуйста, вашим контактом, чтобы мы могли " + "связаться с вами.\n\n" + "Нажмите кнопку «Поделиться контактом» или введите номер телефона / email вручную.", + attachments=contact_keyboard(), + ) + + +async def handle_consent_no(user_id: int, conv_id: int) -> None: + async with async_session() as db: + result = await db.execute( + select(BotConversation).where(BotConversation.id == conv_id) + ) + conv = result.scalar_one_or_none() + if conv: + conv.state = "consent_refused" + await db.commit() + + phones = config_reader.get_phones() + email = config_reader.get_site_mail() + + phone_text = "\n".join( + f"📞 {phone}" for phone, _ in phones + ) + + await max_api.send_message( + user_id, + f"Без вашего согласия мы не можем обработать обращение.\n\n" + f"Вы можете позвонить нам:\n{phone_text}\n\n" + f"Или написать на почту: {email}\n\n" + f"Если передумаете и захотите дать согласие — просто напишите об этом.", + ) + + +async def handle_refused_again(user_id: int, conv_id: int, text: str) -> None: + text_lower = text.strip().lower() + consent_words = ["да", "даю", "согласен", "согласна", "yes", "ok", "хорошо", "даю согласие", "передумал", "согласен дать"] + + if any(w in text_lower for w in consent_words): + await handle_consent_yes(user_id, conv_id) + else: + phones = config_reader.get_phones() + email = config_reader.get_site_mail() + phone_text = ", ".join(phone for phone, _ in phones) + + await max_api.send_message( + user_id, + f"Связаться с нами можно только по телефону {phone_text} " + f"или по почте {email}. " + f"Если хотите дать согласие на обработку данных — напишите «Даю согласие».", + attachments=return_to_consent_keyboard(), + ) diff --git a/max_bot/app/handlers/contact.py b/max_bot/app/handlers/contact.py index f580149..cfc0f8a 100644 --- a/max_bot/app/handlers/contact.py +++ b/max_bot/app/handlers/contact.py @@ -1,43 +1,77 @@ +import datetime import re -from app.settings_cache import SettingsCache -from app.conversation import get_or_create_user, get_template_rendered -from app.fsm import BotState, FSM +import logging +from sqlalchemy import select +from app.database import async_session +from app.models import BotUser, BotConversation, BotMessage +from app.max_api import max_api + +logger = logging.getLogger(__name__) + +PHONE_PATTERN = re.compile(r"^(\+7|8|7)?[\s\-]?\(?\d{3}\)?[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}$") +EMAIL_PATTERN = re.compile(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$") -async def handle_contact_provided(bot, user_data: dict, db, max_api): - max_user_id = user_data["user_id"] - user = await get_or_create_user(db, max_user_id) +async def handle_contact_received(user_id: int, conv_id: int, contact_text: str) -> None: + async with async_session() as db: + result = await db.execute(select(BotUser).where(BotUser.id == user_id)) + user = result.scalar_one_or_none() + if user: + if PHONE_PATTERN.match(contact_text): + user.phone = contact_text + elif EMAIL_PATTERN.match(contact_text): + user.phone = contact_text - phone = user_data.get("phone", "") - email = user_data.get("email", "") + result = await db.execute( + select(BotConversation).where(BotConversation.id == conv_id) + ) + conv = result.scalar_one_or_none() + if conv: + conv.state = "awaiting_inquiry" + if conv.intent == "unknown" or not conv.intent: + await _ask_inquiry(user_id, conv) + else: + await _ask_inquiry_with_context(user_id, conv) - if phone: - user.phone = phone - if email: - user.email = email - await db.flush() - - text = await get_template_rendered(db, "inquiry_request") - await max_api.send_message(max_user_id, text, format="markdown") - FSM.set_state(max_user_id, BotState.INQUIRY_REQUEST) + await db.commit() -async def handle_contact_text(bot, user_data: dict, db, max_api): - max_user_id = user_data["user_id"] - text = user_data.get("text", "").strip() - user = await get_or_create_user(db, max_user_id) +async def handle_manual_contact(user_id: int, conv_id: int, text: str) -> None: + text = text.strip() - phone_match = re.search(r'(\+?7[\s\-\(]?\d{3}[\s\-\)]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2})', text) - email_match = re.search(r'([\w.-]+@[\w.-]+\.\w+)', text) + async with async_session() as db: + msg = BotMessage( + conversation_id=conv_id, + direction="incoming", + text=text, + created_at=datetime.datetime.utcnow(), + ) + db.add(msg) + await db.commit() - if phone_match: - user.phone = phone_match.group(1) - if email_match: - user.email = email_match.group(1) + if PHONE_PATTERN.match(text) or EMAIL_PATTERN.match(text): + await handle_contact_received(user_id, conv_id, text) + else: + await max_api.send_message( + user_id, + "Пожалуйста, введите корректный номер телефона (например, +7 (861) 203-33-30) " + "или адрес электронной почты.", + ) - if phone_match or email_match: - await db.flush() - await handle_contact_provided(bot, user_data, db, max_api) - return - await max_api.send_message(max_user_id, "Пожалуйста, укажите номер телефона или email, чтобы мы могли с вами связаться.") +async def _ask_inquiry(user_id: int, conv: BotConversation) -> None: + await max_api.send_message( + user_id, + "Опишите, пожалуйста, ваш вопрос или задачу. " + "Расскажите подробнее, чем мы можем вам помочь.", + ) + + +async def _ask_inquiry_with_context(user_id: int, conv: BotConversation) -> None: + intent_label = "вопрос" if conv.intent == "question" else "заявку на услугу" if conv.intent == "ticket" else "обращение" + await max_api.send_message( + user_id, + f"Ранее вы упомянули, что хотите оставить {intent_label}. " + "Опишите подробнее суть вашего обращения, чтобы я могла передать " + "информацию специалисту.", + ) diff --git a/max_bot/app/handlers/features/__init__.py b/max_bot/app/handlers/features/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/max_bot/app/handlers/features/__pycache__/appointment.cpython-38.pyc b/max_bot/app/handlers/features/__pycache__/appointment.cpython-38.pyc deleted file mode 100644 index 35cca08..0000000 Binary files a/max_bot/app/handlers/features/__pycache__/appointment.cpython-38.pyc and /dev/null 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 deleted file mode 100644 index ec7198b..0000000 Binary files a/max_bot/app/handlers/features/__pycache__/commercial.cpython-38.pyc and /dev/null 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 deleted file mode 100644 index 32a6f38..0000000 Binary files a/max_bot/app/handlers/features/__pycache__/my_objects.cpython-38.pyc and /dev/null 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 deleted file mode 100644 index 6f7454a..0000000 Binary files a/max_bot/app/handlers/features/__pycache__/photo_report.cpython-38.pyc and /dev/null 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 deleted file mode 100644 index bc94260..0000000 Binary files a/max_bot/app/handlers/features/__pycache__/quality_rate.cpython-38.pyc and /dev/null 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 deleted file mode 100644 index d1a1009..0000000 Binary files a/max_bot/app/handlers/features/__pycache__/risk_score.cpython-38.pyc and /dev/null 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 deleted file mode 100644 index 6224889..0000000 Binary files a/max_bot/app/handlers/features/__pycache__/sla_status.cpython-38.pyc and /dev/null differ diff --git a/max_bot/app/handlers/features/appointment.py b/max_bot/app/handlers/features/appointment.py deleted file mode 100644 index 31aae79..0000000 --- a/max_bot/app/handlers/features/appointment.py +++ /dev/null @@ -1,105 +0,0 @@ -from datetime import datetime -from app.settings_cache import SettingsCache -from app.conversation import get_or_create_user, get_template_rendered -from app.fsm import BotState, FSM -from app.keyboards.inline import build_main_menu_buttons - -_DATE_KEY = "appt_date" -_SLOT_KEY = "appt_slot" - - -async def handle_appointment(bot, user_data: dict, db, max_api): - max_user_id = user_data["user_id"] - text = user_data.get("text", "").strip() - payload = user_data.get("payload", "") - state = FSM.get_state(max_user_id) - - if state != BotState.FEATURE_APPOINTMENT: - FSM.set_state(max_user_id, BotState.FEATURE_APPOINTMENT) - FSM.set_temp(max_user_id, {}) - await max_api.send_message( - max_user_id, - "📅 **Запись на визит**\n\nУкажите желаемую дату (ДД.ММ.ГГГГ):" - ) - return - - tmp = FSM.get_temp(max_user_id) - - if _DATE_KEY not in tmp: - try: - parsed = datetime.strptime(text.strip(), "%d.%m.%Y") - tmp[_DATE_KEY] = parsed.strftime("%Y-%m-%d") - FSM.set_temp(max_user_id, tmp) - - slots = _generate_slots() - msg_lines = [f"📅 **{parsed.strftime('%d.%m.%Y')}**\n\nДоступные слоты:\n"] - for i, slot in enumerate(slots, 1): - msg_lines.append(f"{i}. {slot}") - msg_lines.append("\nВведите номер слота (1-{}):".format(len(slots))) - await max_api.send_message(max_user_id, "\n".join(msg_lines), format="markdown") - except Exception: - await max_api.send_message(max_user_id, "Неверный формат даты. Укажите дату в формате ДД.ММ.ГГГГ") - return - - if _SLOT_KEY not in tmp: - slots = _generate_slots() - try: - idx = int(text.strip()) - 1 - if 0 <= idx < len(slots): - tmp[_SLOT_KEY] = slots[idx] - FSM.set_temp(max_user_id, tmp) - await _confirm_appointment(bot, user_data, db, max_api, tmp) - return - except (ValueError, IndexError): - pass - await max_api.send_message(max_user_id, f"Укажите номер слота (1-{len(slots)}):") - return - - await _confirm_appointment(bot, user_data, db, max_api, tmp) - - -def _generate_slots() -> list: - start_h = SettingsCache.get_int("work_hours_start", 9) - end_h = SettingsCache.get_int("work_hours_end", 18) - slots = [] - for h in range(start_h, end_h): - slots.append(f"{h:02d}:00-{h + 1:02d}:00") - return slots - - -async def _confirm_appointment(bot, user_data, db, max_api, tmp): - max_user_id = user_data["user_id"] - date_str = tmp.get(_DATE_KEY, "—") - slot_str = tmp.get(_SLOT_KEY, "—") - user = await get_or_create_user(db, max_user_id) - text = await get_template_rendered(db, "appointment_confirm", - date=f"{date_str} {slot_str}", - engineer="будет назначен") - await max_api.send_message(max_user_id, text, format="markdown") - - conv = await _create_appointment_conv(db, user.id, f"{date_str} {slot_str}") - from app.integrations import _1c_unf as ic_integration - await ic_integration.send_to_1c(db, conv) - - FSM.clear_temp(max_user_id) - FSM.transition(max_user_id, "done") - buttons = build_main_menu_buttons() - menu_text = await get_template_rendered(db, "main_menu") - await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons) - - -async def _create_appointment_conv(db, user_id, details): - from app.conversation import create_conversation, add_message - from sqlalchemy import select - from app.models.models import BotCategory - result = await db.execute( - select(BotCategory).where(BotCategory.name == "Обслуживание").limit(1) - ) - cat = result.scalar_one_or_none() - conv = await create_conversation(db, user_id) - conv.inquiry_text = f"Запись на визит: {details}" - conv.inquiry_type = cat.id if cat else None - conv.priority = "normal" - await db.flush() - await add_message(db, conv.id, "in", details) - return conv diff --git a/max_bot/app/handlers/features/broadcasts.py b/max_bot/app/handlers/features/broadcasts.py deleted file mode 100644 index 4d19b53..0000000 --- a/max_bot/app/handlers/features/broadcasts.py +++ /dev/null @@ -1,44 +0,0 @@ -from datetime import datetime -from app.settings_cache import SettingsCache -from app.conversation import get_or_create_user, get_template_rendered -from app.fsm import BotState, FSM - - -async def handle_broadcast_send(bot, user_data: dict, db, max_api): - from app.models.models import BotUser - from sqlalchemy import select - - text = user_data.get("text", "").strip() - if not text: - await max_api.send_message( - user_data["user_id"], - "Укажите текст рассылки в параметре broadcast_text." - ) - return - - result = await db.execute(select(BotUser).where(BotUser.consent_given == True)) - users = result.scalars().all() - sent = 0 - failed = 0 - sent_ids = [] - for u in users: - try: - await max_api.send_message(user_id=u.max_user_id, text=text, format="markdown") - sent += 1 - sent_ids.append(u.max_user_id) - except Exception: - failed += 1 - - from app.models.models import BotBroadcast - bc = BotBroadcast( - text=text, - sent_at=datetime.now(), - recipient_count=len(users), - recipient_ids=sent_ids, - status="sent", - ) - db.add(bc) - await db.flush() - - result = f"📨 Рассылка завершена.\nОтправлено: {sent}\nОшибок: {failed}" - await max_api.send_message(user_data["user_id"], result) diff --git a/max_bot/app/handlers/features/commercial.py b/max_bot/app/handlers/features/commercial.py deleted file mode 100644 index a5fb027..0000000 --- a/max_bot/app/handlers/features/commercial.py +++ /dev/null @@ -1,101 +0,0 @@ -from app.settings_cache import SettingsCache -from app.conversation import get_or_create_user, get_template_rendered -from app.fsm import BotState, FSM -from app.keyboards.inline import build_main_menu_buttons - -_STEPS = ["service_type", "scope", "address", "comment"] -_STEP_LABELS = { - "service_type": "Тип услуги (Видеонаблюдение, СКУД, Пожарная сигнализация, IT-инфраструктура, Обслуживание, Другое)", - "scope": "Объём работ (краткое описание)", - "address": "Адрес объекта", - "comment": "Дополнительные пожелания (или отправьте «-» для пропуска)", -} - -_COLLECT_KEY = "commercial_data" -_STEP_KEY = "commercial_step" - - -async def handle_commercial(bot, user_data: dict, db, max_api): - max_user_id = user_data["user_id"] - text = user_data.get("text", "").strip() - state = FSM.get_state(max_user_id) - - if state != BotState.FEATURE_COMMERCIAL: - FSM.set_state(max_user_id, BotState.FEATURE_COMMERCIAL) - FSM.set_temp(max_user_id, {_STEP_KEY: 0, _COLLECT_KEY: {}}) - await max_api.send_message( - max_user_id, - f"📄 **Коммерческое предложение**\n\nШаг 1/{len(_STEPS)}:\n{_STEP_LABELS['service_type']}" - ) - return - - tmp = FSM.get_temp(max_user_id) - step = tmp.get(_STEP_KEY, 0) - data = tmp.get(_COLLECT_KEY, {}) - - if step >= len(_STEPS): - await _finish_commercial(bot, user_data, db, max_api, data) - return - - step_key = _STEPS[step] - if not text and step_key != "comment": - await max_api.send_message(max_user_id, f"Пожалуйста, заполните поле:\n{_STEP_LABELS[step_key]}") - return - - data[step_key] = text if text else "" - step += 1 - - if step < len(_STEPS): - FSM.set_temp(max_user_id, {_STEP_KEY: step, _COLLECT_KEY: data}) - next_key = _STEPS[step] - await max_api.send_message( - max_user_id, - f"📄 Шаг {step + 1}/{len(_STEPS)}:\n{_STEP_LABELS[next_key]}" - ) - else: - await _finish_commercial(bot, user_data, db, max_api, data) - - -async def _finish_commercial(bot, user_data, db, max_api, data): - max_user_id = user_data["user_id"] - user = await get_or_create_user(db, max_user_id) - summary = ( - f"📄 **Коммерческое предложение**\n\n" - f"Услуга: {data.get('service_type', '—')}\n" - f"Объём: {data.get('scope', '—')}\n" - f"Адрес: {data.get('address', '—')}\n" - f"Комментарий: {data.get('comment', '—')}\n\n" - f"Спасибо, {user.first_name}! Мы подготовим КП и свяжемся с вами." - ) - await max_api.send_message(max_user_id, summary, format="markdown") - - conv_text = ( - f"Коммерческое предложение: услуга={data.get('service_type', '')}, " - f"объём={data.get('scope', '')}, адрес={data.get('address', '')}, " - f"комментарий={data.get('comment', '')}" - ) - conv = await _create_commercial_conv(db, user.id, conv_text) - from app.integrations import _1c_unf as ic_integration - await ic_integration.send_to_1c(db, conv) - - FSM.clear_temp(max_user_id) - FSM.transition(max_user_id, "done") - buttons = build_main_menu_buttons() - menu_text = await get_template_rendered(db, "main_menu") - await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons) - - -async def _create_commercial_conv(db, user_id, details): - from app.conversation import create_conversation, add_message - from sqlalchemy import select - from app.models.models import BotCategory - result = await db.execute( - select(BotCategory).where(BotCategory.name == "Консультация").limit(1) - ) - cat = result.scalar_one_or_none() - conv = await create_conversation(db, user_id) - conv.inquiry_text = details - conv.inquiry_type = cat.id if cat else None - await db.flush() - await add_message(db, conv.id, "in", details) - return conv diff --git a/max_bot/app/handlers/features/my_objects.py b/max_bot/app/handlers/features/my_objects.py deleted file mode 100644 index 0ef1dae..0000000 --- a/max_bot/app/handlers/features/my_objects.py +++ /dev/null @@ -1,39 +0,0 @@ -from app.settings_cache import SettingsCache -from app.conversation import get_or_create_user, get_template_rendered -from app.fsm import BotState, FSM -from app.keyboards.inline import build_main_menu_buttons - - -async def handle_my_objects(bot, user_data: dict, db, max_api): - max_user_id = user_data["user_id"] - text = user_data.get("text", "").strip() - - if FSM.get_state(max_user_id) != BotState.FEATURE_MY_OBJECTS: - FSM.set_state(max_user_id, BotState.FEATURE_MY_OBJECTS) - user = await get_or_create_user(db, max_user_id) - await max_api.send_message(max_user_id, - "🏢 **Мои объекты**\n\n" - "Укажите название вашей компании для поиска объектов." - ) - return - - if not text: - await max_api.send_message(max_user_id, "Укажите название компании.") - return - - from app.integrations.portal_db import get_objects_for_customer - objects = await get_objects_for_customer(db, text) - - if not objects: - await max_api.send_message(max_user_id, "Объекты не найдены. Проверьте название компании.") - else: - lines = [f"🏢 **{obj.name}** — {obj.status}" for obj in objects[:10]] - msg = "Ваши объекты:\n\n" + "\n".join(lines) - if len(objects) > 10: - msg += f"\n\n...и ещё {len(objects) - 10} объектов" - await max_api.send_message(max_user_id, msg, format="markdown") - - FSM.transition(max_user_id, "done") - buttons = build_main_menu_buttons() - menu_text = await get_template_rendered(db, "main_menu") - await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons) diff --git a/max_bot/app/handlers/features/notifications.py b/max_bot/app/handlers/features/notifications.py deleted file mode 100644 index 436b047..0000000 --- a/max_bot/app/handlers/features/notifications.py +++ /dev/null @@ -1,68 +0,0 @@ -from app.settings_cache import SettingsCache -from app.conversation import get_or_create_user, get_template_rendered -from app.fsm import BotState, FSM -from app.keyboards.inline import build_main_menu_buttons - - -async def handle_notifications(bot, user_data: dict, db, max_api): - max_user_id = user_data["user_id"] - payload = user_data.get("payload", "") - - if FSM.get_state(max_user_id) != BotState.FEATURE_NOTIFICATIONS: - FSM.set_state(max_user_id, BotState.FEATURE_NOTIFICATIONS) - user = await get_or_create_user(db, max_user_id) - from sqlalchemy import select - from app.models.models import BotNotificationSubscription - result = await db.execute( - select(BotNotificationSubscription).where( - BotNotificationSubscription.user_id == user.id - ).limit(1) - ) - sub = result.scalar_one_or_none() - status_change = "✅" if sub and sub.notify_on_status_change else "⬜" - reply = "✅" if sub and sub.notify_on_reply else "⬜" - broadcast = "✅" if sub and sub.notify_on_broadcast else "⬜" - text = ( - "🔔 **Уведомления**\n\n" - "Выберите, о чём уведомлять:\n\n" - f"{status_change} /status — Смена статуса обращения\n" - f"{reply} /reply — Ответ оператора\n" - f"{broadcast} /broadcast — Новости и рассылки\n\n" - "Нажмите на пункт, чтобы переключить." - ) - await max_api.send_message(max_user_id, text, format="markdown") - return - - user = await get_or_create_user(db, max_user_id) - from sqlalchemy import select - from app.models.models import BotNotificationSubscription - result = await db.execute( - select(BotNotificationSubscription).where( - BotNotificationSubscription.user_id == user.id - ).limit(1) - ) - sub = result.scalar_one_or_none() - if not sub: - sub = BotNotificationSubscription(user_id=user.id) - db.add(sub) - await db.flush() - - toggle_map = { - "/status": "notify_on_status_change", - "/reply": "notify_on_reply", - "/broadcast": "notify_on_broadcast", - } - key = payload if payload.startswith("/") else (payload if payload in toggle_map else user_data.get("text", "").strip()) - if key in toggle_map: - col = toggle_map[key] - current = getattr(sub, col) - setattr(sub, col, not current) - await db.flush() - await max_api.send_message(max_user_id, f"Настройка «{key}» изменена.") - else: - await max_api.send_message(max_user_id, "Нажмите /status, /reply или /broadcast для переключения.") - - FSM.transition(max_user_id, "done") - buttons = build_main_menu_buttons() - menu_text = await get_template_rendered(db, "main_menu") - await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons) diff --git a/max_bot/app/handlers/features/photo_report.py b/max_bot/app/handlers/features/photo_report.py deleted file mode 100644 index 6669dae..0000000 --- a/max_bot/app/handlers/features/photo_report.py +++ /dev/null @@ -1,69 +0,0 @@ -from app.settings_cache import SettingsCache -from app.conversation import get_or_create_user, get_open_conversation, create_conversation, add_message, get_template_rendered -from app.fsm import BotState, FSM -from app.keyboards.inline import build_main_menu_buttons - - -async def handle_photo_report(bot, user_data: dict, db, max_api): - max_user_id = user_data["user_id"] - text = user_data.get("text", "").strip() - - if FSM.get_state(max_user_id) != BotState.FEATURE_PHOTO_REPORT: - FSM.set_state(max_user_id, BotState.FEATURE_PHOTO_REPORT) - await max_api.send_message(max_user_id, - "📸 **Фото-отчёт**\n\n" - "Отправьте фото проблемы и опишите, что произошло.\n" - "Вы можете прикрепить документ (PDF, DOCX, XLSX, TXT)." - ) - return - - user = await get_or_create_user(db, max_user_id) - conv = await get_open_conversation(db, user.id) - if not conv: - conv = await create_conversation(db, user.id) - - has_attachment = user_data.get("has_attachment", False) - attachment_type = user_data.get("attachment_type", "") - attachment_path = user_data.get("attachment_path", "") - - conv.inquiry_text = text or "Фото-отчёт" - conv.has_attachment = has_attachment - conv.attachment_type = attachment_type - conv.attachment_path = attachment_path - conv.priority = "high" - await db.flush() - - await add_message(db, conv.id, "in", text, has_attachment, attachment_type, attachment_path) - - ooh = not SettingsCache.is_work_hours() - if ooh: - start = SettingsCache.get_int("work_hours_start", 9) - end = SettingsCache.get_int("work_hours_end", 18) - ooh_text = await get_template_rendered(db, "out_of_hours", - work_hours_start=start, work_hours_end=end) - await max_api.send_message(max_user_id, ooh_text) - - text = await get_template_rendered(db, "confirmation_ooh" if ooh else "confirmation", - first_name=user.first_name, conv_id=conv.id) - await max_api.send_message(max_user_id, text, format="markdown") - - from app.integrations import _1c_unf as ic_integration - await ic_integration.send_to_1c(db, conv) - - from app.models.models import Incident - incident = Incident( - object_id=0, - reported_by=None, - title=f"Фото-отчёт от {user.first_name or 'клиента'}", - description=text or "Фото-отчёт через бота", - severity="P3", - status="open", - ) - db.add(incident) - await db.flush() - conv.attachment_path = f"incident_{incident.id}" - - FSM.transition(max_user_id, "done") - buttons = build_main_menu_buttons() - menu_text = await get_template_rendered(db, "main_menu") - await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons) diff --git a/max_bot/app/handlers/features/quality_rate.py b/max_bot/app/handlers/features/quality_rate.py deleted file mode 100644 index 35782fc..0000000 --- a/max_bot/app/handlers/features/quality_rate.py +++ /dev/null @@ -1,35 +0,0 @@ -from app.settings_cache import SettingsCache -from app.conversation import get_or_create_user, get_open_conversation, get_template_rendered -from app.keyboards.inline import build_quality_buttons, build_main_menu_buttons -from app.fsm import BotState, FSM - - -async def handle_quality_rate(bot, user_data: dict, db, max_api): - max_user_id = user_data["user_id"] - payload = user_data.get("payload", "") - - if FSM.get_state(max_user_id) != BotState.FEATURE_QUALITY_RATE: - FSM.set_state(max_user_id, BotState.FEATURE_QUALITY_RATE) - conv = await get_open_conversation(db, (await get_or_create_user(db, max_user_id)).id) - conv_id = conv.id if conv else "—" - text = await get_template_rendered(db, "quality_request", conv_id=conv_id) - buttons = build_quality_buttons() - await max_api.send_message_with_keyboard(max_user_id, text, buttons) - return - - if payload.startswith("rate_"): - try: - rating = int(payload.replace("rate_", "")) - except ValueError: - return - await max_api.send_message(max_user_id, f"Спасибо за оценку: {rating}/5! Ваш отзыв важен для нас.") - FSM.transition(max_user_id, "done") - buttons = build_main_menu_buttons() - menu_text = await get_template_rendered(db, "main_menu") - await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons) - return - - FSM.transition(max_user_id, "done") - buttons = build_main_menu_buttons() - menu_text = await get_template_rendered(db, "main_menu") - await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons) diff --git a/max_bot/app/handlers/features/reminders.py b/max_bot/app/handlers/features/reminders.py deleted file mode 100644 index afee5e7..0000000 --- a/max_bot/app/handlers/features/reminders.py +++ /dev/null @@ -1,48 +0,0 @@ -from app.settings_cache import SettingsCache -from app.conversation import get_or_create_user, get_template_rendered -from app.fsm import BotState, FSM -from app.keyboards.inline import build_main_menu_buttons - - -async def handle_reminders(bot, user_data: dict, db, max_api): - max_user_id = user_data["user_id"] - text = user_data.get("text", "").strip() - - if FSM.get_state(max_user_id) != BotState.FEATURE_REMINDERS: - FSM.set_state(max_user_id, BotState.FEATURE_REMINDERS) - user = await get_or_create_user(db, max_user_id) - phone = user.phone or "" - - from sqlalchemy import select, func - from app.models.models import BotConversation, BotTicket, Customer - - open_convs = await db.execute( - select(func.count(BotConversation.id)) - .where(BotConversation.status.in_(["open", "handoff"])) - ) - total_open = open_convs.scalar() or 0 - - open_tickets = await db.execute( - select(func.count(BotTicket.id)) - .where(BotTicket.status.in_(["open", "in_progress"])) - ) - total_tickets = open_tickets.scalar() or 0 - - my_convs = await db.execute( - select(func.count(BotConversation.id)) - .where(BotConversation.user_id == user.id, BotConversation.status.in_(["open", "handoff"])) - ) - my_open = my_convs.scalar() or 0 - - text = ( - "🔔 **Напоминания**\n\n" - f"У вас открыто обращений: {my_open}\n" - f"Всего в системе: {total_open} обращений, {total_tickets} заявок\n\n" - "Напоминания о плановом ТО настраиваются в портале." - ) - await max_api.send_message(max_user_id, text, format="markdown") - FSM.transition(max_user_id, "done") - buttons = build_main_menu_buttons() - menu_text = await get_template_rendered(db, "main_menu") - await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons) - return diff --git a/max_bot/app/handlers/features/risk_score.py b/max_bot/app/handlers/features/risk_score.py deleted file mode 100644 index f294715..0000000 --- a/max_bot/app/handlers/features/risk_score.py +++ /dev/null @@ -1,100 +0,0 @@ -from sqlalchemy import select -from app.settings_cache import SettingsCache -from app.conversation import get_or_create_user, get_template_rendered -from app.fsm import BotState, FSM -from app.keyboards.inline import build_main_menu_buttons - -_ANSWERS_KEY = "risk_answers" -_STEP_KEY = "risk_step" - - -async def _load_questions(db): - from app.models.models import BotRiskQuestion - result = await db.execute( - select(BotRiskQuestion) - .where(BotRiskQuestion.is_active == True) - .order_by(BotRiskQuestion.sort_order) - ) - return result.scalars().all() - - -async def handle_risk_score(bot, user_data: dict, db, max_api): - max_user_id = user_data["user_id"] - text = user_data.get("text", "").strip() - state = FSM.get_state(max_user_id) - - questions = await _load_questions(db) - if not questions: - await max_api.send_message(max_user_id, "Анкета риск-инжиниринга временно недоступна.") - FSM.transition(max_user_id, "cancel") - buttons = build_main_menu_buttons() - menu_text = await get_template_rendered(db, "main_menu") - await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons) - return - - if state != BotState.FEATURE_RISK_SCORE: - FSM.set_state(max_user_id, BotState.FEATURE_RISK_SCORE) - FSM.set_temp(max_user_id, {_STEP_KEY: 0, _ANSWERS_KEY: {}}) - q = questions[0] - await max_api.send_message( - max_user_id, - f"📊 **Risk Score — анкета**\n\nВопрос 1/{len(questions)}:\n{q.question}\n\nОтветьте «да» или «нет»." - ) - return - - tmp = FSM.get_temp(max_user_id) - step = tmp.get(_STEP_KEY, 0) - answers = tmp.get(_ANSWERS_KEY, {}) - - if step >= len(questions): - await _finish_risk_score(max_user_id, answers, questions, db, max_api) - return - - if text.lower() in ("да", "yes", "+", "1"): - answers[questions[step].question_key] = True - elif text.lower() in ("нет", "no", "-", "0"): - answers[questions[step].question_key] = False - else: - await max_api.send_message(max_user_id, "Пожалуйста, ответьте «да» или «нет».") - return - - step += 1 - if step < len(questions): - q = questions[step] - FSM.set_temp(max_user_id, {_STEP_KEY: step, _ANSWERS_KEY: answers}) - await max_api.send_message( - max_user_id, - f"📊 Вопрос {step + 1}/{len(questions)}:\n{q.question}\n\n«да» или «нет»?" - ) - else: - await _finish_risk_score(max_user_id, answers, questions, db, max_api) - - -async def _finish_risk_score(max_user_id, answers, questions, db, max_api): - score = 0 - for q in questions: - if answers.get(q.question_key): - score += q.weight - score = min(score, 100) - - if score < 20: - label = "Низкий" - rec = "Поддерживайте текущий уровень обслуживания" - elif score < 50: - label = "Средний" - rec = "Рекомендуем провести аудит систем" - elif score < 75: - label = "Высокий" - rec = "Необходимо срочное обслуживание" - else: - label = "Критический" - rec = "Требуется немедленное вмешательство" - - tmpl = await get_template_rendered(db, "risk_result", score=score, label=label, recommendation=rec) - await max_api.send_message(max_user_id, tmpl, format="markdown") - - FSM.clear_temp(max_user_id) - FSM.transition(max_user_id, "done") - buttons = build_main_menu_buttons() - menu_text = await get_template_rendered(db, "main_menu") - await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons) diff --git a/max_bot/app/handlers/features/sla_status.py b/max_bot/app/handlers/features/sla_status.py deleted file mode 100644 index 8d3376d..0000000 --- a/max_bot/app/handlers/features/sla_status.py +++ /dev/null @@ -1,54 +0,0 @@ -from app.settings_cache import SettingsCache -from app.conversation import get_or_create_user, get_template_rendered -from app.fsm import BotState, FSM -from app.keyboards.inline import build_main_menu_buttons - - -async def handle_sla_status(bot, user_data: dict, db, max_api): - max_user_id = user_data["user_id"] - text = user_data.get("text", "").strip() - - if FSM.get_state(max_user_id) != BotState.FEATURE_SLA_STATUS: - FSM.set_state(max_user_id, BotState.FEATURE_SLA_STATUS) - await max_api.send_message(max_user_id, - "🔍 **Проверка SLA статуса**\n\n" - "Укажите номер или название объекта." - ) - return - - if not text: - await max_api.send_message(max_user_id, "Укажите номер или название объекта.") - return - - from app.integrations.portal_db import get_object_by_name_or_id, get_sla_for_object, get_last_incident_for_object, get_active_tasks_for_object - obj = await get_object_by_name_or_id(db, text) - if not obj: - await max_api.send_message(max_user_id, "Объект не найден.") - FSM.transition(max_user_id, "done") - buttons = build_main_menu_buttons() - menu_text = await get_template_rendered(db, "main_menu") - await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons) - return - - sla = await get_sla_for_object(db, obj.id) - if not sla: - text = await get_template_rendered(db, "sla_not_found") - await max_api.send_message(max_user_id, text) - else: - level_map = {"start": "Базовый", "business": "Оптимальный", "enterprise": "Максимальный"} - last_incident = await get_last_incident_for_object(db, obj.id) - open_tasks = await get_active_tasks_for_object(db, obj.id) - last_visit = last_incident.created_at.strftime("%d.%m.%Y") if last_incident else "—" - tasks_count = len(open_tasks) - text = await get_template_rendered(db, "sla_found", - object_name=obj.name, - service_level=level_map.get(sla.service_level, sla.service_level), - status=sla.status, - price=sla.sla_price_monthly or 0) - text += f"\n📅 Последний визит: {last_visit}\n📋 Открытых задач: {tasks_count}" - await max_api.send_message(max_user_id, text, format="markdown") - - FSM.transition(max_user_id, "done") - buttons = build_main_menu_buttons() - menu_text = await get_template_rendered(db, "main_menu") - await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons) diff --git a/max_bot/app/handlers/features/sla_ticket.py b/max_bot/app/handlers/features/sla_ticket.py deleted file mode 100644 index d5189f0..0000000 --- a/max_bot/app/handlers/features/sla_ticket.py +++ /dev/null @@ -1,155 +0,0 @@ -from app.settings_cache import SettingsCache -from app.conversation import get_or_create_user, get_template_rendered -from app.fsm import BotState, FSM -from app.keyboards.inline import build_main_menu_buttons - - -async def handle_sla_ticket(bot, user_data: dict, db, max_api): - max_user_id = user_data["user_id"] - text = user_data.get("text", "").strip() - - if FSM.get_state(max_user_id) != BotState.SLA_TICKET_CREATE: - FSM.set_state(max_user_id, BotState.SLA_TICKET_CREATE) - await max_api.send_message(max_user_id, "🔧 **Заявка по SLA**\n\nПроверяю ваш SLA контракт...") - - user = await get_or_create_user(db, max_user_id) - phone = user.phone or "" - if not phone: - await max_api.send_message(max_user_id, "Не могу найти ваш номер телефона. Укажите его, пожалуйста.") - return - - from sqlalchemy import select - from app.models.models import Customer, Object, SLAContract - result = await db.execute( - select(Customer).where(Customer.contact_phone.ilike(f"%{phone[-10:]}%")).limit(1) - ) - customer = result.scalar_one_or_none() - if not customer: - await _send_no_sla(bot, user_data, db, max_api) - return - - result = await db.execute( - select(Object).where(Object.customer_id == customer.id, Object.status == "active").limit(1) - ) - obj = result.scalar_one_or_none() - if not obj: - await _send_no_sla(bot, user_data, db, max_api) - return - - result = await db.execute( - select(SLAContract).where( - SLAContract.object_id == obj.id, - SLAContract.status == "active" - ).order_by(SLAContract.created_at.desc()).limit(1) - ) - sla = result.scalar_one_or_none() - if not sla: - await _send_no_sla(bot, user_data, db, max_api) - return - - level_map = {"start": "Базовый", "business": "Оптимальный", "enterprise": "Максимальный"} - text = await get_template_rendered(db, "sla_ticket_found", - object_name=obj.name, - service_level=level_map.get(sla.service_level, sla.service_level)) - await max_api.send_message(max_user_id, text, format="markdown") - return - - if not text: - await max_api.send_message(max_user_id, "Опишите неисправность. Вы можете прикрепить фото или документ.") - return - - user = await get_or_create_user(db, max_user_id) - - from sqlalchemy import select, func - from app.models.models import Customer, Object, SLAContract, BotTicket - result = await db.execute( - select(Customer).where(Customer.contact_phone.ilike(f"%{user.phone[-10:]}%")).limit(1) - ) - customer = result.scalar_one_or_none() - obj = None - sla = None - if customer: - result = await db.execute( - select(Object).where(Object.customer_id == customer.id, Object.status == "active").limit(1) - ) - obj = result.scalar_one_or_none() - if obj: - result = await db.execute( - select(SLAContract).where( - SLAContract.object_id == obj.id, SLAContract.status == "active" - ).order_by(SLAContract.created_at.desc()).limit(1) - ) - sla = result.scalar_one_or_none() - - priority_map = {"start": "low", "business": "normal", "enterprise": "high"} - response_map = {"start": "4 часа", "business": "2 часа", "enterprise": "30 минут"} - sla_level = sla.service_level if sla else "start" - priority = priority_map.get(sla_level, "normal") - - result = await db.execute(select(func.count(BotTicket.id))) - ticket_num = f"T-{result.scalar() + 1:04d}" - - from app.models.models import BotTicket, BotTicketMessage, BotTicketStatus - ticket = BotTicket( - ticket_number=ticket_num, - user_id=user.id, - object_id=obj.id if obj else None, - customer_id=customer.id if customer else None, - title=text[:100], - description=text, - priority=priority, - status="open", - created_by="bot", - sla_contract_id=sla.id if sla else None, - has_attachment=user_data.get("has_attachment", False), - attachment_type=user_data.get("attachment_type", ""), - attachment_path=user_data.get("attachment_path", ""), - ) - db.add(ticket) - await db.flush() - - db.add(BotTicketMessage(ticket_id=ticket.id, direction="in", text=text, sender="client")) - db.add(BotTicketStatus(ticket_id=ticket.id, old_status="", new_status="open", changed_by="bot")) - await db.flush() - - has_attachment = user_data.get("has_attachment", False) - attachment_type = user_data.get("attachment_type", "") - attachment_path = user_data.get("attachment_path", "") - if has_attachment: - ticket.has_attachment = True - ticket.attachment_type = attachment_type - ticket.attachment_path = attachment_path - await db.flush() - - text = await get_template_rendered(db, "sla_ticket_created", - ticket_number=ticket_num, - priority=priority, - response_time=response_map.get(sla_level, "2 часа")) - await max_api.send_message(max_user_id, text, format="markdown") - - from app.max_notifications import notify_ticket_created as nt - await nt(db, ticket, max_api) - - FSM.transition(max_user_id, "done") - buttons = build_main_menu_buttons() - menu_text = await get_template_rendered(db, "main_menu") - await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons) - - -async def _send_no_sla(bot, user_data, db, max_api): - max_user_id = user_data["user_id"] - await SettingsCache.refresh(db) - phone_1 = SettingsCache.get("phone_1") - phone_2 = SettingsCache.get("phone_2") - email = SettingsCache.get("support_email") - text = await get_template_rendered(db, "sla_ticket_not_found", - phone_1=phone_1, phone_2=phone_2, - support_email=email) - from app.keyboards.inline import build_phone_email_buttons - subject = SettingsCache.get("email_subject") - buttons = build_phone_email_buttons(phone_1, phone_2, email, subject) - await max_api.send_message_with_keyboard(max_user_id, text, buttons, format="markdown") - FSM.transition(max_user_id, "done") - buttons = build_main_menu_buttons() - menu_text = await get_template_rendered(db, "main_menu") - await max_api.send_message_with_keyboard(max_user_id, menu_text, buttons) diff --git a/max_bot/app/handlers/greeting.py b/max_bot/app/handlers/greeting.py index 5c43003..83ae4ad 100644 --- a/max_bot/app/handlers/greeting.py +++ b/max_bot/app/handlers/greeting.py @@ -1,33 +1,270 @@ -from app.settings_cache import SettingsCache -from app.conversation import get_or_create_user, get_open_conversation, get_template_rendered -from app.keyboards.inline import build_main_menu_buttons -from app.fsm import BotState, FSM +import logging +import datetime +from typing import Optional +from sqlalchemy import select +from app.database import async_session +from app.models import BotUser, BotConversation, BotMessage, BotKnowledgeBase, BotCategory +from app.max_api import max_api +from app.yandex_gpt import yandex_gpt +from app.config_reader import config_reader +from app.settings_cache import settings_cache +from app.keyboards import consent_keyboard, return_to_consent_keyboard + +logger = logging.getLogger(__name__) -async def handle_start(bot, user_data: dict, db, max_api): - max_user_id = user_data["user_id"] - first_name = user_data.get("first_name", "") - last_name = user_data.get("last_name", "") - username = user_data.get("username", "") +async def handle_greeting( + user_id: int, + text: Optional[str] = None, + first_name: str = "", + last_name: str = "", + username: str = "", +) -> None: + assistant_name = await settings_cache.get("assistant_name", "София") - user = await get_or_create_user(db, max_user_id, first_name, last_name, username) - await db.flush() + greeting_text = ( + f"Здравствуйте! Я {assistant_name}, помощник AegisOne Engineering. " + f"Чем я могу быть вам полезна?" + ) - await SettingsCache.refresh(db) + await max_api.send_message(user_id, greeting_text) - open_conv = await get_open_conversation(db, user.id) - name = SettingsCache.get_assistant_name() + async with async_session() as db: + existing = await db.execute( + select(BotUser).where(BotUser.id == user_id) + ) + user = existing.scalar_one_or_none() - if open_conv: - text = await get_template_rendered(db, "greeting_returning", - first_name=user.first_name or first_name, - conv_id=open_conv.id) - buttons = build_main_menu_buttons() - await max_api.send_message_with_keyboard(max_user_id, text, buttons, format="markdown") - FSM.set_state(max_user_id, BotState.IDLE) + now = datetime.datetime.utcnow() + if not user: + user = BotUser( + id=user_id, + first_name=first_name, + last_name=last_name, + username=username, + last_interaction=now, + total_conversations=1, + created_at=now, + ) + db.add(user) + else: + if first_name: + user.first_name = first_name + if last_name: + user.last_name = last_name + if username: + user.username = username + user.last_interaction = now + user.total_conversations = (user.total_conversations or 0) + 1 + + await db.commit() + await db.refresh(user) + + conv = BotConversation( + user_id=user_id, + state="analyzing", + created_at=now, + ) + db.add(conv) + await db.commit() + await db.refresh(conv) + + if text: + await analyze_and_respond(user_id, conv.id, text) + else: + async with async_session() as db: + conv.state = "awaiting_input" + await db.commit() + + +async def handle_message(user_id: int, text: str) -> None: + async with async_session() as db: + result = await db.execute( + select(BotConversation) + .where(BotConversation.user_id == user_id) + .order_by(BotConversation.id.desc()) + .limit(1) + ) + conv = result.scalar_one_or_none() + + if not conv: + await handle_greeting(user_id, text) + return + + msg = BotMessage( + conversation_id=conv.id, + direction="incoming", + text=text, + created_at=datetime.datetime.utcnow(), + ) + db.add(msg) + await db.commit() + + state = conv.state if conv else "greeting" + + if state in ("greeting", "analyzing", "awaiting_input"): + await analyze_and_respond(user_id, conv.id, text) + elif state == "awaiting_consent": + from app.handlers.consent import handle_consent_response + await handle_consent_response(user_id, conv.id, text) + elif state == "consent_refused": + from app.handlers.consent import handle_refused_again + await handle_refused_again(user_id, conv.id, text) + elif state == "awaiting_contact": + from app.handlers.contact import handle_manual_contact + await handle_manual_contact(user_id, conv.id, text) + elif state == "awaiting_inquiry": + from app.handlers.inquiry import handle_inquiry + await handle_inquiry(user_id, conv.id, text) + elif state in ("completed", "escalated"): + await max_api.send_message( + user_id, + "Ваше обращение уже передано специалисту. " + "Если у вас новый вопрос, напишите его, и я помогу.", + ) + + +async def _get_user_history_context(user_id: int) -> str: + from app.models import BotTicket + async with async_session() as db: + recent_msgs = await db.execute( + select(BotMessage) + .join(BotConversation) + .where(BotConversation.user_id == user_id) + .order_by(BotMessage.id.desc()) + .limit(10) + ) + messages = list(reversed(recent_msgs.scalars().all())) + + open_tickets = await db.execute( + select(BotTicket) + .where(BotTicket.user_id == user_id) + .where(BotTicket.status != "Закрыта") + .order_by(BotTicket.id.desc()) + ) + tickets = open_tickets.scalars().all() + + parts = [] + if messages: + msg_lines = [] + for m in messages: + who = "Клиент" if m.direction == "incoming" else "Бот" + msg_lines.append(f"{who}: {m.text or ''}") + parts.append("Последние сообщения из прошлых диалогов:\n" + "\n".join(msg_lines)) + + if tickets: + ticket_lines = [] + for t in tickets: + ticket_lines.append(f" - Заявка #{t.id}: {t.title} (статус: {t.status})") + parts.append("Открытые заявки пользователя:\n" + "\n".join(ticket_lines)) + + return "\n\n".join(parts) + + +async def analyze_and_respond(user_id: int, conv_id: int, text: str) -> None: + gpt_available = await yandex_gpt.is_available() + + if not gpt_available: + await _limited_mode(user_id, conv_id) return - text = await get_template_rendered(db, "greeting", name=name) - buttons = build_main_menu_buttons() - await max_api.send_message_with_keyboard(max_user_id, text, buttons, format="markdown") - FSM.set_state(max_user_id, BotState.IDLE) + history_context = await _get_user_history_context(user_id) + intent = await yandex_gpt.analyze_intent(text, history_context) + + async with async_session() as db: + result = await db.execute( + select(BotConversation).where(BotConversation.id == conv_id) + ) + conv = result.scalar_one_or_none() + if not conv: + return + conv.intent = intent + + if intent == "question": + kb_context = await _get_knowledge_base_context(text) + answer = await yandex_gpt.generate_answer(text, kb_context, history_context) + if answer: + await max_api.send_message(user_id, answer) + else: + await _send_contacts(user_id) + conv.state = "completed" + + elif intent == "ticket": + conv.state = "awaiting_consent" + consent_text = ( + "Для обработки Вашего запроса нам нужны ваши контактные данные " + "для обратной связи. Даёте согласие на обработку " + "персональных данных?" + ) + await max_api.send_message( + user_id, consent_text, attachments=consent_keyboard() + ) + + else: + clarification = await yandex_gpt.clarify_intent(text, history_context) + await max_api.send_message(user_id, clarification) + conv.state = "awaiting_consent" + after_text = ( + "\n\nДля обработки Вашего запроса нам нужны ваши контактные данные " + "для обратной связи. Даёте согласие на обработку " + "персональных данных?" + ) + await max_api.send_message( + user_id, after_text, attachments=consent_keyboard() + ) + + await db.commit() + + +async def _limited_mode(user_id: int, conv_id: int) -> None: + async with async_session() as db: + result = await db.execute( + select(BotConversation).where(BotConversation.id == conv_id) + ) + conv = result.scalar_one_or_none() + if conv: + conv.state = "completed" + await db.commit() + + await _send_contacts(user_id) + + +async def _send_contacts(user_id: int) -> None: + phones = config_reader.get_phones() + email = config_reader.get_site_mail() + + phone_text = "\n".join( + f"📞 {phone} — [позвонить](tel:{link})" + for phone, link in phones + ) + + message = ( + "Вы можете связаться с нами:\n\n" + f"{phone_text}\n" + f"📧 [Написать на почту](mailto:{email})\n\n" + f"Или написать на почту: {email}" + ) + + await max_api.send_message(user_id, message, format="markdown") + + +async def _get_knowledge_base_context(query: str) -> str: + async with async_session() as db: + result = await db.execute( + select(BotKnowledgeBase) + .where(BotKnowledgeBase.is_active == True) + .order_by(BotKnowledgeBase.sort_order) + ) + cards = result.scalars().all() + + if not cards: + return "" + + parts = [] + for card in cards: + keywords = " ".join(card.keywords) if card.keywords else "" + answer = card.answer or "Нет ответа" + cat_name = card.category.name if card.category else "Общее" + parts.append(f"[{cat_name}] {card.question}: {answer} (ключевые слова: {keywords})") + + return "\n\n".join(parts[:20]) diff --git a/max_bot/app/handlers/handoff.py b/max_bot/app/handlers/handoff.py index 2c3dc45..8168996 100644 --- a/max_bot/app/handlers/handoff.py +++ b/max_bot/app/handlers/handoff.py @@ -1,32 +1,92 @@ -from app.settings_cache import SettingsCache -from app.conversation import get_or_create_user, get_open_conversation, get_template_rendered -from app.fsm import BotState, FSM +import datetime +import logging +from sqlalchemy import select +from app.database import async_session +from app.models import BotConversation, BotMessage +from app.max_api import max_api +from app.keyboards import consent_keyboard + +logger = logging.getLogger(__name__) -async def handle_handoff_request(bot, user_data: dict, db, max_api): - max_user_id = user_data["user_id"] - user = await get_or_create_user(db, max_user_id) +async def handle_callback(user_id: int, callback_id: str, callback_data: dict) -> None: + async with async_session() as db: + result = await db.execute( + select(BotConversation) + .where(BotConversation.user_id == user_id) + .order_by(BotConversation.id.desc()) + .limit(1) + ) + conv = result.scalar_one_or_none() - if not SettingsCache.is_work_hours(): - from app.handlers.out_of_hours import handle_out_of_hours - await handle_out_of_hours(bot, user_data, db, max_api) - return + conv_id = conv.id if conv else 0 - text = await get_template_rendered(db, "handoff") - await max_api.send_message(max_user_id, text) - - conv = await get_open_conversation(db, user.id) - if conv: - conv.status = "handoff" - conv.priority = "high" - await db.flush() - - from app.integrations import _1c_unf as ic_integration - await ic_integration.send_to_1c(db, conv) - - FSM.set_state(max_user_id, BotState.HANDOFF_REQUEST) + if callback_id.startswith("consent_"): + from app.handlers.consent import handle_consent_callback + await handle_consent_callback(user_id, conv_id, callback_id) + else: + await max_api.send_message( + user_id, + "Извините, я не распознала действие. Пожалуйста, воспользуйтесь кнопками.", + ) -async def handle_inquiry_fallback(bot, user_data, db, max_api): - from app.handlers.inquiry import handle_inquiry - await handle_inquiry(bot, user_data, db, max_api) +async def handle_contact_share( + user_id: int, + conv_id: int, + phone: str, + vcf_info: str = "", + vcf_data: dict = None, + contact_hash: str = "", +) -> None: + if vcf_data is None: + vcf_data = {} + async with async_session() as db: + from app.models import BotUser + user_result = await db.execute(select(BotUser).where(BotUser.id == user_id)) + user = user_result.scalar_one_or_none() + if user: + user.phone = phone or user.phone + if vcf_data.get("first_name"): + user.first_name = vcf_data["first_name"] + if vcf_data.get("last_name"): + user.last_name = vcf_data["last_name"] + if vcf_data.get("patronymic"): + user.patronymic = vcf_data["patronymic"] + if vcf_data.get("email"): + user.email = vcf_data["email"] + if vcf_data.get("organization"): + user.organization = vcf_data["organization"] + if vcf_data.get("address"): + user.address = vcf_data["address"] + if vcf_info: + user.vcf_raw = vcf_info + if contact_hash: + user.contact_hash = contact_hash + user.phone_verified = True + if phone: + user.phone_verified = True + + msg = BotMessage( + conversation_id=conv_id, + direction="incoming", + text=f"[Контакт поделен] {phone or vcf_data.get('phone', '')}", + created_at=datetime.datetime.utcnow(), + ) + db.add(msg) + + conv_result = await db.execute( + select(BotConversation).where(BotConversation.id == conv_id) + ) + conv = conv_result.scalar_one_or_none() + if conv: + conv.state = "awaiting_inquiry" + + await db.commit() + + await max_api.send_message( + user_id, + "Спасибо! Ваш контакт получен.\n\n" + "Опишите, пожалуйста, суть вашего обращения — " + "расскажите подробнее, чем мы можем вам помочь.", + ) diff --git a/max_bot/app/handlers/inquiry.py b/max_bot/app/handlers/inquiry.py index 2e6bfd7..68338a7 100644 --- a/max_bot/app/handlers/inquiry.py +++ b/max_bot/app/handlers/inquiry.py @@ -1,122 +1,128 @@ -from app.settings_cache import SettingsCache -from app.conversation import ( - get_or_create_user, get_open_conversation, create_conversation, - add_message, auto_categorize_text, get_template_rendered -) -from app.integrations.yandex_gpt import categorize_with_gpt -from app.keyboards.inline import build_category_buttons -from app.fsm import BotState, FSM +import datetime +import logging +from sqlalchemy import select +from app.database import async_session +from app.models import BotUser, BotConversation, BotMessage, BotTicket, BotTicketMessage, BotTicketStatus +from app.max_api import max_api +from app.settings_cache import settings_cache +from app.config_reader import config_reader +from app.email_sender import email_sender + +logger = logging.getLogger(__name__) -async def handle_inquiry(bot, user_data: dict, db, max_api): - max_user_id = user_data["user_id"] - text = user_data.get("text", "").strip() - user = await get_or_create_user(db, max_user_id) +async def handle_inquiry(user_id: int, conv_id: int, text: str) -> None: + async with async_session() as db: + result = await db.execute( + select(BotConversation).where(BotConversation.id == conv_id) + ) + conv = result.scalar_one_or_none() + if not conv: + return - if not text: - await max_api.send_message(max_user_id, "Пожалуйста, опишите ваш вопрос.") - return + user_result = await db.execute(select(BotUser).where(BotUser.id == user_id)) + user = user_result.scalar_one_or_none() - conv = await get_open_conversation(db, user.id) - if not conv: - conv = await create_conversation(db, user.id) + conv.inquiry_text = text + conv.state = "escalated" + await db.commit() - conv.inquiry_text = text - - has_attachment = user_data.get("has_attachment", False) - attachment_type = user_data.get("attachment_type", "") - attachment_path_str = user_data.get("attachment_path", "") - if has_attachment: - conv.has_attachment = True - conv.attachment_type = attachment_type - conv.attachment_path = attachment_path_str - - await add_message(db, conv.id, "in", text, has_attachment, attachment_type, attachment_path_str) - - if has_attachment and attachment_path_str and max_api: - try: - file_url = await max_api.get_file_url(attachment_path_str) - if file_url: - import os - import aiohttp - from app.config import get_settings - from app.models.models import FileSyncQueue - settings = get_settings() - local_dir = settings.UPLOADS_DIR - os.makedirs(local_dir, exist_ok=True) - local_filename = f"conv_{conv.id}_{int(datetime.now().timestamp())}_{attachment_type}" - local_path = os.path.join(local_dir, local_filename) - async with aiohttp.ClientSession() as session: - async with session.get(file_url) as resp: - if resp.status == 200: - with open(local_path, "wb") as f: - f.write(await resp.read()) - sync_entry = FileSyncQueue( - local_path=local_path, - remote_path=f"/AegisOne_Service/bot/{local_filename}", - entity_type="conversation", - entity_id=conv.id, - status="pending", - ) - db.add(sync_entry) - except Exception: - pass - - gpt_key = SettingsCache.get("yandex_gpt_key") - if gpt_key: - cat_name = await categorize_with_gpt(text) - if cat_name: - from sqlalchemy import select - from app.models.models import BotCategory - result = await db.execute( - select(BotCategory).where( - BotCategory.name.ilike(f"%{cat_name}%"), - BotCategory.active == True - ).limit(1) - ) - cat = result.scalar_one_or_none() - if cat: - conv.inquiry_type = cat.id - await db.flush() - confirm_text = await get_template_rendered(db, "auto_categorize_confirm", category=cat.name) - from app.keyboards.inline import build_category_buttons - buttons = build_category_buttons() - await max_api.send_message_with_keyboard(max_user_id, confirm_text, buttons) - FSM.set_state(max_user_id, BotState.CATEGORY_SELECT) - return - else: - cat = await auto_categorize_text(db, text) - if cat: - conv.inquiry_type = cat.id - await db.flush() - - await db.flush() - - ooh = not SettingsCache.is_work_hours() - if ooh: - start = SettingsCache.get_int("work_hours_start", 9) - end = SettingsCache.get_int("work_hours_end", 18) - ooh_text = await get_template_rendered(db, "out_of_hours", - work_hours_start=start, work_hours_end=end) - await max_api.send_message(max_user_id, ooh_text) - - await _confirm_inquiry(bot, user_data, db, max_api, conv, ooh) + await _finalize_ticket(user_id, conv, user, text) -async def _confirm_inquiry(bot, user_data, db, max_api, conv, is_ooh: bool): - max_user_id = user_data["user_id"] - user = await get_or_create_user(db, max_user_id) +async def _finalize_ticket( + user_id: int, + conv: BotConversation, + user: BotUser, + inquiry_text: str, +) -> None: + async with async_session() as db: + user_name = user.first_name or user.last_name or "" + ticket_title = f"Обращение от {user_name or 'клиента'} ({user_id})" + if len(inquiry_text) > 100: + ticket_title = inquiry_text[:97] + "..." - if is_ooh: - text = await get_template_rendered(db, "confirmation_ooh", - first_name=user.first_name, conv_id=conv.id) - else: - text = await get_template_rendered(db, "confirmation", - first_name=user.first_name, conv_id=conv.id) + ticket = BotTicket( + user_id=user_id, + title=ticket_title, + description=inquiry_text, + priority="normal", + status="Новая", + created_by="bot", + ) + db.add(ticket) + await db.commit() + await db.refresh(ticket) - await max_api.send_message(max_user_id, text, format="markdown") + msg = BotTicketMessage( + ticket_id=ticket.id, + direction="incoming", + text=inquiry_text, + ) + db.add(msg) - from app.integrations import _1c_unf as ic_integration - await ic_integration.send_to_1c(db, conv) + status_log = BotTicketStatus( + ticket_id=ticket.id, + new_status="Новая", + changed_by="bot", + ) + db.add(status_log) - FSM.set_state(max_user_id, BotState.CONFIRMATION) + conv_result = await db.execute( + select(BotConversation).where(BotConversation.id == conv.id) + ) + conv_obj = conv_result.scalar_one_or_none() + if conv_obj: + conv_obj.state = "completed" + + await db.commit() + + async with async_session() as db2: + user_obj = await db2.get(BotUser, user_id) + if user_obj: + user_obj.total_tickets = (user_obj.total_tickets or 0) + 1 + await db2.commit() + + user_info = f"ID: {user_id}\nИмя: {user.first_name or 'не указано'} {user.last_name or ''}" + contact = user.phone or "не указан" + history = await _get_conversation_history(conv.id) + + support_email = await settings_cache.get("support_email", "zakaz@aegisone.ru") + email_subject = await settings_cache.get( + "email_subject", + "Обращение через Виртуального помощника AegisOne Engineering", + ) + + email_sender.send_ticket_escalation( + to=support_email, + subject=email_subject, + user_info=user_info, + contact=contact, + inquiry=inquiry_text, + history=history, + ) + + await max_api.send_message( + user_id, + f"Зафиксировала: {inquiry_text}\n\n" + f"Ваша заявка отправлена инженеру. В ближайшее время он обязательно " + f"свяжется с вами.", + ) + + +async def _get_conversation_history(conv_id: int) -> str: + async with async_session() as db: + result = await db.execute( + select(BotMessage) + .where(BotMessage.conversation_id == conv_id) + .order_by(BotMessage.id) + ) + messages = result.scalars().all() + + lines = [] + for msg in messages: + direction = "Клиент" if msg.direction == "incoming" else "Бот" + time_str = msg.created_at.strftime("%d.%m.%Y %H:%M") if msg.created_at else "" + lines.append(f"[{time_str}] {direction}: {msg.text or ''}") + + return "\n".join(lines) diff --git a/max_bot/app/handlers/main_menu.py b/max_bot/app/handlers/main_menu.py deleted file mode 100644 index 80bba2e..0000000 --- a/max_bot/app/handlers/main_menu.py +++ /dev/null @@ -1,103 +0,0 @@ -from app.settings_cache import SettingsCache -from app.conversation import get_or_create_user, get_template_rendered -from app.keyboards.inline import build_main_menu_buttons -from app.fsm import BotState, FSM - - -async def handle_main_menu(bot, user_data: dict, db, max_api): - max_user_id = user_data["user_id"] - payload = user_data.get("payload", "") - - if payload == "new_inquiry": - user = await get_or_create_user(db, max_user_id) - if user.consent_given: - from app.handlers.contact import handle_contact_provided - await handle_contact_provided(bot, user_data, db, max_api) - else: - from app.handlers.consent import handle_consent_request - await handle_consent_request(bot, user_data, db, max_api) - return - - if payload == "handoff": - from app.handlers.handoff import handle_handoff_request - await handle_handoff_request(bot, user_data, db, max_api) - return - - if payload == "kb_search": - FSM.set_state(max_user_id, BotState.KB_SEARCH) - await max_api.send_message(max_user_id, "Задайте ваш вопрос, и я постараюсь найти ответ.") - return - - if payload.startswith("feature_"): - feature_key = payload.replace("feature_", "") - if SettingsCache.is_feature_enabled(feature_key): - await _handle_feature(bot, user_data, db, max_api, feature_key) - else: - await max_api.send_message(max_user_id, "Этот функционал временно недоступен.") - return - - if payload.startswith("cat_"): - await _handle_category_select(bot, user_data, db, max_api, payload) - return - - if payload == "cancel": - FSM.reset(max_user_id) - text = await get_template_rendered(db, "main_menu") - buttons = build_main_menu_buttons() - await max_api.send_message_with_keyboard(max_user_id, text, buttons) - return - - FSM.reset(max_user_id) - text = await get_template_rendered(db, "main_menu") - buttons = build_main_menu_buttons() - await max_api.send_message_with_keyboard(max_user_id, text, buttons) - - -async def _handle_feature(bot, user_data, db, max_api, feature_key: str): - feature_handlers = { - "risk_score": ("app.handlers.features.risk_score", "handle_risk_score"), - "sla_status": ("app.handlers.features.sla_status", "handle_sla_status"), - "photo_report": ("app.handlers.features.photo_report", "handle_photo_report"), - "appointment": ("app.handlers.features.appointment", "handle_appointment"), - "quality_rate": ("app.handlers.features.quality_rate", "handle_quality_rate"), - "commercial_offer": ("app.handlers.features.commercial", "handle_commercial"), - "my_objects": ("app.handlers.features.my_objects", "handle_my_objects"), - "reminders": ("app.handlers.features.reminders", "handle_reminders"), - "sla_ticket": ("app.handlers.features.sla_ticket", "handle_sla_ticket"), - "notifications": ("app.handlers.features.notifications", "handle_notifications"), - "broadcasts": ("app.handlers.features.broadcasts", "handle_broadcast_send"), - } - handler = feature_handlers.get(feature_key) - if handler: - import importlib - mod = importlib.import_module(handler[0]) - func = getattr(mod, handler[1]) - await func(bot, user_data, db, max_api) - else: - await max_api.send_message(max_user_id, "Функция в разработке.") - - -async def _handle_category_select(bot, user_data, db, max_api, payload: str): - max_user_id = user_data["user_id"] - try: - cat_id = int(payload.replace("cat_", "")) - except ValueError: - return - - from sqlalchemy import select - from app.models.models import BotCategory - result = await db.execute(select(BotCategory).where(BotCategory.id == cat_id)) - cat = result.scalar_one_or_none() - if not cat: - return - - from app.conversation import get_open_conversation, get_or_create_user - user = await get_or_create_user(db, max_user_id) - conv = await get_open_conversation(db, user.id) - if conv: - conv.inquiry_type = cat.id - await db.flush() - - ooh = not SettingsCache.is_work_hours() - from app.handlers.inquiry import _confirm_inquiry - await _confirm_inquiry(bot, user_data, db, max_api, conv, ooh) diff --git a/max_bot/app/handlers/out_of_hours.py b/max_bot/app/handlers/out_of_hours.py deleted file mode 100644 index 4b726a7..0000000 --- a/max_bot/app/handlers/out_of_hours.py +++ /dev/null @@ -1,43 +0,0 @@ -from app.settings_cache import SettingsCache -from app.conversation import get_template_rendered -from app.keyboards.inline import build_cancel_button -from app.fsm import BotState, FSM -from app.handlers.main_menu import handle_main_menu - - -async def handle_out_of_hours(bot, user_data: dict, db, max_api): - max_user_id = user_data["user_id"] - await SettingsCache.refresh(db) - - start = SettingsCache.get_int("work_hours_start", 9) - end = SettingsCache.get_int("work_hours_end", 18) - hours_str = SettingsCache.get_work_hours_str() - - text = ( - f"Сейчас мы не работаем. Наше рабочее время: Пн-Пт {hours_str} (МСК).\n\n" - f"Оставьте сообщение — мы ответим как можно быстрее." - ) - buttons = [ - [{"type": "message", "text": "✉️ Оставить сообщение", "payload": "leave_message"}], - [{"type": "message", "text": "📞 Заказать звонок", "payload": "callback"}], - ] - await max_api.send_message_with_keyboard(max_user_id, text, buttons) - FSM.set_state(max_user_id, BotState.OUT_OF_HOURS) - - -async def handle_out_of_hours_callback(bot, user_data: dict, db, max_api, payload: str): - max_user_id = user_data["user_id"] - - if payload == "leave_message": - FSM.set_state(max_user_id, BotState.INQUIRY_REQUEST) - from app.handlers.inquiry import handle_inquiry - await handle_inquiry(bot, user_data, db, max_api) - return - - if payload == "callback": - from app.handlers.consent import handle_consent_request - FSM.set_state(max_user_id, BotState.NAME_REQUEST) - await max_api.send_message(max_user_id, "Как к вам обращаться?") - return - - await handle_main_menu(bot, user_data, db, max_api) diff --git a/max_bot/app/integrations/_1c_unf.py b/max_bot/app/integrations/_1c_unf.py deleted file mode 100644 index 02aafc6..0000000 --- a/max_bot/app/integrations/_1c_unf.py +++ /dev/null @@ -1,100 +0,0 @@ -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 deleted file mode 100644 index e69de29..0000000 diff --git a/max_bot/app/integrations/__pycache__/_1c_unf.cpython-314.pyc b/max_bot/app/integrations/__pycache__/_1c_unf.cpython-314.pyc deleted file mode 100644 index da6d69a..0000000 Binary files a/max_bot/app/integrations/__pycache__/_1c_unf.cpython-314.pyc and /dev/null 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 deleted file mode 100644 index fab329d..0000000 Binary files a/max_bot/app/integrations/__pycache__/_1c_unf.cpython-38.pyc and /dev/null differ diff --git a/max_bot/app/integrations/__pycache__/__init__.cpython-314.pyc b/max_bot/app/integrations/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index de10113..0000000 Binary files a/max_bot/app/integrations/__pycache__/__init__.cpython-314.pyc and /dev/null 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 deleted file mode 100644 index 64e6d45..0000000 Binary files a/max_bot/app/integrations/__pycache__/portal_db.cpython-314.pyc and /dev/null 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 deleted file mode 100644 index bdf673a..0000000 Binary files a/max_bot/app/integrations/__pycache__/portal_db.cpython-38.pyc and /dev/null 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 deleted file mode 100644 index 1291fde..0000000 Binary files a/max_bot/app/integrations/__pycache__/yandex_gpt.cpython-314.pyc and /dev/null 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 deleted file mode 100644 index 9c8dc0a..0000000 Binary files a/max_bot/app/integrations/__pycache__/yandex_gpt.cpython-38.pyc and /dev/null differ diff --git a/max_bot/app/integrations/portal_db.py b/max_bot/app/integrations/portal_db.py deleted file mode 100644 index dcd74f4..0000000 --- a/max_bot/app/integrations/portal_db.py +++ /dev/null @@ -1,57 +0,0 @@ -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 deleted file mode 100644 index 86e682f..0000000 --- a/max_bot/app/integrations/yandex_gpt.py +++ /dev/null @@ -1,80 +0,0 @@ -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.py b/max_bot/app/keyboards.py new file mode 100644 index 0000000..a832882 --- /dev/null +++ b/max_bot/app/keyboards.py @@ -0,0 +1,77 @@ +from typing import Optional, List, Dict, Any + +POLITICA_URL = "https://aegisone.ru/politica.php" + + +def _callback_button(text: str, payload: str, intent: str = "default") -> dict: + return { + "type": "callback", + "text": text, + "payload": payload, + "intent": intent, + } + + +def _link_button(text: str, url: str) -> dict: + return { + "type": "link", + "text": text, + "url": url, + } + + +def _request_contact_button(text: str) -> dict: + return { + "type": "request_contact", + "text": text, + } + + +def _message_button(text: str) -> dict: + return { + "type": "message", + "text": text, + } + + +def make_inline_keyboard(buttons: List[List[Dict[str, Any]]]) -> List[Dict[str, Any]]: + return [ + { + "type": "inline_keyboard", + "payload": {"buttons": buttons}, + } + ] + + +def consent_keyboard() -> list: + return make_inline_keyboard([ + [ + _callback_button("✅ Даю согласие", "consent_yes", intent="positive"), + _link_button("📄 Подробнее", POLITICA_URL), + _callback_button("❌ Не даю согласие", "consent_no", intent="negative"), + ], + ]) + + +def contact_keyboard() -> list: + return make_inline_keyboard([ + [ + _request_contact_button("📱 Поделиться контактом"), + ], + ]) + + +def single_button_keyboard(text: str, payload: str, intent: str = "default") -> list: + return make_inline_keyboard([ + [ + _callback_button(text, payload, intent), + ], + ]) + + +def return_to_consent_keyboard() -> list: + return make_inline_keyboard([ + [ + _callback_button("✅ Даю согласие", "consent_yes", intent="positive"), + ], + ]) diff --git a/max_bot/app/keyboards/__init__.py b/max_bot/app/keyboards/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/max_bot/app/keyboards/__pycache__/__init__.cpython-314.pyc b/max_bot/app/keyboards/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index e67fec3..0000000 Binary files a/max_bot/app/keyboards/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/max_bot/app/keyboards/__pycache__/inline.cpython-314.pyc b/max_bot/app/keyboards/__pycache__/inline.cpython-314.pyc deleted file mode 100644 index b4dd1a2..0000000 Binary files a/max_bot/app/keyboards/__pycache__/inline.cpython-314.pyc and /dev/null differ diff --git a/max_bot/app/keyboards/__pycache__/inline.cpython-38.pyc b/max_bot/app/keyboards/__pycache__/inline.cpython-38.pyc deleted file mode 100644 index 67ae683..0000000 Binary files a/max_bot/app/keyboards/__pycache__/inline.cpython-38.pyc and /dev/null differ diff --git a/max_bot/app/keyboards/inline.py b/max_bot/app/keyboards/inline.py deleted file mode 100644 index c18e6ee..0000000 --- a/max_bot/app/keyboards/inline.py +++ /dev/null @@ -1,90 +0,0 @@ -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 index 69e4dc3..0214766 100644 --- a/max_bot/app/main.py +++ b/max_bot/app/main.py @@ -1,879 +1,547 @@ -import json +from __future__ import annotations import logging +import json from contextlib import asynccontextmanager -from datetime import datetime from typing import Optional +from fastapi import FastAPI, Request, HTTPException +from fastapi.responses import JSONResponse +from sqlalchemy import select, text -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 settings +from app.database import engine, async_session +from app.models import Base +from app.max_api import max_api +from app.settings_cache import settings_cache +from app.yandex_gpt import yandex_gpt +from app.handlers.greeting import handle_greeting, handle_message +from app.handlers.handoff import handle_callback, handle_contact_share +from fastapi.middleware.cors import CORSMiddleware -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() +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) logger = logging.getLogger(__name__) -max_api: Optional[MaxAPIClient] = None - @asynccontextmanager async def lifespan(app: FastAPI): - global max_api + logger.info("Starting max_bot service...") async with engine.begin() as conn: - pass + await conn.run_sync(Base.metadata.create_all) + logger.info("Database tables ensured.") - 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") + await _apply_migrations() + await _seed_default_settings() + await _register_webhook() yield - if max_api: - await max_api.close() + await max_api.close() + logger.info("max_bot service stopped.") -app = FastAPI(title="AegisOne Max Bot — София", lifespan=lifespan) +app = FastAPI(title="AegisOne MAX Bot", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["https://service.aegisone.ru", "https://aegisone.ru"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) -@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) +async def _apply_migrations(): + async with engine.begin() as conn: + try: + await conn.execute(text(""" + DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name='bot_tickets' AND column_name='status' + ) THEN + ALTER TABLE bot_tickets ADD COLUMN status VARCHAR(20) DEFAULT 'Новая'; + END IF; + END $$; + """)) + await conn.execute(text(""" + UPDATE bot_tickets SET status = 'Новая' WHERE status IS NULL OR status NOT IN ('Новая','В работе','Закрыта'); + """)) + except Exception as e: + logger.warning(f"Migration note: {e}") - raw = await request.body() + # BotUser new columns + _add_column_if_not_exists(conn, "bot_users", "patronymic", "VARCHAR(255)") + _add_column_if_not_exists(conn, "bot_users", "email", "VARCHAR(255)") + _add_column_if_not_exists(conn, "bot_users", "organization", "VARCHAR(255)") + _add_column_if_not_exists(conn, "bot_users", "address", "TEXT") + _add_column_if_not_exists(conn, "bot_users", "vcf_raw", "TEXT") + _add_column_if_not_exists(conn, "bot_users", "contact_hash", "VARCHAR(255)") + _add_column_if_not_exists(conn, "bot_users", "phone_verified", "BOOLEAN DEFAULT FALSE") + _add_column_if_not_exists(conn, "bot_users", "email_verified", "BOOLEAN DEFAULT FALSE") + _add_column_if_not_exists(conn, "bot_users", "last_interaction", "TIMESTAMP") + _add_column_if_not_exists(conn, "bot_users", "total_conversations", "INTEGER DEFAULT 0") + _add_column_if_not_exists(conn, "bot_users", "total_tickets", "INTEGER DEFAULT 0") + + +def _add_column_if_not_exists(conn, table: str, column: str, col_type: str): + from sqlalchemy import text as sa_text 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() + conn.execute(sa_text(f""" + DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name='{table}' AND column_name='{column}' + ) THEN + ALTER TABLE {table} ADD COLUMN {column} {col_type}; + END IF; + END $$; + """)) except Exception as e: - logger.error(f"Webhook handler error: {e}", exc_info=True) - await db.rollback() - - return JSONResponse({"ok": True}) + logger.warning(f"Migration add column {table}.{column}: {e}") -@app.get("/api/1c/response") -@app.post("/api/1c/response") -async def api_1c_response(request: Request, db: AsyncSession = Depends(get_db)): +async def _seed_default_settings(): + defaults = { + "assistant_name": "София", + "assistant_role": "Представитель компании AegisOne Engineering, помогающий клиентам с запросами по безопасности", + "support_email": "zakaz@aegisone.ru", + "email_subject": "Обращение через Виртуального помощника AegisOne Engineering", + } + async with async_session() as db: + from app.models import BotSetting + for key, value in defaults.items(): + result = await db.execute(select(BotSetting).where(BotSetting.key == key)) + if not result.scalar_one_or_none(): + db.add(BotSetting(key=key, value=value)) + await db.commit() + settings_cache.invalidate() + + +async def _register_webhook(): + try: + subs = await max_api.get_subscriptions() + logger.info(f"Current subscriptions: {subs}") + except Exception as e: + logger.warning(f"Could not get subscriptions: {e}") + + try: + result = await max_api.subscribe_webhook() + logger.info(f"Webhook subscription result: {result}") + except Exception as e: + logger.error(f"Failed to subscribe webhook: {e}") + + +def _verify_secret(request: Request) -> bool: + secret = request.headers.get("X-Max-Bot-Api-Secret", "") + return secret == settings.webhook_secret + + +@app.post("/webhook") +async def webhook(request: Request): + if not _verify_secret(request): + logger.warning("Invalid webhook secret") + raise HTTPException(status_code=401, detail="Invalid secret") + 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: + raise HTTPException(status_code=400, detail="Invalid JSON") + + update_type = body.get("update_type", "") + logger.info(f"Received webhook: type={update_type}") + + try: + if update_type == "bot_started": + user_id = body.get("user", {}).get("user_id") or body.get("chat_id") + payload = body.get("payload") + user_info = body.get("user", {}) + first_name = user_info.get("first_name", "") + last_name = user_info.get("last_name", "") + username = user_info.get("username", "") + if user_id: + await handle_greeting(user_id, payload, first_name, last_name, username) + + elif update_type == "message_created": + user_id = body.get("sender", {}).get("user_id") or body.get("chat_id") + text = body.get("body", {}).get("text", "") + + attachments = body.get("body", {}).get("attachments", []) + for att in attachments: + if att.get("type") == "contact": + if user_id: + vcf_info = att.get("payload", {}).get("vcf_info", "") + contact_hash = att.get("payload", {}).get("hash", "") + vcf_data = _parse_vcf(vcf_info) + phone = vcf_data.get("phone") or _extract_phone_from_vcf(vcf_info) + async with async_session() as db: + result = await db.execute( + select(BotConversation) + .where(BotConversation.user_id == user_id) + .order_by(BotConversation.id.desc()) + .limit(1) + ) + conv = result.scalar_one_or_none() + conv_id = conv.id if conv else 0 + await handle_contact_share(user_id, conv_id, phone, vcf_info, vcf_data, contact_hash) + return JSONResponse({"ok": True}) + + if user_id and text: + await handle_message(user_id, text) + + elif update_type == "dialog_cleared": + user_id = body.get("user", {}).get("user_id") or body.get("chat_id") + if user_id: + async with async_session() as db: + result = await db.execute( + select(BotConversation) + .where(BotConversation.user_id == user_id) + .order_by(BotConversation.id.desc()) + .limit(1) + ) + conv = result.scalar_one_or_none() + if conv: + conv.state = "greeting" + await db.commit() + logger.info(f"Dialog cleared for user {user_id}, state reset") + + elif update_type == "message_callback": + user_id = body.get("user", {}).get("user_id") or body.get("chat_id") + callback_id = body.get("callback", {}).get("callback_id", "") + callback_data = body.get("callback", {}) + + if user_id and callback_id: + await handle_callback(user_id, callback_id, callback_data) + + return JSONResponse({"ok": True}) + except Exception as e: - logger.error(f"1C response error: {e}", exc_info=True) - await db.rollback() + logger.exception(f"Error processing webhook: {e}") return JSONResponse({"ok": False, "error": str(e)}, status_code=500) @app.get("/health") async def health(): - return {"status": "ok", "bot": "София"} + return {"status": "ok"} -# ===================== Service Portal API ===================== +SENSITIVE_KEYS = {"yandex_gpt_key", "yandex_folder_id", "yandex_disk_token", + "yandex_disk_root", "yandex_disk_client_id", "yandex_disk_client_secret"} + @app.get("/api/bot/settings") -async def api_bot_settings(db: AsyncSession = Depends(get_db)): - await SettingsCache.refresh(db) - return JSONResponse(SettingsCache.get_all()) +async def api_get_settings(): + all_settings = await settings_cache.get_all() + masked = dict(all_settings) + for key in SENSITIVE_KEYS: + if key in masked and masked[key]: + masked[key] = masked[key][:6] + "..." if len(masked[key]) > 6 else "***" + return JSONResponse(masked) @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() +async def api_save_settings(request: Request): + try: + body = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON") + + yandex_keys = {"yandex_gpt_key", "yandex_folder_id", "yandex_disk_token", + "yandex_disk_root", "yandex_disk_client_id", "yandex_disk_client_secret", + "assistant_name", "assistant_role", "support_email", "email_subject", + "max_bot_id"} + 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}) + if key in yandex_keys: + await settings_cache.set(key, str(value)) - -@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) +async def api_get_kb(): + from app.models import BotKnowledgeBase + async with async_session() as db: + result = await db.execute( + select(BotKnowledgeBase).order_by(BotKnowledgeBase.sort_order) ) - 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 - ], - }) + cards = result.scalars().all() + data = [] + for c in cards: + data.append({ + "id": c.id, + "category_id": c.category_id, + "question": c.question, + "answer": c.answer or "", + "keywords": c.keywords or [], + "is_active": c.is_active, + "source_url": c.source_url or "", + "sort_order": c.sort_order, + }) + return JSONResponse(data) + + +@app.post("/api/bot/kb") +async def api_create_kb(request: Request): + from app.models import BotKnowledgeBase + body = await request.json() + if not body.get("question") or not body["question"].strip(): + return JSONResponse({"ok": False, "error": "Вопрос обязателен"}, status_code=400) + async with async_session() as db: + card = BotKnowledgeBase( + category_id=body.get("category_id"), + question=body["question"].strip(), + answer=body.get("answer"), + keywords=body.get("keywords", []), + is_active=body.get("is_active", True), + source_url=body.get("source_url"), + sort_order=body.get("sort_order", 0), + ) + db.add(card) + await db.commit() + return JSONResponse({"ok": True}) + + +@app.delete("/api/bot/kb/{card_id}") +async def api_delete_kb(card_id: int): + from app.models import BotKnowledgeBase + async with async_session() as db: + result = await db.execute( + select(BotKnowledgeBase).where(BotKnowledgeBase.id == card_id) + ) + card = result.scalar_one_or_none() + if card: + await db.delete(card) + await db.commit() + return JSONResponse({"ok": True}) + + +@app.put("/api/bot/kb/{card_id}") +async def api_update_kb(card_id: int, request: Request): + from app.models import BotKnowledgeBase + body = await request.json() + async with async_session() as db: + result = await db.execute( + select(BotKnowledgeBase).where(BotKnowledgeBase.id == card_id) + ) + card = result.scalar_one_or_none() + if not card: + return JSONResponse({"ok": False, "error": "Not found"}, status_code=404) + if "category_id" in body: + card.category_id = body["category_id"] + if "question" in body: + card.question = body["question"] + if "answer" in body: + card.answer = body.get("answer") + if "keywords" in body: + card.keywords = body["keywords"] + if "is_active" in body: + card.is_active = body["is_active"] + if "source_url" in body: + card.source_url = body.get("source_url") + if "sort_order" in body: + card.sort_order = body["sort_order"] + await db.commit() + return JSONResponse({"ok": True}) + + +@app.get("/api/bot/users/consented") +async def api_get_consented_users(): + from app.models import BotUser + async with async_session() as db: + result = await db.execute( + select(BotUser).where(BotUser.consent_given == True).order_by(BotUser.consent_date.desc()) + ) + users = result.scalars().all() + data = [] + for u in users: + data.append({ + "id": u.id, + "first_name": u.first_name or "", + "last_name": u.last_name or "", + "patronymic": u.patronymic or "", + "username": u.username or "", + "phone": u.phone or "", + "email": u.email or "", + "organization": u.organization or "", + "consent_given": u.consent_given, + "consent_date": u.consent_date.isoformat() if u.consent_date else "", + "phone_verified": u.phone_verified, + "last_interaction": u.last_interaction.isoformat() if u.last_interaction else "", + "total_conversations": u.total_conversations, + "total_tickets": u.total_tickets, + "created_at": u.created_at.isoformat() if u.created_at else "", + }) + return JSONResponse(data) @app.post("/api/bot/broadcast") -async def api_bot_broadcast(request: Request, db: AsyncSession = Depends(get_db)): - from app.models.models import BotBroadcast, BotUser +async def api_broadcast(request: Request): + body = await request.json() + user_ids = body.get("user_ids", []) + text = body.get("text", "") + send_all = body.get("all", False) + + if not text: + return JSONResponse({"ok": False, "error": "Text is required"}, status_code=400) + + if send_all: + from app.models import BotUser + async with async_session() as db: + result = await db.execute(select(BotUser)) + users = result.scalars().all() + user_ids = [u.id for u in users if u.consent_given] + + if not user_ids: + return JSONResponse({"ok": False, "error": "No recipients"}, status_code=400) + + sent = 0 + errors = 0 + import asyncio + for i, uid in enumerate(user_ids): + try: + await max_api.send_message(uid, text) + sent += 1 + except Exception as e: + logger.error(f"Broadcast failed for user {uid}: {e}") + errors += 1 + if i % 10 == 9: + await asyncio.sleep(0.5) + + return JSONResponse({"ok": True, "sent": sent, "errors": errors}) + + +@app.post("/api/bot/broadcast/preview") +async def api_broadcast_preview(request: Request): body = await request.json() text = body.get("text", "") - if not text: - return JSONResponse({"error": "Text is required"}, status_code=400) + return JSONResponse({"ok": True, "preview": text}) - 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 - ]) +async def api_get_tickets(): + from app.models import BotTicket + async with async_session() as db: + result = await db.execute( + select(BotTicket).order_by(BotTicket.id.desc()).limit(100) + ) + tickets = result.scalars().all() + data = [] + for t in tickets: + data.append({ + "id": t.id, + "user_id": t.user_id, + "title": t.title or "", + "description": t.description or "", + "priority": t.priority, + "status": t.status, + "created_at": t.created_at.isoformat() if t.created_at else "", + }) + return JSONResponse(data) -@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 +@app.patch("/api/bot/tickets/{ticket_id}") +async def api_update_ticket(ticket_id: int, request: Request): + from app.models import BotTicket, BotTicketStatus 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}) + status = body.get("status") + if status and status not in ("Новая", "В работе", "Закрыта"): + return JSONResponse({"ok": False, "error": "Invalid status"}, status_code=400) + async with async_session() as db: + result = await db.execute( + select(BotTicket).where(BotTicket.id == ticket_id) + ) + ticket = result.scalar_one_or_none() + if not ticket: + return JSONResponse({"ok": False, "error": "Not found"}, status_code=404) + + old_status = ticket.status + if status: + ticket.status = status + db.add(BotTicketStatus( + ticket_id=ticket_id, + old_status=old_status, + new_status=status, + changed_by="engineer", + )) + await db.commit() -@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}) +def _extract_phone_from_vcf(vcf_info: str) -> str: + import re + match = re.search(r"TEL[^:]*:(\+?\d+)", vcf_info) + if match: + return match.group(1) + return "" -@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()]) +def _extract_name_from_vcf(vcf_info: str) -> str: + import re + match = re.search(r"FN:(.+)", vcf_info) + if match: + return match.group(1).strip() + return "" -@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 - ]) +def _parse_vcf(vcf_info: str) -> dict: + import re + result = { + "first_name": "", + "last_name": "", + "patronymic": "", + "phone": "", + "email": "", + "organization": "", + "address": "", + } + # FN: full name (first + last) + fn_match = re.search(r"FN:(.+)", vcf_info) + if fn_match: + full_name = fn_match.group(1).strip() + parts = full_name.split(None, 2) + if len(parts) >= 2: + result["first_name"] = parts[0] + result["last_name"] = parts[1] + if len(parts) >= 3: + result["patronymic"] = parts[2] + elif len(parts) == 1: + result["first_name"] = parts[0] -@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}) + # N: structured name (last;first;middle;prefix;suffix) + n_match = re.search(r"N(?:;[^:]*)?:([^;\r\n]+)", vcf_info) + if n_match: + n_parts = n_match.group(1).split(";") + if len(n_parts) >= 1 and n_parts[0]: + result["last_name"] = n_parts[0] + if len(n_parts) >= 2 and n_parts[1]: + result["first_name"] = n_parts[1] + if len(n_parts) >= 3 and n_parts[2]: + result["patronymic"] = n_parts[2] + # TEL: phone + tel_match = re.search(r"TEL[^:]*:(\+?\d[\d\s\-()]*)", vcf_info) + if tel_match: + phone_raw = tel_match.group(1).strip() + result["phone"] = re.sub(r"[\s\-()]", "", phone_raw) -@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}) + # EMAIL + email_match = re.search(r"EMAIL[^:]*:([^\r\n]+)", vcf_info) + if email_match: + result["email"] = email_match.group(1).strip() + # ORG + org_match = re.search(r"ORG[^:]*:([^\r\n;]+)", vcf_info) + if org_match: + result["organization"] = org_match.group(1).strip() -@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 - ], - }) + # ADR: structured address + adr_match = re.search(r"ADR[^:]*:([^\r\n]+)", vcf_info) + if adr_match: + result["address"] = adr_match.group(1).strip().replace(";", ", ") - -@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}) + return result diff --git a/max_bot/app/max_api.py b/max_bot/app/max_api.py index b5b0486..9d5a6ac 100644 --- a/max_bot/app/max_api.py +++ b/max_bot/app/max_api.py @@ -1,159 +1,97 @@ -import os -import re -import hmac -import hashlib -import logging -from datetime import datetime +import httpx from typing import Optional - -import aiohttp - -logger = logging.getLogger(__name__) - -MAX_API_URL = "https://platform-api.max.ru" +from app.config import settings -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() +class MaxAPI: + def __init__(self): + self.base_url = settings.max_api_base + self.token = settings.max_token + self.client = httpx.AsyncClient(timeout=30.0) 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() + return { + "Authorization": self.token, + "Content-Type": "application/json", + } async def get_me(self) -> dict: - return await self._request("GET", "/me") + r = await self.client.get(f"{self.base_url}/me", headers=self._headers()) + r.raise_for_status() + return r.json() - 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 subscribe_webhook(self) -> dict: + payload = { + "url": settings.webhook_url, + "update_types": settings.update_types, + "secret": settings.webhook_secret, + } + r = await self.client.post( + f"{self.base_url}/subscriptions", + headers=self._headers(), + json=payload, + ) + r.raise_for_status() + return r.json() async def unsubscribe_webhook(self) -> dict: - return await self._request("DELETE", "/subscriptions") + r = await self.client.delete( + f"{self.base_url}/subscriptions", + headers=self._headers(), + ) + r.raise_for_status() + return r.json() - 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() + async def get_subscriptions(self) -> dict: + r = await self.client.get( + f"{self.base_url}/subscriptions", + headers=self._headers(), + ) + r.raise_for_status() + return r.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_message( + self, + user_id: int, + text: str, + attachments: Optional[list] = None, + format: Optional[str] = None, + notify: bool = True, + ) -> dict: + payload = {"text": text, "notify": notify} + if attachments: + payload["attachments"] = attachments + if format: + payload["format"] = format + r = await self.client.post( + f"{self.base_url}/messages?user_id={user_id}", + headers=self._headers(), + json=payload, + ) + r.raise_for_status() + return r.json() - 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 answer_callback( + self, + callback_id: str, + message: Optional[dict] = None, + notification: Optional[str] = None, + ) -> dict: + payload = {} + if message is not None: + payload["message"] = message + if notification is not None: + payload["notification"] = notification + r = await self.client.post( + f"{self.base_url}/answers?callback_id={callback_id}", + headers=self._headers(), + json=payload, + ) + r.raise_for_status() + return r.json() - 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 close(self): + await self.client.aclose() - 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) +max_api = MaxAPI() diff --git a/max_bot/app/max_notifications.py b/max_bot/app/max_notifications.py deleted file mode 100644 index 894d1c2..0000000 --- a/max_bot/app/max_notifications.py +++ /dev/null @@ -1,148 +0,0 @@ -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.py b/max_bot/app/models.py new file mode 100644 index 0000000..1bc0c06 --- /dev/null +++ b/max_bot/app/models.py @@ -0,0 +1,140 @@ +from __future__ import annotations +import datetime +from sqlalchemy import Column, Integer, BigInteger, String, Text, Boolean, DateTime, ForeignKey, ARRAY +from sqlalchemy.orm import DeclarativeBase, relationship + + +class Base(DeclarativeBase): + pass + + +class BotSetting(Base): + __tablename__ = "bot_settings" + id = Column(Integer, primary_key=True, autoincrement=True) + key = Column(String(255), unique=True, nullable=False) + value = Column(Text, nullable=True) + created_at = Column(DateTime, default=datetime.datetime.utcnow) + updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow) + + +class BotFeature(Base): + __tablename__ = "bot_features" + id = Column(Integer, primary_key=True, autoincrement=True) + key = Column(String(255), unique=True, nullable=False) + name = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + is_enabled = Column(Boolean, default=False) + created_at = Column(DateTime, default=datetime.datetime.utcnow) + + +class BotCategory(Base): + __tablename__ = "bot_categories" + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + sort_order = Column(Integer, default=0) + created_at = Column(DateTime, default=datetime.datetime.utcnow) + + +class BotKnowledgeBase(Base): + __tablename__ = "bot_knowledge_base" + id = Column(Integer, primary_key=True, autoincrement=True) + category_id = Column(Integer, ForeignKey("bot_categories.id"), nullable=True) + question = Column(Text, nullable=False) + answer = Column(Text, nullable=True) + keywords = Column(ARRAY(String), nullable=True) + is_active = Column(Boolean, default=True) + source_url = Column(String(500), nullable=True) + sort_order = Column(Integer, default=0) + created_at = Column(DateTime, default=datetime.datetime.utcnow) + updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow) + + category = relationship("BotCategory", lazy="joined") + + +class BotUser(Base): + __tablename__ = "bot_users" + id = Column(BigInteger, primary_key=True) + first_name = Column(String(255), nullable=True) + last_name = Column(String(255), nullable=True) + patronymic = Column(String(255), nullable=True) + username = Column(String(255), nullable=True) + phone = Column(String(50), nullable=True) + email = Column(String(255), nullable=True) + organization = Column(String(255), nullable=True) + address = Column(Text, nullable=True) + vcf_raw = Column(Text, nullable=True) + contact_hash = Column(String(255), nullable=True) + phone_verified = Column(Boolean, default=False) + email_verified = Column(Boolean, default=False) + consent_given = Column(Boolean, default=False) + consent_date = Column(DateTime, nullable=True) + last_interaction = Column(DateTime, nullable=True) + total_conversations = Column(Integer, default=0) + total_tickets = Column(Integer, default=0) + created_at = Column(DateTime, default=datetime.datetime.utcnow) + updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow) + + +class BotConversation(Base): + __tablename__ = "bot_conversations" + id = Column(BigInteger, primary_key=True, autoincrement=True) + user_id = Column(BigInteger, ForeignKey("bot_users.id"), nullable=False) + state = Column(String(50), default="greeting") + intent = Column(String(50), nullable=True) + inquiry_text = Column(Text, nullable=True) + created_at = Column(DateTime, default=datetime.datetime.utcnow) + updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow) + + user = relationship("BotUser", lazy="joined") + + +class BotMessage(Base): + __tablename__ = "bot_messages" + id = Column(BigInteger, primary_key=True, autoincrement=True) + conversation_id = Column(BigInteger, ForeignKey("bot_conversations.id"), nullable=False) + direction = Column(String(10), nullable=False) + text = Column(Text, nullable=True) + attachment_json = Column(Text, nullable=True) + created_at = Column(DateTime, default=datetime.datetime.utcnow) + + conversation = relationship("BotConversation", lazy="joined") + + +class BotTicket(Base): + __tablename__ = "bot_tickets" + id = Column(BigInteger, primary_key=True, autoincrement=True) + user_id = Column(BigInteger, ForeignKey("bot_users.id"), nullable=False) + title = Column(String(500), nullable=True) + description = Column(Text, nullable=True) + priority = Column(String(20), default="normal") + status = Column(String(20), default="Новая") + created_by = Column(String(100), default="bot") + assigned_to = Column(BigInteger, nullable=True) + created_at = Column(DateTime, default=datetime.datetime.utcnow) + updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow) + + user = relationship("BotUser", lazy="joined") + + +class BotTicketMessage(Base): + __tablename__ = "bot_ticket_messages" + id = Column(BigInteger, primary_key=True, autoincrement=True) + ticket_id = Column(BigInteger, ForeignKey("bot_tickets.id"), nullable=False) + direction = Column(String(10), nullable=False) + text = Column(Text, nullable=True) + created_at = Column(DateTime, default=datetime.datetime.utcnow) + + ticket = relationship("BotTicket", lazy="joined") + + +class BotTicketStatus(Base): + __tablename__ = "bot_ticket_statuses" + id = Column(BigInteger, primary_key=True, autoincrement=True) + ticket_id = Column(BigInteger, ForeignKey("bot_tickets.id"), nullable=False) + old_status = Column(String(20), nullable=True) + new_status = Column(String(20), nullable=False) + changed_by = Column(String(100), nullable=True) + created_at = Column(DateTime, default=datetime.datetime.utcnow) + + ticket = relationship("BotTicket", lazy="joined") diff --git a/max_bot/app/models/__init__.py b/max_bot/app/models/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/max_bot/app/models/__pycache__/__init__.cpython-314.pyc b/max_bot/app/models/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index e632cb3..0000000 Binary files a/max_bot/app/models/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/max_bot/app/models/__pycache__/models.cpython-314.pyc b/max_bot/app/models/__pycache__/models.cpython-314.pyc deleted file mode 100644 index 3622427..0000000 Binary files a/max_bot/app/models/__pycache__/models.cpython-314.pyc and /dev/null differ diff --git a/max_bot/app/models/__pycache__/models.cpython-38.pyc b/max_bot/app/models/__pycache__/models.cpython-38.pyc deleted file mode 100644 index 176ba38..0000000 Binary files a/max_bot/app/models/__pycache__/models.cpython-38.pyc and /dev/null differ diff --git a/max_bot/app/models/models.py b/max_bot/app/models/models.py deleted file mode 100644 index af39e90..0000000 --- a/max_bot/app/models/models.py +++ /dev/null @@ -1,350 +0,0 @@ -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 index f4587cb..8eaf470 100644 --- a/max_bot/app/settings_cache.py +++ b/max_bot/app/settings_cache.py @@ -1,129 +1,45 @@ -import time -from typing import Optional +from typing import Optional, Dict from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession -from app.models.models import BotSetting, BotFeature, BotResponseTemplate, BotCategory +from app.database import async_session +from app.models import BotSetting class SettingsCache: - _cache: dict = {} - _features: dict = {} - _templates: dict = {} - _categories: list = [] - _last_update: float = 0 - TTL = 300 # 5 minutes + def __init__(self): + self._cache: dict[str, str] = {} + self._loaded = False - @classmethod - async def refresh(cls, db: AsyncSession): - now = time.time() - if now - cls._last_update < cls.TTL and cls._cache: - return + async def _load(self): + async with async_session() as db: + result = await db.execute(select(BotSetting)) + rows = result.scalars().all() + self._cache = {row.key: row.value or "" for row in rows} + self._loaded = True - result = await db.execute(select(BotSetting)) - cls._cache = {row.key: row.value for row in result.scalars().all()} + async def get(self, key: str, default: str = "") -> str: + if not self._loaded: + await self._load() + return self._cache.get(key, default) - result = await db.execute(select(BotFeature).where(BotFeature.enabled == True)) - cls._features = {row.feature_key: row for row in result.scalars().all()} + async def set(self, key: str, value: str) -> bool: + async with async_session() as db: + result = await db.execute(select(BotSetting).where(BotSetting.key == key)) + setting = result.scalar_one_or_none() + if setting: + setting.value = value + else: + db.add(BotSetting(key=key, value=value)) + await db.commit() + self._cache[key] = value + return True - result = await db.execute(select(BotFeature)) - all_features = {row.feature_key: row for row in result.scalars().all()} + async def get_all(self) -> Dict[str, str]: + if not self._loaded: + await self._load() + return dict(self._cache) - result = await db.execute(select(BotResponseTemplate)) - cls._templates = {row.template_key: row.template_text for row in result.scalars().all()} + def invalidate(self): + self._loaded = False - 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", "") +settings_cache = SettingsCache() diff --git a/max_bot/app/text_renderer.py b/max_bot/app/text_renderer.py deleted file mode 100644 index de2afdc..0000000 --- a/max_bot/app/text_renderer.py +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index e69de29..0000000 diff --git a/max_bot/app/utils/__pycache__/__init__.cpython-314.pyc b/max_bot/app/utils/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 943351a..0000000 Binary files a/max_bot/app/utils/__pycache__/__init__.cpython-314.pyc and /dev/null 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 deleted file mode 100644 index d1f4b95..0000000 Binary files a/max_bot/app/utils/__pycache__/emotion_detector.cpython-314.pyc and /dev/null differ diff --git a/max_bot/app/utils/emotion_detector.py b/max_bot/app/utils/emotion_detector.py deleted file mode 100644 index b85d0b8..0000000 --- a/max_bot/app/utils/emotion_detector.py +++ /dev/null @@ -1,28 +0,0 @@ -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/app/yandex_gpt.py b/max_bot/app/yandex_gpt.py new file mode 100644 index 0000000..d87cb25 --- /dev/null +++ b/max_bot/app/yandex_gpt.py @@ -0,0 +1,107 @@ +import httpx +import json +import logging +from typing import Optional +from app.config import settings +from app.settings_cache import settings_cache + +logger = logging.getLogger(__name__) + +YANDEX_GPT_URL = "https://llm.api.cloud.yandex.net/foundationModels/v1/completion" +YANDEX_GPT_MODEL = "yandexgpt-lite" + + +class YandexGPT: + def __init__(self): + self._api_key: Optional[str] = None + self._folder_id: Optional[str] = None + + async def _load_credentials(self) -> bool: + self._api_key = await settings_cache.get("yandex_gpt_key") + self._folder_id = await settings_cache.get("yandex_folder_id") + return bool(self._api_key and self._folder_id) + + async def is_available(self) -> bool: + return await self._load_credentials() + + async def _request(self, prompt: str, system_prompt: str = "") -> Optional[str]: + if not await self._load_credentials(): + return None + + messages = [] + if system_prompt: + messages.append({"role": "system", "text": system_prompt}) + messages.append({"role": "user", "text": prompt}) + + payload = { + "modelUri": f"gpt://{self._folder_id}/{YANDEX_GPT_MODEL}", + "completionOptions": { + "stream": False, + "temperature": 0.3, + "maxTokens": 1000, + }, + "messages": messages, + } + + headers = { + "Authorization": f"Api-Key {self._api_key}", + "Content-Type": "application/json", + } + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + r = await client.post(YANDEX_GPT_URL, headers=headers, json=payload) + r.raise_for_status() + data = r.json() + result = data.get("result", {}) + alternatives = result.get("alternatives", []) + if alternatives: + return alternatives[0].get("message", {}).get("text", "") + return None + except Exception as e: + logger.error(f"YandexGPT request failed: {e}") + return None + + async def analyze_intent(self, text: str, history_context: str = "") -> str: + system_prompt = ( + "Ты — ассистент София из компании AegisOne Engineering. " + "Определи намерение пользователя. Ответь строго одним словом: " + "'question' — если это общий вопрос о компании, услугах, контактах; " + "'ticket' — если пользователь хочет оставить заявку, заказать услугу, попросить обратный звонок; " + "'unknown' — если намерение неочевидно." + ) + if history_context: + system_prompt += f"\n\nИстория обращений пользователя:\n{history_context}" + result = await self._request(text, system_prompt) + if result and result.strip().lower() in ("question", "ticket", "unknown"): + return result.strip().lower() + return "unknown" + + async def generate_answer(self, question: str, context: str, history_context: str = "") -> Optional[str]: + system_prompt = ( + "Ты — ассистент София, представитель компании AegisOne Engineering. " + "Отвечай на вопросы клиентов дружелюбно, профессионально и по делу. " + "Используй информацию из базы знаний, но отвечай живым языком, не копируй текст дословно. " + "Если информации недостаточно, предложи связаться с компанией по телефону или почте." + ) + if history_context: + system_prompt += f"\n\nДополнительный контекст об этом пользователе:\n{history_context}" + user_prompt = f"Вопрос клиента: {question}\n\nКонтекст из базы знаний:\n{context}\n\nДай ответ клиенту:" + return await self._request(user_prompt, system_prompt) + + async def clarify_intent(self, text: str, history_context: str = "") -> str: + system_prompt = ( + "Ты — ассистент София. " + "Пользователь написал нечто неочевидное. " + "Ответь дружелюбно, попроси уточнить, хочет ли он: " + "1) задать вопрос о компании AegisOne Engineering, " + "2) оставить заявку на услугу, " + "3) или что-то другое." + ) + if history_context: + system_prompt += f"\n\nКонтекст о пользователе:\n{history_context}" + result = await self._request(text, system_prompt) + return result or "Извините, я не совсем поняла ваш запрос. Вы хотели бы задать вопрос о нашей компании или оставить заявку?" + + +yandex_gpt = YandexGPT() diff --git a/max_bot/deploy_full.sh b/max_bot/deploy_full.sh new file mode 100644 index 0000000..1780930 --- /dev/null +++ b/max_bot/deploy_full.sh @@ -0,0 +1,162 @@ +#!/bin/bash +# ============================================ +# AegisOne MAX Bot + Portal — Full Auto Deploy +# ============================================ +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +REMOTE_USER="angel" +REMOTE_HOST="81.177.141.34" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) + +echo "╔══════════════════════════════════════════╗" +echo "║ AegisOne MAX Bot — Full Auto Deploy ║" +echo "╚══════════════════════════════════════════╝" +echo "" + +# === STEP 1: Run tests === +echo ">>> [1/5] Running tests..." +cd "$SCRIPT_DIR" +python -m pytest tests/ -v --tb=short 2>&1 || { + echo "WARNING: Tests failed, but continuing with deploy..." +} + +# === STEP 2: Build archive === +echo ">>> [2/5] Building deployment archive..." +cd "$PROJECT_DIR" + +# Create exclude list for tar +cat > /tmp/exclude_max_bot.txt << 'EXCL' +max_bot/__pycache__ +max_bot/*.pyc +max_bot/.pytest_cache +max_bot/tests +max_bot/logs +EXCL + +tar czf /tmp/max_bot_${TIMESTAMP}.tar.gz \ + -X /tmp/exclude_max_bot.txt \ + max_bot/ + +echo "Archive created: /tmp/max_bot_${TIMESTAMP}.tar.gz ($(du -h /tmp/max_bot_${TIMESTAMP}.tar.gz | cut -f1))" + +# Also build py_service archive with the updated files +cat > /tmp/exclude_py.txt << 'EXCL' +py_service/__pycache__ +py_service/*.pyc +py_service/.pytest_cache +py_service/app/__pycache__ +py_service/app/routers/__pycache__ +py_service/app/templates/__pycache__ +EXCL + +tar czf /tmp/py_service_bot_${TIMESTAMP}.tar.gz \ + -X /tmp/exclude_py.txt \ + py_service/app/templates/base.html \ + py_service/app/templates/_sidebar.html \ + py_service/app/routers/service_pages.py \ + py_service/app/templates/pages/bot_settings.html \ + py_service/app/templates/pages/bot_kb.html \ + py_service/app/templates/pages/bot_test.html \ + py_service/app/templates/pages/bot_tickets.html \ + py_service/app/templates/pages/bot_consent.html \ + py_service/app/templates/pages/portal_settings.html + +# === STEP 3: Copy to server === +echo ">>> [3/5] Copying to server..." +scp /tmp/max_bot_${TIMESTAMP}.tar.gz "$REMOTE_USER@$REMOTE_HOST:/tmp/" +scp /tmp/py_service_bot_${TIMESTAMP}.tar.gz "$REMOTE_USER@$REMOTE_HOST:/tmp/" +scp "$SCRIPT_DIR/sql/schema.sql" "$REMOTE_USER@$REMOTE_HOST:/tmp/bot_schema.sql" +scp "$SCRIPT_DIR/sql/seed_knowledge_base.sql" "$REMOTE_USER@$REMOTE_HOST:/tmp/seed_kb.sql" + +# === STEP 4: Remote deploy === +echo ">>> [4/5] Deploying on remote server..." +ssh "$REMOTE_USER@$REMOTE_HOST" << 'REMOTESHELL' + set -e + echo "--- Remote deploy started ---" + + # 4a. Apply SQL schema + echo "Applying SQL schema..." + PGPASSWORD=aegisone_pass psql -h localhost -U aegisone -d aegisone -f /tmp/bot_schema.sql 2>/dev/null || true + + # 4b. Apply SQL seed (knowledge base) + echo "Seeding knowledge base..." + PGPASSWORD=aegisone_pass psql -h localhost -U aegisone -d aegisone -f /tmp/seed_kb.sql 2>/dev/null || true + + # 4c. Deploy max_bot + echo "Deploying max_bot service..." + BACKUP="/opt/projects/backups/max_bot_$(date +%Y%m%d_%H%M%S)" + if [ -d "/opt/projects/aegisone-py/max_bot" ]; then + mkdir -p "/opt/projects/backups" + cp -a "/opt/projects/aegisone-py/max_bot" "$BACKUP" + echo "Backup saved to $BACKUP" + fi + + mkdir -p "/opt/projects/aegisone-py/max_bot" + tar xzf /tmp/max_bot_*.tar.gz -C /opt/projects/aegisone-py/ + + cd /opt/projects/aegisone-py/max_bot + docker compose down 2>/dev/null || true + docker build --network host --no-cache -t max_bot-max_bot:latest . + docker compose up -d + + # 4d. Deploy py_service updates (portal pages) - using docker cp + echo "Deploying portal updates..." + # Templates + PAGES_SRC="/opt/projects/aegisone-py/py_service/app/templates/pages" + docker exec aegisone-app mkdir -p /app/app/templates/pages 2>/dev/null || true + docker cp "/opt/projects/aegisone-py/py_service/app/templates/base.html" aegisone-app:/app/app/templates/base.html + docker cp "/opt/projects/aegisone-py/py_service/app/templates/_sidebar.html" aegisone-app:/app/app/templates/_sidebar.html + docker cp "$PAGES_SRC/bot_settings.html" aegisone-app:/app/app/templates/pages/bot_settings.html + docker cp "$PAGES_SRC/bot_kb.html" aegisone-app:/app/app/templates/pages/bot_kb.html + docker cp "$PAGES_SRC/bot_test.html" aegisone-app:/app/app/templates/pages/bot_test.html + docker cp "$PAGES_SRC/bot_tickets.html" aegisone-app:/app/app/templates/pages/bot_tickets.html + docker cp "$PAGES_SRC/bot_consent.html" aegisone-app:/app/app/templates/pages/bot_consent.html + docker cp "$PAGES_SRC/portal_settings.html" aegisone-app:/app/app/templates/pages/portal_settings.html + # Router + docker cp "/opt/projects/aegisone-py/py_service/app/routers/service_pages.py" aegisone-app:/app/app/routers/service_pages.py + docker restart aegisone-app 2>/dev/null || docker restart py_service_app_1 2>/dev/null || \ + docker compose -f /opt/projects/aegisone-py/py_service/docker-compose.yml restart app + + # 4e. Update role permissions for engineer (add bot menu items) + echo "Updating role permissions..." + PGPASSWORD=aegisone_pass psql -h localhost -U aegisone -d aegisone -c " + INSERT INTO role_menu_permissions (role, menu_key, visible) + VALUES + ('engineer', 'bot_settings', true), + ('engineer', 'bot_kb', true), + ('engineer', 'bot_test', true), + ('engineer', 'bot_tickets', true), + ('engineer', 'bot_consent', true) + ON CONFLICT DO NOTHING; + " 2>/dev/null || true + + # 4f. Register webhook subscription + echo "Registering webhook..." + sleep 3 + curl -s -o /dev/null -w "Health check: %{http_code}\n" https://max.aegisone.ru/health || echo "Health check failed" + + # 4g. Cleanup temp files + rm -f /tmp/max_bot_*.tar.gz /tmp/py_service_bot_*.tar.gz /tmp/bot_schema.sql /tmp/seed_kb.sql + + echo "--- Remote deploy completed ---" +REMOTESHELL + +# === STEP 5: Verify === +echo ">>> [5/5] Verifying deployment..." +sleep 3 +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" https://max.aegisone.ru/health 2>/dev/null || echo "000") +if [ "$HTTP_CODE" = "200" ]; then + echo "✅ Health check PASSED (HTTP $HTTP_CODE)" + echo "✅ Bot is running at https://max.aegisone.ru" + echo "✅ Portal: https://service.aegisone.ru/service/bot-settings" +else + echo "⚠️ Health check returned HTTP $HTTP_CODE" + echo " Check the server: ssh $REMOTE_USER@$REMOTE_HOST 'docker logs max_bot'" +fi + +echo "" +echo "╔══════════════════════════════════════════╗" +echo "║ Deploy completed at $(date) ║" +echo "╚══════════════════════════════════════════╝" diff --git a/max_bot/docker-compose.yml b/max_bot/docker-compose.yml index f392e33..ed390ed 100644 --- a/max_bot/docker-compose.yml +++ b/max_bot/docker-compose.yml @@ -1,15 +1,21 @@ services: - max-bot: - build: - context: . - network: host - container_name: aegisone-max-bot + max_bot: + build: . + container_name: max_bot + restart: always 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 + - ../config.php:/var/www/html/config.php:ro + - ./logs:/app/logs + environment: + - BOT_DATABASE_URL=postgresql+asyncpg://aegisone:aegisone_pass@localhost:5432/aegisone + - BOT_MAX_TOKEN=f9LHodD0cOKkk03DYXj906MdIeRs3HdDo0BC5H2fLMVCMz3PA_Vx4aJqt2l2qPLM5zd5EQ1Zh6APxzpw2UYE + - BOT_WEBHOOK_URL=https://max.aegisone.ru/webhook + - BOT_WEBHOOK_SECRET=aegisone_bot_secret_2026 + - BOT_CONFIG_PHP_PATH=/var/www/html/config.php + - BOT_PHP_SENDMAIL_PATH=/usr/sbin/sendmail + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" diff --git a/max_bot/max_bot_deploy.sh b/max_bot/max_bot_deploy.sh index 82e8fa4..68824ab 100644 --- a/max_bot/max_bot_deploy.sh +++ b/max_bot/max_bot_deploy.sh @@ -1,106 +1,64 @@ #!/bin/bash -# ========================================== -# MAX Bot — Deploy Script -# ========================================== +# AegisOne 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; } +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="/opt/projects/aegisone-py" +REMOTE_USER="angel" +REMOTE_HOST="81.177.141.34" -cd "$(dirname "$0")" +echo "=== MAX Bot Deploy ===" +echo "Building and packaging max_bot..." -echo -e "${CYAN}" -echo "==========================================" -echo " MAX Bot — Deploy" -echo "==========================================" -echo -e "${NC}" +cd "$SCRIPT_DIR" -BOT="/opt/projects/aegisone-py/max_bot" -PY="/opt/projects/aegisone-py" -NGX="/opt/projects/nginx-proxy" +# Create tar archive +cd "$PROJECT_DIR" +tar czf /tmp/max_bot_update.tar.gz \ + -C "$SCRIPT_DIR/.." max_bot/ \ + --exclude max_bot/__pycache__ \ + --exclude max_bot/.pytest_cache \ + --exclude "max_bot/*.pyc" \ + --exclude max_bot/tests -# ========================================== -# 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" +echo "Copying to server..." +scp /tmp/max_bot_update.tar.gz "$REMOTE_USER@$REMOTE_HOST:/tmp/" +ssh "$REMOTE_USER@$REMOTE_HOST" << 'EOF' + set -e + PROJECT="/opt/projects/aegisone-py/max_bot" + BACKUP="/opt/projects/backups/max_bot_$(date +%Y%m%d_%H%M%S)" -# ========================================== -# 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" + echo "Creating backup..." + if [ -d "$PROJECT" ]; then + mkdir -p "/opt/projects/backups" + cp -a "$PROJECT" "$BACKUP" + fi -# ========================================== -# 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" + echo "Extracting update..." + mkdir -p "$PROJECT" + tar xzf /tmp/max_bot_update.tar.gz -C /opt/projects/aegisone-py/ - 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" + echo "Rebuilding and restarting container..." + cd "$PROJECT" + docker compose down 2>/dev/null || true + docker compose build --no-cache --network host + docker compose up -d - 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 + echo "Checking health..." + sleep 5 + for i in 1 2 3 4 5; do + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" https://max.aegisone.ru/health || echo "000") + if [ "$HTTP_CODE" = "200" ]; then + echo "Health check PASSED (HTTP $HTTP_CODE)" + break + fi + echo "Waiting... attempt $i/5" + sleep 3 + done -# ========================================== -# 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 + echo "Deploy complete!" +EOF -# ========================================== -# 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 "" +echo "=== Done ===" diff --git a/max_bot/requirements.txt b/max_bot/requirements.txt index 55c6b31..9af3ef3 100644 --- a/max_bot/requirements.txt +++ b/max_bot/requirements.txt @@ -1,12 +1,9 @@ -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 +fastapi>=0.110.0 +uvicorn[standard]>=0.27.0 +sqlalchemy[asyncio]>=2.0.0 +asyncpg>=0.29.0 +pydantic>=2.0.0 +pydantic-settings>=2.0.0 +httpx>=0.27.0 +pytest>=8.0.0 +pytest-asyncio>=0.23.0 diff --git a/max_bot/sql/bot_schema.sql b/max_bot/sql/bot_schema.sql deleted file mode 100644 index 7faa988..0000000 --- a/max_bot/sql/bot_schema.sql +++ /dev/null @@ -1,205 +0,0 @@ --- 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 deleted file mode 100644 index b28ee53..0000000 --- a/max_bot/sql/migrations/v1.5.1_broadcast_recipient_ids.sql +++ /dev/null @@ -1,2 +0,0 @@ --- 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 deleted file mode 100644 index afb210a..0000000 --- a/max_bot/sql/migrations/v1.5.2_role_menu_permissions.sql +++ /dev/null @@ -1,26 +0,0 @@ --- 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.6.0_fixes.sql b/max_bot/sql/migrations/v1.6.0_fixes.sql deleted file mode 100644 index 745bd51..0000000 --- a/max_bot/sql/migrations/v1.6.0_fixes.sql +++ /dev/null @@ -1,38 +0,0 @@ --- 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/sql/schema.sql b/max_bot/sql/schema.sql new file mode 100644 index 0000000..dc051df --- /dev/null +++ b/max_bot/sql/schema.sql @@ -0,0 +1,170 @@ +-- ============================================ +-- AegisOne MAX Bot — Database Schema +-- ============================================ +-- Run this to set up all bot tables. +-- Assumes database 'aegisone' already exists. + +-- ============================================ +-- CLEANUP: Drop ALL old bot tables before recreating +-- ============================================ +DROP TABLE IF EXISTS bot_1c_sync_log CASCADE; +DROP TABLE IF EXISTS bot_ticket_messages CASCADE; +DROP TABLE IF EXISTS bot_ticket_statuses CASCADE; +DROP TABLE IF EXISTS bot_tickets CASCADE; +DROP TABLE IF EXISTS bot_messages CASCADE; +DROP TABLE IF EXISTS bot_conversations CASCADE; +DROP TABLE IF EXISTS bot_knowledge_base CASCADE; +DROP TABLE IF EXISTS bot_categories CASCADE; +DROP TABLE IF EXISTS bot_users CASCADE; +DROP TABLE IF EXISTS bot_consent_logs CASCADE; +DROP TABLE IF EXISTS bot_features CASCADE; +DROP TABLE IF EXISTS bot_settings CASCADE; +DROP TABLE IF EXISTS bot_notification_subscriptions CASCADE; +DROP TABLE IF EXISTS bot_notifications CASCADE; +DROP TABLE IF EXISTS bot_risk_questions CASCADE; +DROP TABLE IF EXISTS bot_broadcasts CASCADE; +DROP TABLE IF EXISTS bot_response_templates CASCADE; +DROP TABLE IF EXISTS file_sync_queue CASCADE; + +-- 1. BOT SETTINGS +CREATE TABLE IF NOT EXISTS bot_settings ( + id SERIAL PRIMARY KEY, + key VARCHAR(255) UNIQUE NOT NULL, + value TEXT, + created_at TIMESTAMP DEFAULT now(), + updated_at TIMESTAMP DEFAULT now() +); + +-- 2. BOT FEATURES +CREATE TABLE IF NOT EXISTS bot_features ( + id SERIAL PRIMARY KEY, + key VARCHAR(255) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + is_enabled BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT now() +); + +-- 3. BOT CATEGORIES (for knowledge base) +CREATE TABLE IF NOT EXISTS bot_categories ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT now() +); + +-- 4. BOT KNOWLEDGE BASE (Q&A pairs) +CREATE TABLE IF NOT EXISTS bot_knowledge_base ( + id SERIAL PRIMARY KEY, + category_id INTEGER REFERENCES bot_categories(id) ON DELETE SET NULL, + question TEXT NOT NULL, + answer TEXT, + keywords TEXT[], + is_active BOOLEAN DEFAULT TRUE, + source_url VARCHAR(500), + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT now(), + updated_at TIMESTAMP DEFAULT now() +); + +-- 5. BOT USERS (MAX messenger users) +CREATE TABLE IF NOT EXISTS bot_users ( + id BIGINT PRIMARY KEY, + first_name VARCHAR(255), + last_name VARCHAR(255), + patronymic VARCHAR(255), + username VARCHAR(255), + phone VARCHAR(50), + email VARCHAR(255), + organization VARCHAR(255), + address TEXT, + vcf_raw TEXT, + contact_hash VARCHAR(255), + phone_verified BOOLEAN DEFAULT FALSE, + email_verified BOOLEAN DEFAULT FALSE, + consent_given BOOLEAN DEFAULT FALSE, + consent_date TIMESTAMP, + last_interaction TIMESTAMP, + total_conversations INTEGER DEFAULT 0, + total_tickets INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT now(), + updated_at TIMESTAMP DEFAULT now() +); + +-- 6. BOT CONVERSATIONS +CREATE TABLE IF NOT EXISTS bot_conversations ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES bot_users(id) ON DELETE CASCADE, + state VARCHAR(50) DEFAULT 'greeting', + intent VARCHAR(50), + inquiry_text TEXT, + created_at TIMESTAMP DEFAULT now(), + updated_at TIMESTAMP DEFAULT now() +); + +-- 7. BOT MESSAGES +CREATE TABLE IF NOT EXISTS bot_messages ( + id BIGSERIAL PRIMARY KEY, + conversation_id BIGINT NOT NULL REFERENCES bot_conversations(id) ON DELETE CASCADE, + direction VARCHAR(10) NOT NULL, + text TEXT, + attachment_json TEXT, + created_at TIMESTAMP DEFAULT now() +); + +-- 8. BOT TICKETS (support tickets) +CREATE TABLE IF NOT EXISTS bot_tickets ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES bot_users(id) ON DELETE CASCADE, + title VARCHAR(500), + description TEXT, + priority VARCHAR(20) DEFAULT 'normal', + status VARCHAR(20) DEFAULT 'Новая', + created_by VARCHAR(100) DEFAULT 'bot', + assigned_to BIGINT, + created_at TIMESTAMP DEFAULT now(), + updated_at TIMESTAMP DEFAULT now() +); + +-- 9. BOT TICKET MESSAGES +CREATE TABLE IF NOT EXISTS bot_ticket_messages ( + id BIGSERIAL PRIMARY KEY, + ticket_id BIGINT NOT NULL REFERENCES bot_tickets(id) ON DELETE CASCADE, + direction VARCHAR(10) NOT NULL, + text TEXT, + created_at TIMESTAMP DEFAULT now() +); + +-- 10. BOT TICKET STATUSES (change log) +CREATE TABLE IF NOT EXISTS bot_ticket_statuses ( + id BIGSERIAL PRIMARY KEY, + ticket_id BIGINT NOT NULL REFERENCES bot_tickets(id) ON DELETE CASCADE, + old_status VARCHAR(20), + new_status VARCHAR(20) NOT NULL, + changed_by VARCHAR(100), + created_at TIMESTAMP DEFAULT now() +); + +-- ============================================ +-- CLEANUP: Drop unused tables from old bot +-- ============================================ +DROP TABLE IF EXISTS bot_notification_subscriptions CASCADE; +DROP TABLE IF EXISTS bot_notifications CASCADE; +DROP TABLE IF EXISTS bot_risk_questions CASCADE; +DROP TABLE IF EXISTS bot_broadcasts CASCADE; +DROP TABLE IF EXISTS file_sync_queue CASCADE; +DROP TABLE IF EXISTS bot_response_templates CASCADE; + +-- ============================================ +-- INDEXES +-- ============================================ +CREATE INDEX IF NOT EXISTS idx_bot_conversations_user_id ON bot_conversations(user_id); +CREATE INDEX IF NOT EXISTS idx_bot_conversations_state ON bot_conversations(state); +CREATE INDEX IF NOT EXISTS idx_bot_messages_conversation_id ON bot_messages(conversation_id); +CREATE INDEX IF NOT EXISTS idx_bot_tickets_user_id ON bot_tickets(user_id); +CREATE INDEX IF NOT EXISTS idx_bot_tickets_status ON bot_tickets(status); +CREATE INDEX IF NOT EXISTS idx_bot_ticket_messages_ticket_id ON bot_ticket_messages(ticket_id); +CREATE INDEX IF NOT EXISTS idx_bot_knowledge_base_category ON bot_knowledge_base(category_id); +CREATE INDEX IF NOT EXISTS idx_bot_knowledge_base_active ON bot_knowledge_base(is_active); +CREATE INDEX IF NOT EXISTS idx_bot_knowledge_base_keywords ON bot_knowledge_base USING gin(keywords); diff --git a/max_bot/sql/seed_knowledge_base.sql b/max_bot/sql/seed_knowledge_base.sql new file mode 100644 index 0000000..816ad65 --- /dev/null +++ b/max_bot/sql/seed_knowledge_base.sql @@ -0,0 +1,194 @@ +-- ============================================ +-- Categories +-- ============================================ +INSERT INTO bot_categories (id, name, description, sort_order) VALUES +(1, 'О компании', 'Общая информация о компании AegisOne Engineering', 1), +(2, 'Аудит безопасности', 'Услуги по независимому аудиту систем безопасности', 2), +(3, 'Сервисное обслуживание (SLA)', 'Регулярное обслуживание и SLA-контракты', 3), +(4, 'Реагирование на инциденты', 'Мониторинг и реагирование на инциденты безопасности', 4), +(5, 'Технический надзор', 'Контроль качества монтажа и пусконаладки', 5), +(6, 'Документация и нормативы', 'Проектная документация и соответствие нормативам', 6), +(7, 'Риск-инжиниринг', 'Оценка и управление рисками безопасности', 7), +(8, 'Отрасли и кейсы', 'Отрасли, в которых работаем, и примеры из практики', 8) +ON CONFLICT (id) DO NOTHING; + +-- ============================================ +-- 40 Q&A pairs +-- ============================================ +TRUNCATE bot_knowledge_base RESTART IDENTITY CASCADE; + +-- === CATEGORY 1: О КОМПАНИИ (6 with answers) === +INSERT INTO bot_knowledge_base (category_id, question, answer, keywords, is_active, source_url, sort_order) VALUES +(1, 'Чем занимается AegisOne Engineering?', + 'AegisOne Engineering предоставляет инженерные решения для безопасности бизнеса: аудит, сервисное обслуживание и технический надзор систем безопасности. Мы снижаем риски, обеспечиваем стабильность и подтверждаем соответствие нормативам.', + ARRAY['компания', 'услуги', 'безопасность', 'инжиниринг', 'аудит', 'сервис', 'надзор'], TRUE, 'https://aegisone.ru/', 1), + +(1, 'Где находится AegisOne Engineering?', + 'Юридический адрес: 350072, Россия, Краснодарский край, г. Краснодар, ул. Московская, д. 81/1, пом. 25. Работаем по всему Краснодарскому краю, ЮФО и всей России с выездом специалиста на объект.', + ARRAY['адрес', 'краснодар', 'местоположение', 'офис', 'где найти'], TRUE, 'https://aegisone.ru/', 2), + +(1, 'Сколько лет работает компания?', + 'Компания работает более 20 лет на рынке Краснодара, Краснодарского края и Южного федерального округа.', + ARRAY['история', 'стаж', 'опыт', 'лет', 'компания'], TRUE, 'https://aegisone.ru/', 3), + +(1, 'Какая миссия компании?', + 'Наша миссия — давать руководителям объективную картину состояния их систем безопасности, выявляя скрытые угрозы и экономя ресурсы. Мы не продаём оборудование — мы продаём спокойствие и стабильность.', + ARRAY['миссия', 'цель', 'принципы', 'философия'], TRUE, 'https://aegisone.ru/', 4), + +(1, 'Продаёт ли AegisOne Engineering оборудование?', + 'Нет, мы не продаём оборудование. Мы работаем с системами безопасности «под ключ», но в рамках независимых услуг выступаем исключительно как эксперты. Наша задача — объективно оценить ситуацию и обеспечить работоспособность.', + ARRAY['оборудование', 'продажа', 'монтаж', 'не продаём'], TRUE, 'https://aegisone.ru/', 5), + +(1, 'Какие гарантии даёт компания?', + 'Для SLA-контрактов гарантируем время реакции, периодичность ТО и KPI, прописанные в договоре. Для аудита — полноту и объективность отчёта. Все специалисты имеют профильное образование и опыт от 10 лет.', + ARRAY['гарантии', 'качество', 'kpi', 'сроки', 'специалисты'], TRUE, 'https://aegisone.ru/', 6); + +-- === CATEGORY 2: АУДИТ БЕЗОПАСНОСТИ (4 with answers, 2 inactive) === +INSERT INTO bot_knowledge_base (category_id, question, answer, keywords, is_active, source_url, sort_order) VALUES +(2, 'Что такое независимый аудит безопасности?', + 'Это объективная оценка состояния систем видеонаблюдения, СКУД и ОПС с выявлением уязвимостей и скрытых рисков. Результат — детальный отчёт с фотографиями, рекомендациями и дорожной картой.', + ARRAY['аудит', 'независимый', 'оценка', 'проверка', 'безопасность'], TRUE, 'https://aegisone.ru/audit/', 7), + +(2, 'Что входит в аудит систем безопасности?', + 'Входит проверка систем видеонаблюдения (состояние камер, покрытие, качество записи), СКУД (уязвимости, корректность конфигурации) и ОПС (эффективность обнаружения угроз, своевременность реагирования).', + ARRAY['аудит', 'видеонаблюдение', 'скуда', 'опс', 'проверка'], TRUE, 'https://aegisone.ru/audit/', 8), + +(2, 'Сколько стоит аудит?', + 'Стоимость зависит от объекта: площади, количества систем, удалённости. Называем цену после предварительного обсуждения — это бесплатно и ни к чему не обязывает.', + ARRAY['аудит', 'цена', 'стоимость', 'сколько стоит'], TRUE, 'https://aegisone.ru/audit/', 9), + +(2, 'Как проходит аудит?', + 'В 5 этапов: 1) первичное обсуждение, 2) анализ документов, 3) выезд на объект (визуальный и инструментальный осмотр), 4) подготовка отчёта, 5) презентация результатов руководству.', + ARRAY['аудит', 'процесс', 'этапы', 'как проходит'], TRUE, 'https://aegisone.ru/audit/', 10), + +(2, 'Чем аудит отличается от внутренней проверки службы безопасности?', + NULL, + ARRAY['аудит', 'отличие', 'внутренняя проверка', 'собственная служба'], FALSE, 'https://aegisone.ru/audit/', 11), + +(2, 'Что содержится в отчёте по аудиту?', + NULL, + ARRAY['отчёт', 'результат', 'аудит', 'содержание'], FALSE, 'https://aegisone.ru/audit/', 12); + +-- === CATEGORY 3: SLA (3 with answers, 2 inactive) === +INSERT INTO bot_knowledge_base (category_id, question, answer, keywords, is_active, source_url, sort_order) VALUES +(3, 'Что входит в сервисное обслуживание (SLA)?', + 'Регулярное техническое обслуживание, 24/7 мониторинг систем, гарантированное время реакции, отчётность по KPI. Включает профилактику отказов и плановые проверки.', + ARRAY['sla', 'сервис', 'обслуживание', 'то', 'техническое'], TRUE, 'https://aegisone.ru/sla/', 13), + +(3, 'Какие тарифы SLA есть?', + 'Базовый (от 15 000 ₽/мес): ежемесячное ТО, 48ч реакция, удалённый мониторинг. Оптимальный (от 30 000 ₽/мес): ТО 2 раза/мес, 24ч реакция, 24/7 мониторинг. Максимальный (от 60 000 ₽/мес): еженедельное ТО, 12ч реакция, выделенный инженер.', + ARRAY['sla', 'тарифы', 'цены', 'стоимость', 'базовый', 'оптимальный', 'максимальный'], TRUE, 'https://aegisone.ru/sla/', 14), + +(3, 'Нужно ли проводить аудит перед заключением SLA?', + 'Рекомендуется, чтобы определить фактическое состояние систем и оптимальный тариф. Точное предложение формируется после аудита.', + ARRAY['аудит', 'sla', 'перед', 'нужен ли'], TRUE, 'https://aegisone.ru/sla/', 15), + +(3, 'Как контролируется качество обслуживания по SLA?', + NULL, + ARRAY['контроль', 'качество', 'sla', 'отчётность', 'kpi'], FALSE, 'https://aegisone.ru/sla/', 16), + +(3, 'Что происходит при аварийной ситуации по SLA?', + NULL, + ARRAY['авария', 'sla', 'экстренная', 'реагирование', 'поломка'], FALSE, 'https://aegisone.ru/sla/', 17); + +-- === CATEGORY 4: ИНЦИДЕНТЫ (3 with answers, 1 inactive) === +INSERT INTO bot_knowledge_base (category_id, question, answer, keywords, is_active, source_url, sort_order) VALUES +(4, 'Что такое реагирование на инциденты?', + 'Это 24/7 мониторинг систем безопасности и оперативное реагирование на инциденты. Включает расследование причин, устранение последствий и профилактику повторений.', + ARRAY['инцидент', 'реагирование', 'мониторинг', '24/7'], TRUE, 'https://aegisone.ru/incident/', 18), + +(4, 'Как быстро вы реагируете на инцидент?', + 'Время реакции — от 4 до 24 часов в зависимости от тарифа SLA. Возможен экстренный выезд.', + ARRAY['скорость', 'время', 'реакция', 'инцидент', 'быстро'], TRUE, 'https://aegisone.ru/incident/', 19), + +(4, 'Какие системы вы мониторите?', + 'Видеонаблюдение, СКУД, охранно-пожарную сигнализацию (ОПС) и системы периметральной защиты.', + ARRAY['мониторинг', 'системы', 'видеонаблюдение', 'скуда', 'опс'], TRUE, 'https://aegisone.ru/incident/', 20), + +(4, 'Нужен ли договор для разового вызова на инцидент?', + NULL, + ARRAY['договор', 'разовый', 'инцидент', 'без договора'], FALSE, 'https://aegisone.ru/incident/', 21); + +-- === CATEGORY 5: ТЕХНИЧЕСКИЙ НАДЗОР (3 with answers, 1 inactive) === +INSERT INTO bot_knowledge_base (category_id, question, answer, keywords, is_active, source_url, sort_order) VALUES +(5, 'Что такое технический надзор?', + 'Это независимый контроль качества монтажа и пусконаладки систем безопасности. Гарантируем соответствие проекту, нормативам и срокам.', + ARRAY['технический надзор', 'контроль', 'монтаж', 'качество', 'пусконаладка'], TRUE, 'https://aegisone.ru/supervision/', 22), + +(5, 'Когда нужен технический надзор?', + 'При строительстве или реконструкции объекта, при модернизации систем безопасности, а также при приёмке объекта в эксплуатацию.', + ARRAY['надзор', 'когда нужен', 'строительство', 'модернизация', 'приёмка'], TRUE, 'https://aegisone.ru/supervision/', 23), + +(5, 'Чем технадзор отличается от авторского надзора?', + 'Авторский надзор ведётся проектировщиком и проверяет соответствие проекту. Технический надзор — это независимый контроль качества, сроков и соблюдения норм.', + ARRAY['отличие', 'авторский', 'технадзор', 'проектировщик'], TRUE, 'https://aegisone.ru/supervision/', 24), + +(5, 'Как документируются результаты технадзора?', + NULL, + ARRAY['документы', 'акты', 'технадзор', 'результаты'], FALSE, 'https://aegisone.ru/supervision/', 25); + +-- === CATEGORY 6: ДОКУМЕНТАЦИЯ (3 with answers, 1 inactive) === +INSERT INTO bot_knowledge_base (category_id, question, answer, keywords, is_active, source_url, sort_order) VALUES +(6, 'Какие документы вы разрабатываете?', + 'Проектную документацию для видеонаблюдения, СКУД, ОПС, периметральной защиты. А также проводим экспертизу существующей документации и аудит соответствия нормативам.', + ARRAY['документация', 'проектная', 'разработка', 'скуда', 'опс', 'пожарная'], TRUE, 'https://aegisone.ru/documentation/', 26), + +(6, 'Какие документы нужны для сдачи объекта?', + 'Зависит от типа объекта. Обычно: проектная документация (стадии П и Р), исполнительная документация, акты скрытых работ, паспорта и сертификаты оборудования, акты приёмки.', + ARRAY['сдача', 'документы', 'объект', 'приёмка', 'исполнительная'], TRUE, 'https://aegisone.ru/documentation/', 27), + +(6, 'Есть ли лицензия на проектирование?', + 'Да, компания имеет все необходимые разрешения и компетенции для проектирования объектов любого класса сложности.', + ARRAY['лицензия', 'разрешение', 'проектирование', 'сро'], TRUE, 'https://aegisone.ru/documentation/', 28), + +(6, 'Можно ли восстановить утерянную документацию?', + NULL, + ARRAY['восстановление', 'документация', 'утеряна', 'исполнительная схема'], FALSE, 'https://aegisone.ru/documentation/', 29); + +-- === CATEGORY 7: РИСК-ИНЖИНИРИНГ (3 with answers, 1 inactive) === +INSERT INTO bot_knowledge_base (category_id, question, answer, keywords, is_active, source_url, sort_order) VALUES +(7, 'Что такое риск-инжиниринг?', + 'Это системный подход к управлению рисками безопасности: оценка, анализ и разработка стратегии защиты. Помогает защитить бизнес от финансовых и репутационных потерь.', + ARRAY['риск', 'инжиниринг', 'управление', 'оценка', 'стратегия'], TRUE, 'https://aegisone.ru/risk/', 30), + +(7, 'Чем риск-инжиниринг отличается от аудита?', + 'Аудит оценивает текущее состояние систем. Риск-инжиниринг шире: анализирует бизнес-процессы, угрозы, сценарии и предлагает стратегию с экономическим обоснованием.', + ARRAY['отличие', 'риск-инжиниринг', 'аудит', 'разница'], TRUE, 'https://aegisone.ru/risk/', 31), + +(7, 'Как измерить эффективность безопасности?', + 'Через ROI безопасности: сравниваем стоимость риска «до» и «после» внедрения мер. Даём конкретные цифры и сроки окупаемости.', + ARRAY['эффективность', 'roi', 'безопасность', 'измерение', 'окупаемость'], TRUE, 'https://aegisone.ru/risk/', 32), + +(7, 'Как часто нужно обновлять оценку рисков?', + NULL, + ARRAY['обновление', 'периодичность', 'риски', 'оценка', 'частота'], FALSE, 'https://aegisone.ru/risk/', 33); + +-- === CATEGORY 8: ОТРАСЛИ И КЕЙСЫ (7 inactive — all need answers) === +INSERT INTO bot_knowledge_base (category_id, question, answer, keywords, is_active, source_url, sort_order) VALUES +(8, 'В каких отраслях вы работаете?', + NULL, + ARRAY['отрасли', 'клиенты', 'ритейл', 'склады', 'производство', 'офисы', 'гостиницы', 'банки'], FALSE, 'https://aegisone.ru/', 34), + +(8, 'Работаете ли вы с торговыми центрами?', + NULL, + ARRAY['тц', 'торговый центр', 'ритейл', 'безопасность'], FALSE, 'https://aegisone.ru/', 35), + +(8, 'Работаете ли вы с производственными предприятиями?', + NULL, + ARRAY['производство', 'завод', 'промышленность', 'безопасность'], FALSE, 'https://aegisone.ru/', 36), + +(8, 'Был ли у вас кейс со складом торговой сети?', + NULL, + ARRAY['кейс', 'склад', 'торговая сеть', 'пример'], FALSE, 'https://aegisone.ru/', 37), + +(8, 'Какой результат вы получили для банковского офиса?', + NULL, + ARRAY['банк', 'офис', 'опс', 'пожарная', 'кейс'], FALSE, 'https://aegisone.ru/', 38), + +(8, 'Что такое метод «Тайный покупатель»?', + NULL, + ARRAY['тайный покупатель', 'оценка', 'персонал', 'охрана', 'метод'], FALSE, 'https://aegisone.ru/', 39), + +(8, 'Есть ли у вас кейсы для гостиниц?', + NULL, + ARRAY['гостиница', 'отель', 'кейс', 'скуда', 'бронирование'], FALSE, 'https://aegisone.ru/', 40); diff --git a/max_bot/tests/__pycache__/__init__.cpython-314.pyc b/max_bot/tests/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 83e7681..0000000 Binary files a/max_bot/tests/__pycache__/__init__.cpython-314.pyc and /dev/null 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 deleted file mode 100644 index f9865d7..0000000 Binary files a/max_bot/tests/__pycache__/conftest.cpython-314-pytest-9.0.3.pyc and /dev/null 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 deleted file mode 100644 index 820ed8d..0000000 Binary files a/max_bot/tests/__pycache__/test_fsm.cpython-314-pytest-9.0.3.pyc and /dev/null 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 deleted file mode 100644 index 50fbc8f..0000000 Binary files a/max_bot/tests/__pycache__/test_handlers.cpython-314-pytest-9.0.3.pyc and /dev/null 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 deleted file mode 100644 index 1ccd866..0000000 Binary files a/max_bot/tests/__pycache__/test_max_api.cpython-314-pytest-9.0.3.pyc and /dev/null 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 deleted file mode 100644 index 76e3165..0000000 Binary files a/max_bot/tests/__pycache__/test_webhook.cpython-314-pytest-9.0.3.pyc and /dev/null differ diff --git a/max_bot/tests/conftest.py b/max_bot/tests/conftest.py deleted file mode 100644 index 280f53d..0000000 --- a/max_bot/tests/conftest.py +++ /dev/null @@ -1,78 +0,0 @@ -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_email_sender.py b/max_bot/tests/test_email_sender.py new file mode 100644 index 0000000..5f169de --- /dev/null +++ b/max_bot/tests/test_email_sender.py @@ -0,0 +1,57 @@ +import pytest +import os +from unittest.mock import patch, MagicMock +from app.email_sender import EmailSender + + +@pytest.fixture +def sender(): + return EmailSender() + + +def test_send_debug_mode(sender): + with patch("os.name", "nt"): + result = sender.send("test@test.com", "Test", "Body", "from@test.com") + assert result is True + + log_path = os.path.join(os.path.dirname(__file__), "..", "logs", "emails.log") + if os.path.isfile(log_path): + with open(log_path, "r", encoding="utf-8") as f: + content = f.read() + assert "test@test.com" in content + assert "Test" in content + os.remove(log_path) + + +def test_send_linux_mode(sender): + mock_proc = MagicMock() + mock_proc.returncode = 0 + + with patch("os.name", "posix"), \ + patch("subprocess.run", return_value=mock_proc): + result = sender.send("test@test.com", "Test", "Body", "from@test.com") + assert result is True + + +def test_send_with_default_from(sender): + with patch("app.email_sender.config_reader") as mock_config: + mock_config.get_mail_from.return_value = "no-reply@aegisone.ru" + result = sender.send("test@test.com", "Test", "Body") + assert result is True + + +def test_send_ticket_escalation(sender): + with patch.object(sender, "send", return_value=True) as mock_send: + result = sender.send_ticket_escalation( + to="support@aegisone.ru", + subject="Test", + user_info="ID: 123", + contact="+79990000000", + inquiry="Need help", + history="Client: Hello", + ) + assert result is True + mock_send.assert_called_once() + args = mock_send.call_args[0] + assert "ID: 123" in args[2] + assert "Need help" in args[2] diff --git a/max_bot/tests/test_fsm.py b/max_bot/tests/test_fsm.py deleted file mode 100644 index f9a6656..0000000 --- a/max_bot/tests/test_fsm.py +++ /dev/null @@ -1,168 +0,0 @@ -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 index 5a59f16..4dd77cd 100644 --- a/max_bot/tests/test_handlers.py +++ b/max_bot/tests/test_handlers.py @@ -1,179 +1,174 @@ import pytest -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock, patch, ANY +from app.handlers.greeting import analyze_and_respond +from app.handlers.consent import handle_consent_response +from app.handlers.contact import handle_manual_contact +from app.handlers.inquiry import handle_inquiry +from app.handlers.handoff import handle_callback +from app.models import BotConversation, BotUser, BotTicket, BotKnowledgeBase -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 + +def make_mock_db(): + db = AsyncMock() + result = MagicMock() + result.scalar_one_or_none.return_value = None + result.scalars.return_value.all.return_value = [] + db.execute.return_value = result + return db @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": "Петров"} +async def test_consent_response_yes(): + mock_max_api = MagicMock() + mock_max_api.send_message = AsyncMock() - await SettingsCache.refresh(test_session) - await handle_start(bot, user_data, test_session, mock_max_api) + with patch("app.handlers.consent.max_api", mock_max_api), \ + patch("app.handlers.consent.async_session") as mock_session: - 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 + mock_db = make_mock_db() + mock_session.return_value.__aenter__.return_value = mock_db + + mock_user = MagicMock(spec=BotUser) + mock_user.consent_given = False + mock_user.consent_date = None + mock_conv = MagicMock(spec=BotConversation) + mock_conv.id = 1 + + mock_result = MagicMock() + mock_result.scalar_one_or_none.side_effect = [mock_user, mock_conv] + mock_db.execute.return_value = mock_result + + await handle_consent_response(12345, 1, "даю согласие") + assert mock_max_api.send_message.called @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": "Пётр"} +async def test_consent_response_no(): + mock_max_api = MagicMock() + mock_max_api.send_message = AsyncMock() - user = await get_or_create_user(test_session, 67890, "Пётр") - await test_session.flush() - await create_conversation(test_session, user.id) - await test_session.flush() + with patch("app.handlers.consent.max_api", mock_max_api), \ + patch("app.handlers.consent.async_session") as mock_session, \ + patch("app.handlers.consent.config_reader") as mock_config: - await SettingsCache.refresh(test_session) - await handle_start(bot, user_data, test_session, mock_max_api) + mock_db = make_mock_db() + mock_session.return_value.__aenter__.return_value = mock_db - mock_max_api.send_message_with_keyboard.assert_called_once() - assert FSM.get_state(67890) == BotState.IDLE + mock_config.get_phones.return_value = [("+7 (861) 203-33-30", "+78612033330")] + mock_config.get_site_mail.return_value = "mail@aegisone.ru" + + mock_conv = MagicMock(spec=BotConversation) + mock_conv.id = 1 + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = mock_conv + mock_db.execute.return_value = mock_result + + await handle_consent_response(12345, 1, "нет") + assert mock_max_api.send_message.called @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": "Анна"} +async def test_manual_contact_valid_phone(): + mock_max_api = MagicMock() + mock_max_api.send_message = AsyncMock() - await SettingsCache.refresh(test_session) - await handle_consent_request(bot, user_data, test_session, mock_max_api) + with patch("app.handlers.contact.max_api", mock_max_api), \ + patch("app.handlers.contact.async_session") as mock_session, \ + patch("app.handlers.contact.handle_contact_received", + new=AsyncMock()) as mock_received: - mock_max_api.send_message_with_keyboard.assert_called_once() - assert FSM.get_state(111) == BotState.CONSENT_REQUEST + mock_db = make_mock_db() + mock_session.return_value.__aenter__.return_value = mock_db + + await handle_manual_contact(12345, 1, "+78612033330") + assert mock_received.called @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} +async def test_manual_contact_invalid(): + mock_max_api = MagicMock() + mock_max_api.send_message = AsyncMock() - user = await get_or_create_user(test_session, 222) - await test_session.flush() + with patch("app.handlers.contact.max_api", mock_max_api), \ + patch("app.handlers.contact.async_session") as mock_session: - await handle_consent_given(bot, user_data, test_session, mock_max_api) + mock_db = make_mock_db() + mock_session.return_value.__aenter__.return_value = mock_db - assert user.consent_given is True - mock_max_api.send_message_with_keyboard.assert_called_once() - assert FSM.get_state(222) == BotState.CONTACT_REQUEST + await handle_manual_contact(12345, 1, "просто текст") + assert mock_max_api.send_message.called @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} +async def test_callback_consent_yes(): + mock_max_api = MagicMock() + mock_max_api.send_message = AsyncMock() - user = await get_or_create_user(test_session, 333) - await test_session.flush() + with patch("app.handlers.handoff.max_api", mock_max_api), \ + patch("app.handlers.consent.max_api", mock_max_api), \ + patch("app.handlers.handoff.async_session") as mock_hoff_session, \ + patch("app.handlers.consent.async_session") as mock_consent_session: - await SettingsCache.refresh(test_session) - await handle_consent_refused(bot, user_data, test_session, mock_max_api) + mock_db = make_mock_db() + mock_hoff_session.return_value.__aenter__.return_value = mock_db + mock_consent_session.return_value.__aenter__.return_value = mock_db - mock_max_api.send_message_with_keyboard.assert_called_once() - assert FSM.get_state(333) == BotState.IDLE + mock_conv = MagicMock(spec=BotConversation) + mock_conv.id = 1 + mock_user = MagicMock(spec=BotUser) + mock_user.consent_given = False + mock_user.consent_date = None + + mock_result = MagicMock() + mock_result.scalar_one_or_none.side_effect = [mock_conv, mock_user, mock_conv] + mock_db.execute.return_value = mock_result + + await handle_callback(12345, "consent_yes", {}) + assert mock_max_api.send_message.called @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"} +async def test_inquiry_handling(): + mock_max_api = MagicMock() + mock_max_api.send_message = AsyncMock() - await SettingsCache.refresh(test_session) - await handle_main_menu(bot, user_data, test_session, mock_max_api) + mock_email = MagicMock() + mock_email.send_ticket_escalation = MagicMock(return_value=True) - # Should go to consent request - mock_max_api.send_message_with_keyboard.assert_called_once() + with patch("app.handlers.inquiry.max_api", mock_max_api), \ + patch("app.handlers.inquiry.email_sender", mock_email), \ + patch("app.handlers.inquiry.async_session") as mock_session, \ + patch("app.handlers.inquiry.settings_cache") as mock_cache: + mock_db = make_mock_db() + mock_session.return_value.__aenter__.return_value = mock_db -@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"} + mock_conv = MagicMock(spec=BotConversation) + mock_conv.id = 1 + mock_conv.intent = "ticket" + mock_conv.state = "awaiting_inquiry" + mock_conv.inquiry_text = None - await SettingsCache.refresh(test_session) - await handle_main_menu(bot, user_data, test_session, mock_max_api) + mock_user = MagicMock(spec=BotUser) + mock_user.id = 12345 + mock_user.first_name = "Иван" + mock_user.phone = "+78612033330" - mock_max_api.send_message_with_keyboard.assert_called_once() - assert FSM.get_state(555) == BotState.IDLE + mock_conv_result = MagicMock() + mock_conv_result.scalar_one_or_none.return_value = mock_conv + mock_user_result = MagicMock() + mock_user_result.scalar_one_or_none.return_value = mock_user + mock_db.execute.side_effect = [ + mock_conv_result, mock_user_result, + mock_conv_result, + MagicMock(), MagicMock(), MagicMock(), + ] -@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"} + mock_cache.get = AsyncMock(side_effect=lambda k, d="": { + "support_email": "support@aegisone.ru", + "email_subject": "Test subject" + }.get(k, d)) - 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 + await handle_inquiry(12345, 1, "Нужна консультация") + assert mock_max_api.send_message.called diff --git a/max_bot/tests/test_max_api.py b/max_bot/tests/test_max_api.py index 42b1e34..b2a3790 100644 --- a/max_bot/tests/test_max_api.py +++ b/max_bot/tests/test_max_api.py @@ -1,134 +1,57 @@ -from unittest.mock import AsyncMock, patch, MagicMock - import pytest - -from app.max_api import MaxAPIClient +from unittest.mock import AsyncMock, MagicMock, patch +from app.max_api import MaxAPI @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" + return MaxAPI() @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} + mock_response = MagicMock() + mock_response.json.return_value = {"success": True} + + with patch.object(api.client, "post", new=AsyncMock(return_value=mock_response)): + result = await api.subscribe_webhook() + assert result == {"success": 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} +async def test_send_message(api): + mock_response = MagicMock() + mock_response.json.return_value = {"ok": True} + + with patch.object(api.client, "post", new=AsyncMock(return_value=mock_response)): + result = await api.send_message(12345, "test", format="markdown") + 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} +async def test_send_message_with_keyboard(api): + mock_response = MagicMock() + mock_response.json.return_value = {"ok": True} + keyboard = [{"type": "inline_keyboard", "payload": {"buttons": [[{"type": "callback", "text": "OK", "payload": "test"}]]}}] + + with patch.object(api.client, "post", new=AsyncMock(return_value=mock_response)): + result = await api.send_message(12345, "test", attachments=keyboard) + 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" +async def test_answer_callback_with_notification(api): + mock_response = MagicMock() + mock_response.json.return_value = {"success": True} + + with patch.object(api.client, "post", new=AsyncMock(return_value=mock_response)): + result = await api.answer_callback("cb_123", notification="OK") + assert result == {"success": True} @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): +async def test_headers(api): headers = api._headers() - assert headers["Authorization"] == "test-token" + assert "Authorization" in headers + assert "Content-Type" in headers 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 deleted file mode 100644 index 522a1d7..0000000 --- a/max_bot/tests/test_webhook.py +++ /dev/null @@ -1,183 +0,0 @@ -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/tests/test_yandex_gpt.py b/max_bot/tests/test_yandex_gpt.py new file mode 100644 index 0000000..99a47f1 --- /dev/null +++ b/max_bot/tests/test_yandex_gpt.py @@ -0,0 +1,59 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from app.yandex_gpt import YandexGPT + + +@pytest.fixture +def gpt(): + return YandexGPT() + + +@pytest.mark.asyncio +async def test_analyze_intent_question(gpt): + gpt._load_credentials = AsyncMock(return_value=True) + + with patch("app.yandex_gpt.YandexGPT._request", new=AsyncMock(return_value="question")): + result = await gpt.analyze_intent("Что вы делаете?") + assert result == "question" + + +@pytest.mark.asyncio +async def test_analyze_intent_ticket(gpt): + gpt._load_credentials = AsyncMock(return_value=True) + + with patch("app.yandex_gpt.YandexGPT._request", new=AsyncMock(return_value="ticket")): + result = await gpt.analyze_intent("Хочу заказать аудит") + assert result == "ticket" + + +@pytest.mark.asyncio +async def test_analyze_intent_unknown(gpt): + gpt._load_credentials = AsyncMock(return_value=True) + + with patch("app.yandex_gpt.YandexGPT._request", new=AsyncMock(return_value=None)): + result = await gpt.analyze_intent("абырвалг") + assert result == "unknown" + + +@pytest.mark.asyncio +async def test_analyze_intent_unavailable(gpt): + gpt._load_credentials = AsyncMock(return_value=False) + result = await gpt.analyze_intent("Что вы делаете?") + assert result == "unknown" + + +@pytest.mark.asyncio +async def test_generate_answer(gpt): + gpt._load_credentials = AsyncMock(return_value=True) + + with patch("app.yandex_gpt.YandexGPT._request", new=AsyncMock(return_value="Мы предоставляем услуги аудита...")): + result = await gpt.generate_answer("Какие услуги?", "контекст") + assert result is not None + assert "аудита" in result + + +@pytest.mark.asyncio +async def test_generate_answer_unavailable(gpt): + gpt._load_credentials = AsyncMock(return_value=False) + result = await gpt.generate_answer("Какие услуги?", "контекст") + assert result is None diff --git a/max_bot/version.txt b/max_bot/version.txt index 9075be4..bd8bf88 100644 --- a/max_bot/version.txt +++ b/max_bot/version.txt @@ -1 +1 @@ -1.5.5 +1.7.0 diff --git a/max_bot/ТЗ.md b/max_bot/ТЗ.md new file mode 100644 index 0000000..7845a18 --- /dev/null +++ b/max_bot/ТЗ.md @@ -0,0 +1,112 @@ +Общая концепция +[Имя Бота] не виртуальный помощник, а AI-ассистент, созданный для имитации живого человеческого общения с клиентами. Её главная задача — быть первым контактным лицом компании, обеспечивая клиентам мгновенную, компетентную и эмпатичную поддержку 24/7 строго соблюдаея требования ФЗ-152. Она не использует жёсткие шаблоны ответов, а вместо этого [Имя Бота] анализирует контекст диалога с помощью технологий NLP (Natural Language Processing), что позволяет ей вести естественный, гибкий и осмысленный разговор, адаптируясь под стиль и потребности каждого клиента. Так же задача [Имя Бота] отвечать на общие вопросы используя Базу знаний. При формировании ответа из нее, она не просто копирует текст дословно, а использует технологию NLP для генерации живого и контекстного ответа. + +Технические требования к интеграции: + +Источник контактов: Для ответа на отказ в согласии (этап 3) и для отправки уведомлений используются данные из файла config.php: +Телефоны: раздел 2. ТЕЛЕФОНЫ. +Почта: переменная SITE_MAIL. +Эскалация на инженера: При необходимости создаётся заявка и отправляется письмо. +Отправитель: Почтовый ящик, указанный в переменной SITE_MAIL_FROM из config.php. +Получатель: Почта, указанная в настройках бота (Email поддержки). +Тема: Тема, указанная в настройках бота. +Содержание: Полный текст заявки. Клиенту отправляется подтверждение о передаче обращения специалисту. +Подробный сценарий работы +1. Приветствие и анализ запроса +Стартовое сообщение: +«Здравствуйте! Я [Имя Бота], помощник AegisOne Engineering. Чем я могу быть вам полезна?» + +Анализ ввода пользователя (NLP): +Бот распознаёт намерение: общий вопрос, желание оставить заявку, нестандартный запрос. +Если вопрос общий (о компании, услугах, контактах) — ответ формируется на основе базы знаний и анализа сайта aegisone.ru. +Если пользователь хочет оставить заявку — переход к этапу получения согласия. +Если запрос неформатный или неочевидный — бот уточняет суть обращения, фиксирует его и предлагает вернуться к обсуждению после получения согласия. +2. Согласие на обработку персональных данных (ФЗ-152) +Запрос согласия: +«Для обработки Вашего запроса нам нужны ваши контактные данные для обратной связи. Даёте согласие на обработку персональных данных?[✅ Даю согласие] [📄 Подробнее] [❌ Не даю согласие]» + +Кнопка «Подробнее» ведёт на страницу политики обработки данных. +Реакция на действия пользователя: +Согласие: переход к сбору контактов. +Отказ: Бот сообщает, что без согласия обработка невозможна, и предлагает альтернативные способы связи (телефон, почта). +Иной ввод: Бот объясняет важность согласия и возвращает к этапу запроса согласия. +3. При отказе в согласии +Сообщение: +«Без вашего согласия мы не можем обработать обращение. Вы можете позвонить нам: [Телефоны из config.php, раздел "2. ТЕЛЕФОНЫ"] или написать на почту [Переменная SITE_MAIL из config.php].» + +Дальнейшие действия: +Если пользователь пишет любой текст — бот объясняет, что связаться можно только по телефону или почте, и снова предлагает дать согласие. +Если пользователь выражает готовность дать согласие — возврат к этапу 2. +4. Сбор контактных данных +Варианты: +Кнопка [📱 Поделиться контактом] (автоматически передаёт номер телефона). +Ручной ввод телефона или e-mail. +Обработка данных: +Полученные контакты отправляются в статистику. +Вся переписка фиксируется для анализа и пополнения базы знаний. +5. Сбор сути обращения +Бот напоминает, что было зафиксировано на этапе анализа запроса (если был нестандартный вопрос), и просит уточнить детали или призвать к действию для понимания сути запроса. +6. Финальная обработка заявки +После сбора всех данных Бот подтверждает информацию: +«Зафиксировала: [суть обращения]. Ваша заявка отправлена инженеру. В ближайшее время он обязательно свяжется с вами». + +Роль бота с учётом NLP и интеграции с CRM +Распознавание намерений (NLP) +Бот анализирует текст пользователя, определяя: + +Информационный запрос (о компании, услугах). +Намерение оставить заявку (заказ, обратный звонок). +Нестандартный или сложный запрос (требует уточнения). +Интеграция с CRM (Сервисная часть портала) +Все заявки и контакты автоматически передаются в сервисную часть портала (страница заявок). В заявке фиксируется: + +Дата и время обращения. +Контактные данные клиента. +Текст обращения и история переписки. +Статус заявки (новая). +Ответственный: инженер. +Эскалация на инженера +Если NLP не может однозначно определить намерение или запрос требует участия специалиста: + +Бот создаёт заявку в системе со статусом «новая». +Система автоматически отправляет письмо с почтового ящика SITE_MAIL_FROM (из config.php) на Email поддержки с темой из настроек бота. В письме дублируется полный текст заявки. +Бот сообщает пользователю: +«Я передала ваш запрос специалисту. Он уже получил уведомление и обязательно свяжется с вами в ближайшее время». + +Диалог завершается. + +--- + +Настройка сервисной части (Настройки — Чат-бот Max) +Для корректной работы бота необходимо выполнить следующие настройки: + +Настройки — Чат-бот Max — Общие +Имя ассистента: [указать Имя Бота]. +Роль ассистента: [указать роль Бота]. +MAX-бот ID: [указать ID Бота]. +Email поддержки: [указать почту для получения заявок]. +Тема письма: [указать тему для уведомлений об эскалации]. + +Настройки — Чат-бот Max — База знаний +Заполнить максимально возможными вопросами (придумать 40шт) ориентируясь на публичную часть сайта (aegisone.ru). +Если есть вопрос, но нет ответа на публичной части сайта — создать карточку вопроса с ключевыми словами, категорией, но не активной карточкой. Сам ответ будет добавлен позже. +При формировании ответа бот не копирует текст из базы знаний дословно, а использует технологию NLP для генерации живого и контекстного ответа. + +Настройки — Чат-бот Max — Тестовый прогон +Настроить функционал для полного тестирования всех веток сценария перед открытием доступа клиентам. + +Настройки — Общие +Ключ YandexGPT: [указать ключ API]. Так же будет использоваться в другом функционале сервисной части. +Ключ Yandex Folder ID: [указать ключ ID]. Так же будет использоваться в другом функционале сервисной части. +Если поле «Ключ YandexGPT» пусто, то бот работает в ограниченном режиме: он не отвечает на вопросы из базы знаний через NLP, а только предоставляет контактные данные для связи: +«Вы можете позвонить нам: [Телефоны из config.php] или написать на почту [Переменная SITE_MAIL из config.php].» Контактные данные с активными ссылками. + +Статусы заявок +В системе заявок предусмотреть статусы: +Новая (создаётся ботом). +В работе (меняет инженер). +Закрыта (меняет инженер после решения вопроса). + +API YandexGPT: AQVN32jyeHHEZcikwLbdedZkp9XmRwTE_gJEX8RU +Yandex Folder ID: b1gl51d91s6tchaghp0v +MAX-бот ID: f9LHodD0cOKkk03DYXj906MdIeRs3HdDo0BC5H2fLMVCMz3PA_Vx4aJqt2l2qPLM5zd5EQ1Zh6APxzpw2UYE \ No newline at end of file diff --git a/nginx/conf.d/aegisone.conf b/nginx/conf.d/aegisone.conf new file mode 100644 index 0000000..4f34d36 --- /dev/null +++ b/nginx/conf.d/aegisone.conf @@ -0,0 +1,38 @@ +# aegisone.ru / www.aegisone.ru -> PHP frontend +server { + listen 80; + server_name aegisone.ru www.aegisone.ru; + + location /.well-known/acme-challenge/ { + root /usr/share/nginx/html; + } + + location / { + proxy_pass http://localhost:8080; + 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; + } +} + +server { + listen 443 ssl; + http2 on; + server_name aegisone.ru www.aegisone.ru; + + ssl_certificate /etc/nginx/certs/live/aegisone.ru.selfsigned/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/live/aegisone.ru.selfsigned/privkey.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + location / { + proxy_pass http://localhost:8080; + 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; + } +} diff --git a/nginx/conf.d/gitea.conf b/nginx/conf.d/gitea.conf new file mode 100644 index 0000000..6b19cc9 --- /dev/null +++ b/nginx/conf.d/gitea.conf @@ -0,0 +1,42 @@ +# git.aegisone.ru -> Gitea +server { + listen 80; + server_name git.aegisone.ru; + + location /.well-known/acme-challenge/ { + root /usr/share/nginx/html; + } + + client_max_body_size 50M; + + location / { + proxy_pass http://localhost:3000; + 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; + } +} + +server { + listen 443 ssl; + http2 on; + server_name git.aegisone.ru; + + ssl_certificate /etc/nginx/certs/live/aegisone.ru.selfsigned/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/live/aegisone.ru.selfsigned/privkey.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + client_max_body_size 50M; + + location / { + proxy_pass http://localhost:3000; + 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; + } +} diff --git a/max_bot/nginx/max-aegisone.conf b/nginx/conf.d/max-aegisone.conf similarity index 100% rename from max_bot/nginx/max-aegisone.conf rename to nginx/conf.d/max-aegisone.conf diff --git a/nginx/conf.d/service-aegisone.conf b/nginx/conf.d/service-aegisone.conf new file mode 100644 index 0000000..5c138d4 --- /dev/null +++ b/nginx/conf.d/service-aegisone.conf @@ -0,0 +1,70 @@ +# service.aegisone.ru -> FastAPI (aegisone-py) +server { + listen 80; + server_name service.aegisone.ru; + + location /.well-known/acme-challenge/ { + root /usr/share/nginx/html; + } + + location /service-style.php { + proxy_pass http://localhost:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + location /logo.php { + proxy_pass http://localhost:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + location /assets/ { + proxy_pass http://localhost:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location / { + proxy_pass http://localhost:8000; + 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; + } +} + +server { + listen 443 ssl; + http2 on; + server_name service.aegisone.ru; + + ssl_certificate /etc/nginx/certs/live/aegisone.ru.selfsigned/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/live/aegisone.ru.selfsigned/privkey.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + location /service-style.php { + proxy_pass http://localhost:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + location /logo.php { + proxy_pass http://localhost:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + location /assets/ { + proxy_pass http://localhost:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location / { + proxy_pass http://localhost:8000; + 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; + } +} diff --git a/nginx/docker-compose.yml b/nginx/docker-compose.yml new file mode 100644 index 0000000..1b4ea66 --- /dev/null +++ b/nginx/docker-compose.yml @@ -0,0 +1,21 @@ +services: + nginx: + image: nginx:alpine + container_name: nginx-proxy + network_mode: host + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + - ./certs:/etc/nginx/certs:ro + - ./conf.d:/etc/nginx/conf.d:ro + - ./html:/usr/share/nginx/html:ro + restart: unless-stopped + + certbot: + image: certbot/certbot:latest + container_name: certbot + network_mode: host + volumes: + - ./certs:/etc/letsencrypt + - ./html:/var/www/certbot + entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" + restart: unless-stopped diff --git a/nginx/fix_nginx.sh b/nginx/fix_nginx.sh new file mode 100644 index 0000000..05b85a6 --- /dev/null +++ b/nginx/fix_nginx.sh @@ -0,0 +1,124 @@ +#!/bin/bash +set -e + +echo "==========================================" +echo " AegisOne Nginx Fix Script" +echo "==========================================" + +NGINX_DIR="/opt/projects/nginx-proxy" + +# 1. Остановить текущий nginx, если запущен +echo "" +echo "[1/7] Остановка текущего nginx..." +cd "$NGINX_DIR" +docker compose down 2>/dev/null || true + +# 2. Сохранить старые конфиги +echo "" +echo "[2/7] Резервное копирование старых конфигов..." +BACKUP_DIR="/root/nginx-backup-$(date +%s)" +mkdir -p "$BACKUP_DIR" +if [ -f "$NGINX_DIR/nginx.conf" ]; then + cp "$NGINX_DIR/nginx.conf" "$BACKUP_DIR/" +fi +if [ -d "$NGINX_DIR/conf.d" ]; then + cp -r "$NGINX_DIR/conf.d" "$BACKUP_DIR/" +fi +echo " ✅ Бэкап: $BACKUP_DIR" + +# 3. Создать структуру директорий +echo "" +echo "[3/7] Создание структуры..." +mkdir -p "$NGINX_DIR/certs/live/aegisone.ru" +mkdir -p "$NGINX_DIR/conf.d" +mkdir -p "$NGINX_DIR/html" +mkdir -p "$NGINX_DIR/certs" + +# 4. Сгенерировать самоподписанный сертификат (временный, пока Let's Encrypt не выдаст настоящий) +echo "" +echo "[4/7] Генерация временного самоподписанного сертификата..." +if [ ! -f "$NGINX_DIR/certs/live/aegisone.ru/fullchain.pem" ]; then + openssl req -x509 -nodes -days 30 -newkey rsa:2048 \ + -keyout "$NGINX_DIR/certs/live/aegisone.ru/privkey.pem" \ + -out "$NGINX_DIR/certs/live/aegisone.ru/fullchain.pem" \ + -subj "/CN=aegisone.ru" \ + 2>/dev/null || { + echo " ⚠️ OpenSSL не найден, генерируем через Docker..." + docker run --rm -v "$NGINX_DIR/certs:/certs" alpine:latest sh -c " + apk add openssl >/dev/null 2>&1 + mkdir -p /certs/live/aegisone.ru + openssl req -x509 -nodes -days 30 -newkey rsa:2048 \ + -keyout /certs/live/aegisone.ru/privkey.pem \ + -out /certs/live/aegisone.ru/fullchain.pem \ + -subj '/CN=aegisone.ru' + " + } + echo " ✅ Самоподписанный сертификат создан" +else + echo " ℹ️ Сертификат уже существует" +fi + +# 5. Копирование конфигов +echo "" +echo "[5/7] Установка новых конфигов..." +cp nginx.conf "$NGINX_DIR/nginx.conf" +cp -r conf.d/* "$NGINX_DIR/conf.d/" +echo " ✅ Конфиги скопированы" + +# 6. Создать html/index.html для webroot (нужен certbot) +echo "" +echo "[6/7] Подготовка webroot..." +mkdir -p "$NGINX_DIR/html/.well-known/acme-challenge" +cat > "$NGINX_DIR/html/index.html" << 'EOF' + + +